diff --git a/assets/js/group-classes.js b/assets/js/group-classes.js
new file mode 100644
index 0000000..0b9af64
--- /dev/null
+++ b/assets/js/group-classes.js
@@ -0,0 +1,162 @@
+/* global usScheduler */
+(function () {
+ 'use strict';
+
+ const app = document.getElementById('us-group-app');
+ if (!app) return;
+
+ const list = document.getElementById('us-group-list');
+ const confirm = document.getElementById('us-group-confirmation');
+ const errorBox = document.getElementById('us-group-error');
+ const { restUrl, nonce } = usScheduler;
+
+ function apiFetch(path, options = {}) {
+ return fetch(restUrl + path, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': nonce,
+ ...(options.headers || {}),
+ },
+ }).then(async (res) => {
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.message || 'Request failed');
+ return data;
+ });
+ }
+
+ function showError(message) {
+ errorBox.textContent = message;
+ errorBox.style.display = 'block';
+ }
+
+ function clearError() {
+ errorBox.style.display = 'none';
+ }
+
+ function escHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function questionField(q) {
+ const name = `q_${q.id}`;
+ const required = q.is_required ? 'required' : '';
+ let input;
+ if (q.field_type === 'textarea') {
+ input = ``;
+ } else if (q.field_type === 'select') {
+ const opts = (q.options || []).map((o) => ``).join('');
+ input = ``;
+ } else if (q.field_type === 'checkbox') {
+ input = ``;
+ } else {
+ input = ``;
+ }
+ return `
`;
+ }
+
+ function policyField(p) {
+ return `
+
+
${escHtml(p.title)}
+
${p.body || ''}
+
+
`;
+ }
+
+ function renderClasses(offerings) {
+ const groups = offerings.filter((o) => o.kind === 'group_class');
+ if (!groups.length) {
+ list.innerHTML = 'No group classes are open for enrolment right now.
';
+ return;
+ }
+
+ list.innerHTML = groups.map((o) => `
+
+
${escHtml(o.title)}
+ ${o.schedule_note ? `
${escHtml(o.schedule_note)}
` : ''}
+ ${o.description ? `
${escHtml(o.description)}
` : ''}
+
${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}
+
+
+ `).join('');
+
+ list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
+ const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
+ btn.addEventListener('click', () => openEnrolment(offering));
+ });
+ }
+
+ function openEnrolment(offering) {
+ clearError();
+ Promise.all([
+ apiFetch(`offerings/${offering.id}/questions`),
+ apiFetch('policies?scope=booking'),
+ ])
+ .then(([questions, policies]) => renderEnrolment(offering, questions, policies))
+ .catch((err) => showError(err.message));
+ }
+
+ function renderEnrolment(offering, questions, policies) {
+ list.innerHTML = `
+
+
${escHtml(offering.title)}
+
+
`;
+
+ document.getElementById('us-group-cancel').addEventListener('click', loadClasses);
+ document.getElementById('us-enrol-form').addEventListener('submit', (e) => {
+ e.preventDefault();
+ submitEnrolment(e.target, offering, questions);
+ });
+ }
+
+ function submitEnrolment(form, offering, questions) {
+ clearError();
+
+ const answers = {};
+ questions.forEach((q) => {
+ const field = form.elements[`q_${q.id}`];
+ if (!field) return;
+ answers[q.id] = field.type === 'checkbox' ? (field.checked ? '1' : '0') : field.value;
+ });
+
+ const accepted = [...form.querySelectorAll('.us-policy-accept:checked')].map((c) => Number(c.value));
+
+ apiFetch('enrollments', {
+ method: 'POST',
+ body: JSON.stringify({
+ offering_id: offering.id,
+ answers,
+ accepted_policy_version_ids: accepted,
+ }),
+ })
+ .then(() => {
+ list.style.display = 'none';
+ confirm.style.display = 'block';
+ })
+ .catch((err) => showError(err.message));
+ }
+
+ function loadClasses() {
+ clearError();
+ list.style.display = 'block';
+ confirm.style.display = 'none';
+ apiFetch('offerings?kind=group_class')
+ .then(renderClasses)
+ .catch((err) => showError(err.message));
+ }
+
+ loadClasses();
+}());
diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md
index 60777b2..825b3bc 100644
--- a/docs/features/group-classes.md
+++ b/docs/features/group-classes.md
@@ -43,11 +43,19 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin
- Instructors see enrolments for their own group classes under **My Lessons**
## Implementation
-- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository`
+- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
-- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController`
+- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_lessons`)
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
+- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode)
+- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
+
+> **Payment seam:** payment is deferred to #7. An enrolment is created with
+> `status = active` and `payment_id = null`; the pay→confirm + receipt step plugs
+> in later. Instructor-specific enrolment views (the spec's "under My Lessons")
+> are a follow-up — this iteration ships the studio-admin **Group Classes** page
+> (`view_all_lessons`) plus per-student/per-instructor REST queries.
## Tests
-- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
- `tests/Unit/GroupClass/EnrollmentTest.php`
+- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
diff --git a/src/AdminMenu.php b/src/AdminMenu.php
index a9b0ac8..5b011fd 100644
--- a/src/AdminMenu.php
+++ b/src/AdminMenu.php
@@ -10,6 +10,8 @@ use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonController;
+use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
+use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Policy\PolicyController;
@@ -27,14 +29,16 @@ class AdminMenu {
private QuestionController $questionController;
private PolicyController $policyController;
private RegistrationController $registrationController;
+ private GroupClassController $groupClassController;
- public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites ) {
+ public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings );
$this->offeringController = new OfferingController( $offerings );
$this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites );
+ $this->groupClassController = new GroupClassController( $enrollments, $offerings );
}
public function register(): void {
@@ -107,6 +111,17 @@ class AdminMenu {
35
);
+ // Studio admin: all group-class enrolments.
+ add_menu_page(
+ __( 'Group Classes', 'unsupervised-schedular' ),
+ __( 'Group Classes', 'unsupervised-schedular' ),
+ RoleManager::CAP_VIEW_ALL_LESSONS,
+ 'us-group-classes',
+ [ $this->groupClassController, 'renderPage' ],
+ 'dashicons-groups',
+ 36
+ );
+
// Instructor: view their upcoming lessons.
add_menu_page(
__( 'My Lessons', 'unsupervised-schedular' ),
diff --git a/src/GroupClass/Enrollment.php b/src/GroupClass/Enrollment.php
new file mode 100644
index 0000000..896c8af
--- /dev/null
+++ b/src/GroupClass/Enrollment.php
@@ -0,0 +1,54 @@
+
+ */
+ public const VALID_STATUSES = [ self::STATUS_ACTIVE, self::STATUS_CANCELLED, self::STATUS_COMPLETED ];
+
+ public function __construct(
+ public readonly int $offeringId,
+ public readonly int $studentId,
+ public readonly int $instructorId,
+ public readonly string $status = self::STATUS_ACTIVE,
+ public readonly ?int $paymentId = null,
+ public readonly ?int $id = null,
+ ) {}
+
+ public static function fromRow( object $row ): self {
+ return new self(
+ offeringId: (int) $row->offering_id,
+ studentId: (int) $row->student_id,
+ instructorId: (int) $row->instructor_id,
+ status: $row->status,
+ paymentId: null !== $row->payment_id ? (int) $row->payment_id : null,
+ id: (int) $row->id,
+ );
+ }
+
+ /**
+ * Returns a plain array representation of the enrolment.
+ *
+ * @return array
+ */
+ public function toArray(): array {
+ return [
+ 'id' => $this->id,
+ 'offering_id' => $this->offeringId,
+ 'student_id' => $this->studentId,
+ 'instructor_id' => $this->instructorId,
+ 'status' => $this->status,
+ 'payment_id' => $this->paymentId,
+ ];
+ }
+}
diff --git a/src/GroupClass/EnrollmentEndpoint.php b/src/GroupClass/EnrollmentEndpoint.php
new file mode 100644
index 0000000..2c6d7d3
--- /dev/null
+++ b/src/GroupClass/EnrollmentEndpoint.php
@@ -0,0 +1,141 @@
+ \WP_REST_Server::READABLE,
+ 'callback' => [ $this, 'index' ],
+ 'permission_callback' => [ $this, 'isLoggedIn' ],
+ ],
+ [
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => [ $this, 'enroll' ],
+ 'permission_callback' => [ $this, 'canBook' ],
+ 'args' => [
+ 'offering_id' => [
+ 'type' => 'integer',
+ 'required' => true,
+ 'sanitize_callback' => 'absint',
+ ],
+ 'answers' => [
+ 'type' => 'object',
+ 'default' => [],
+ ],
+ 'accepted_policy_version_ids' => [
+ 'type' => 'array',
+ 'default' => [],
+ ],
+ ],
+ ],
+ ]
+ );
+ }
+
+ public function index( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
+ $userId = get_current_user_id();
+
+ if ( current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
+ $enrollments = $this->enrollments->findAllActive();
+ } elseif ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
+ $enrollments = $this->enrollments->findByInstructor( $userId );
+ } else {
+ $enrollments = $this->enrollments->findByStudent( $userId );
+ }
+
+ return new \WP_REST_Response( array_map( fn( Enrollment $e ) => $e->toArray(), $enrollments ), 200 );
+ }
+
+ public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
+ $offeringId = absint( $request->get_param( 'offering_id' ) );
+ $offering = $this->offerings->findById( $offeringId );
+
+ if ( null === $offering || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
+ return new \WP_Error( 'invalid_offering', __( 'Group class not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
+ }
+
+ $studentId = get_current_user_id();
+
+ if ( $this->enrollments->hasActiveEnrollment( $offeringId, $studentId ) ) {
+ return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
+ }
+
+ if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
+ return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
+ }
+
+ $answers = $this->answers( $request );
+ $acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) );
+
+ $gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds );
+ if ( $gateError instanceof \WP_Error ) {
+ return $gateError;
+ }
+
+ $id = $this->enrollments->insert(
+ new Enrollment(
+ offeringId: $offeringId,
+ studentId: $studentId,
+ instructorId: $offering->instructorId,
+ )
+ );
+
+ $this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
+
+ return new \WP_REST_Response(
+ [
+ 'id' => $id,
+ 'status' => Enrollment::STATUS_ACTIVE,
+ ],
+ 201
+ );
+ }
+
+ public function isLoggedIn(): bool {
+ return is_user_logged_in();
+ }
+
+ public function canBook(): bool {
+ return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
+ }
+
+ /**
+ * Extract a question_id => value map from the request.
+ *
+ * @return array
+ */
+ private function answers( \WP_REST_Request $request ): array {
+ $out = [];
+ foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) {
+ $out[ (int) $questionId ] = sanitize_text_field( (string) $value );
+ }
+
+ return $out;
+ }
+
+ private function clientIp(): ?string {
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
+ $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
+
+ return '' !== $ip ? $ip : null;
+ }
+}
diff --git a/src/GroupClass/EnrollmentRepository.php b/src/GroupClass/EnrollmentRepository.php
new file mode 100644
index 0000000..b74fa40
--- /dev/null
+++ b/src/GroupClass/EnrollmentRepository.php
@@ -0,0 +1,129 @@
+table = $db->prefix . 'us_group_enrollments';
+ }
+
+ public function insert( Enrollment $enrollment ): int {
+ $this->db->insert(
+ $this->table,
+ [
+ 'offering_id' => $enrollment->offeringId,
+ 'student_id' => $enrollment->studentId,
+ 'instructor_id' => $enrollment->instructorId,
+ 'status' => $enrollment->status,
+ 'payment_id' => $enrollment->paymentId,
+ 'enrolled_at' => current_time( 'mysql' ),
+ ],
+ [ '%d', '%d', '%d', '%s', '%d', '%s' ]
+ );
+
+ return $this->db->insert_id;
+ }
+
+ public function findById( int $id ): ?Enrollment {
+ $row = $this->db->get_row(
+ $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
+ );
+
+ return $row ? Enrollment::fromRow( $row ) : null;
+ }
+
+ /**
+ * Count active enrolments for an offering (capacity check).
+ */
+ public function countActiveForOffering( int $offeringId ): int {
+ return (int) $this->db->get_var(
+ $this->db->prepare(
+ "SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND status = %s",
+ $offeringId,
+ Enrollment::STATUS_ACTIVE
+ )
+ );
+ }
+
+ /**
+ * Whether a student already holds an active enrolment in an offering.
+ */
+ public function hasActiveEnrollment( int $offeringId, int $studentId ): bool {
+ $count = (int) $this->db->get_var(
+ $this->db->prepare(
+ "SELECT COUNT(*) FROM {$this->table} WHERE offering_id = %d AND student_id = %d AND status = %s",
+ $offeringId,
+ $studentId,
+ Enrollment::STATUS_ACTIVE
+ )
+ );
+
+ return $count > 0;
+ }
+
+ /**
+ * A student's enrolments, newest first.
+ *
+ * @return list
+ */
+ public function findByStudent( int $studentId ): array {
+ $rows = $this->db->get_results(
+ $this->db->prepare(
+ "SELECT * FROM {$this->table} WHERE student_id = %d ORDER BY enrolled_at DESC",
+ $studentId
+ )
+ );
+
+ return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
+ }
+
+ /**
+ * Enrolments in an instructor's group classes, newest first.
+ *
+ * @return list
+ */
+ public function findByInstructor( int $instructorId ): array {
+ $rows = $this->db->get_results(
+ $this->db->prepare(
+ "SELECT * FROM {$this->table} WHERE instructor_id = %d ORDER BY enrolled_at DESC",
+ $instructorId
+ )
+ );
+
+ return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
+ }
+
+ /**
+ * All active enrolments across instructors (studio-admin view).
+ *
+ * @return list
+ */
+ public function findAllActive(): array {
+ $rows = $this->db->get_results(
+ $this->db->prepare(
+ "SELECT * FROM {$this->table} WHERE status = %s ORDER BY enrolled_at DESC",
+ Enrollment::STATUS_ACTIVE
+ )
+ );
+
+ return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
+ }
+
+ public function updateStatus( int $id, string $status ): bool {
+ if ( ! in_array( $status, Enrollment::VALID_STATUSES, true ) ) {
+ return false;
+ }
+
+ return (bool) $this->db->update(
+ $this->table,
+ [ 'status' => $status ],
+ [ 'id' => $id ],
+ [ '%s' ],
+ [ '%d' ]
+ );
+ }
+}
diff --git a/src/GroupClass/GroupClassController.php b/src/GroupClass/GroupClassController.php
new file mode 100644
index 0000000..25d10fd
--- /dev/null
+++ b/src/GroupClass/GroupClassController.php
@@ -0,0 +1,37 @@
+offerings->findById( $enrollment->offeringId );
+ $student = get_userdata( $enrollment->studentId );
+
+ return [
+ 'student' => $student ? $student->display_name : (string) $enrollment->studentId,
+ 'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
+ 'status' => $enrollment->status,
+ ];
+ },
+ $this->enrollments->findAllActive()
+ );
+
+ include USC_PLUGIN_DIR . 'templates/admin/group-classes.php';
+ }
+}
diff --git a/src/GroupClass/GroupClassPage.php b/src/GroupClass/GroupClassPage.php
new file mode 100644
index 0000000..7ecf423
--- /dev/null
+++ b/src/GroupClass/GroupClassPage.php
@@ -0,0 +1,36 @@
+ $atts Shortcode attributes (unused — reserved for future options).
+ */
+ public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
+ if ( ! is_user_logged_in() ) {
+ return sprintf(
+ '%s %s.
',
+ esc_html__( 'Please', 'unsupervised-schedular' ),
+ esc_url( wp_login_url( get_permalink() ) ),
+ esc_html__( 'log in to enrol in a class', 'unsupervised-schedular' )
+ );
+ }
+
+ if ( ! current_user_can( RoleManager::CAP_BOOK_LESSON ) ) {
+ return '' . esc_html__( 'This page is for students only.', 'unsupervised-schedular' ) . '
';
+ }
+
+ wp_enqueue_style( 'us-scheduler' );
+ wp_enqueue_script( 'us-scheduler-group' );
+
+ ob_start();
+ include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
+ return (string) ob_get_clean();
+ }
+}
diff --git a/src/Plugin.php b/src/Plugin.php
index 539cc38..81edbae 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository;
+use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyRepository;
@@ -32,11 +33,12 @@ class Plugin {
$policyService = new PolicyService( $policies, $policyVersions );
$acceptances = new AcceptanceRepository( $wpdb );
$invites = new InviteRepository( $wpdb );
+ $enrollments = new EnrollmentRepository( $wpdb );
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
( new RoleManager() )->register();
- ( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites ) )->register();
- ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate ) )->register();
+ ( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments ) )->register();
+ ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments ) )->register();
( new ShortcodeRegistrar( $invites, $policies, $policyVersions, $acceptances ) )->register();
}
}
diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php
index 494e00a..47cf56a 100644
--- a/src/RestRegistrar.php
+++ b/src/RestRegistrar.php
@@ -7,6 +7,8 @@ use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
+use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
+use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Policy\PolicyEndpoint;
@@ -26,13 +28,15 @@ class RestRegistrar {
private OfferingEndpoint $offeringEndpoint;
private QuestionEndpoint $questionEndpoint;
private PolicyEndpoint $policyEndpoint;
+ private EnrollmentEndpoint $enrollmentEndpoint;
- public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate ) {
+ public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate );
$this->offeringEndpoint = new OfferingEndpoint( $offerings );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
+ $this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate );
}
public function register(): void {
@@ -45,5 +49,6 @@ class RestRegistrar {
$this->offeringEndpoint->registerRoutes( self::NAMESPACE );
$this->questionEndpoint->registerRoutes( self::NAMESPACE );
$this->policyEndpoint->registerRoutes( self::NAMESPACE );
+ $this->enrollmentEndpoint->registerRoutes( self::NAMESPACE );
}
}
diff --git a/src/Schema.php b/src/Schema.php
index c45d786..4d5d989 100644
--- a/src/Schema.php
+++ b/src/Schema.php
@@ -140,6 +140,21 @@ class Schema {
KEY registration (registration_type, registration_id)
) {$charset};",
+ "CREATE TABLE {$prefix}us_group_enrollments (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ offering_id BIGINT UNSIGNED NOT NULL,
+ student_id BIGINT UNSIGNED NOT NULL,
+ instructor_id BIGINT UNSIGNED NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'active',
+ payment_id BIGINT UNSIGNED DEFAULT NULL,
+ enrolled_at DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ KEY offering_id (offering_id),
+ KEY student_id (student_id),
+ KEY instructor_id (instructor_id),
+ KEY status (status)
+ ) {$charset};",
+
"CREATE TABLE {$prefix}us_invites (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(191) NOT NULL,
diff --git a/src/ShortcodeRegistrar.php b/src/ShortcodeRegistrar.php
index 0fb3501..7cb5da4 100644
--- a/src/ShortcodeRegistrar.php
+++ b/src/ShortcodeRegistrar.php
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
+use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
@@ -16,6 +17,7 @@ class ShortcodeRegistrar {
private BookingPage $bookingPage;
private LoginPage $loginPage;
private RegistrationPage $registrationPage;
+ private GroupClassPage $groupClassPage;
public function __construct(
InviteRepository $invites,
@@ -26,27 +28,30 @@ class ShortcodeRegistrar {
$this->bookingPage = new BookingPage();
$this->loginPage = new LoginPage();
$this->registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances );
+ $this->groupClassPage = new GroupClassPage();
}
public function register(): void {
add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] );
add_shortcode( 'us_student_login', [ $this->loginPage, 'render' ] );
add_shortcode( 'us_student_register', [ $this->registrationPage, 'render' ] );
+ add_shortcode( 'us_group_classes', [ $this->groupClassPage, 'render' ] );
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
}
public function enqueueAssets(): void {
wp_register_style( 'us-scheduler', USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
- wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [], USC_VERSION, true );
- wp_localize_script(
- 'us-scheduler',
- 'usScheduler',
- [
- 'restUrl' => rest_url( 'us-scheduler/v1/' ),
- 'nonce' => wp_create_nonce( 'wp_rest' ),
- ]
- );
+ $data = [
+ 'restUrl' => rest_url( 'us-scheduler/v1/' ),
+ 'nonce' => wp_create_nonce( 'wp_rest' ),
+ ];
+
+ wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [], USC_VERSION, true );
+ wp_localize_script( 'us-scheduler', 'usScheduler', $data );
+
+ wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [], USC_VERSION, true );
+ wp_localize_script( 'us-scheduler-group', 'usScheduler', $data );
}
}
diff --git a/templates/admin/group-classes.php b/templates/admin/group-classes.php
new file mode 100644
index 0000000..96f92b3
--- /dev/null
+++ b/templates/admin/group-classes.php
@@ -0,0 +1,36 @@
+ $rows */
+?>
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+
+
+
+
+
+ |
+ |
+ |
+
+
+
+
+
+
diff --git a/templates/frontend/group-classes-page.php b/templates/frontend/group-classes-page.php
new file mode 100644
index 0000000..7e9a2cf
--- /dev/null
+++ b/templates/frontend/group-classes-page.php
@@ -0,0 +1,16 @@
+
+
diff --git a/tests/Unit/GroupClass/EnrollmentRepositoryTest.php b/tests/Unit/GroupClass/EnrollmentRepositoryTest.php
new file mode 100644
index 0000000..b6f8440
--- /dev/null
+++ b/tests/Unit/GroupClass/EnrollmentRepositoryTest.php
@@ -0,0 +1,113 @@
+db = Mockery::mock(\wpdb::class);
+ $this->db->prefix = 'wp_';
+ $this->repo = new EnrollmentRepository($this->db);
+ }
+
+ public function testInsertReturnsId(): void
+ {
+ Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00');
+
+ $this->db->shouldReceive('insert')
+ ->once()
+ ->with(
+ 'wp_us_group_enrollments',
+ Mockery::on(static function (array $d): bool {
+ return $d['offering_id'] === 7
+ && $d['student_id'] === 5
+ && $d['instructor_id'] === 3
+ && $d['status'] === Enrollment::STATUS_ACTIVE;
+ }),
+ ['%d', '%d', '%d', '%s', '%d', '%s']
+ );
+ $this->db->insert_id = 12;
+
+ self::assertSame(12, $this->repo->insert(new Enrollment(7, 5, 3)));
+ }
+
+ public function testCountActiveForOffering(): void
+ {
+ $this->db->shouldReceive('prepare')
+ ->once()
+ ->with(Mockery::pattern('/COUNT\(\*\).*offering_id = %d AND status = %s/s'), 7, Enrollment::STATUS_ACTIVE)
+ ->andReturn('SELECT ...');
+
+ $this->db->shouldReceive('get_var')->andReturn('4');
+
+ self::assertSame(4, $this->repo->countActiveForOffering(7));
+ }
+
+ public function testHasActiveEnrollmentTrueWhenCountPositive(): void
+ {
+ $this->db->shouldReceive('prepare')
+ ->once()
+ ->with(Mockery::pattern('/offering_id = %d AND student_id = %d AND status = %s/'), 7, 5, Enrollment::STATUS_ACTIVE)
+ ->andReturn('SELECT ...');
+
+ $this->db->shouldReceive('get_var')->andReturn('1');
+
+ self::assertTrue($this->repo->hasActiveEnrollment(7, 5));
+ }
+
+ public function testHasActiveEnrollmentFalseWhenZero(): void
+ {
+ $this->db->shouldReceive('prepare')->andReturn('SELECT ...');
+ $this->db->shouldReceive('get_var')->andReturn('0');
+
+ self::assertFalse($this->repo->hasActiveEnrollment(7, 5));
+ }
+
+ public function testFindAllActiveMapsRows(): void
+ {
+ $this->db->shouldReceive('prepare')->andReturn('SELECT ...');
+ $this->db->shouldReceive('get_results')->andReturn([
+ (object) [
+ 'id' => '12',
+ 'offering_id' => '7',
+ 'student_id' => '5',
+ 'instructor_id' => '3',
+ 'status' => Enrollment::STATUS_ACTIVE,
+ 'payment_id' => null,
+ ],
+ ]);
+
+ $all = $this->repo->findAllActive();
+
+ self::assertCount(1, $all);
+ self::assertInstanceOf(Enrollment::class, $all[0]);
+ }
+
+ public function testUpdateStatusRejectsInvalid(): void
+ {
+ self::assertFalse($this->repo->updateStatus(1, 'bogus'));
+ }
+
+ public function testUpdateStatusCallsWpdb(): void
+ {
+ $this->db->shouldReceive('update')
+ ->once()
+ ->with('wp_us_group_enrollments', ['status' => Enrollment::STATUS_CANCELLED], ['id' => 1], ['%s'], ['%d'])
+ ->andReturn(1);
+
+ self::assertTrue($this->repo->updateStatus(1, Enrollment::STATUS_CANCELLED));
+ }
+}
diff --git a/tests/Unit/GroupClass/EnrollmentTest.php b/tests/Unit/GroupClass/EnrollmentTest.php
new file mode 100644
index 0000000..06cf5db
--- /dev/null
+++ b/tests/Unit/GroupClass/EnrollmentTest.php
@@ -0,0 +1,52 @@
+status);
+ self::assertNull($enrollment->paymentId);
+ self::assertNull($enrollment->id);
+ }
+
+ public function testStatusConstants(): void
+ {
+ self::assertContains(Enrollment::STATUS_ACTIVE, Enrollment::VALID_STATUSES);
+ self::assertContains(Enrollment::STATUS_CANCELLED, Enrollment::VALID_STATUSES);
+ self::assertContains(Enrollment::STATUS_COMPLETED, Enrollment::VALID_STATUSES);
+ }
+
+ public function testFromRowMapsCorrectly(): void
+ {
+ $enrollment = Enrollment::fromRow((object) [
+ 'id' => '12',
+ 'offering_id' => '7',
+ 'student_id' => '5',
+ 'instructor_id' => '3',
+ 'status' => Enrollment::STATUS_ACTIVE,
+ 'payment_id' => null,
+ ]);
+
+ self::assertSame(12, $enrollment->id);
+ self::assertSame(7, $enrollment->offeringId);
+ self::assertSame(5, $enrollment->studentId);
+ self::assertNull($enrollment->paymentId);
+ }
+
+ public function testToArrayContainsExpectedKeys(): void
+ {
+ $arr = (new Enrollment(7, 5, 3, id: 12))->toArray();
+
+ foreach (['id', 'offering_id', 'student_id', 'instructor_id', 'status', 'payment_id'] as $key) {
+ self::assertArrayHasKey($key, $arr);
+ }
+ }
+}