From 9cb5207dcdfe52bf30dd86cf9e445102f01ad2ec Mon Sep 17 00:00:00 2001 From: James Griffin Date: Sun, 7 Jun 2026 11:43:33 -0300 Subject: [PATCH] Add group-class enrolment (year commitment, capacity, registration gate) Implements #4: students enrol in a group_class offering via the same registration gate as private lessons (intake questions + booking-scoped policy acceptance). Enrolment is capacity-enforced and prevents duplicates. - Schema: us_group_enrollments table. - Enrollment value object + EnrollmentRepository (countActiveForOffering, hasActiveEnrollment, per-student/instructor/all-active queries, status). - EnrollmentEndpoint: GET /enrollments (scoped) and POST /enrollments (validates group_class, capacity, no-duplicate; reuses RegistrationGate; records answers/acceptances type enrollment). - GroupClassController + admin page (view_all_lessons): all active enrolments. - Front-end: [us_group_classes] shortcode (GroupClassPage) + group-classes.js enrol flow (list classes -> questions + policies -> POST /enrollments). - Wiring in Plugin, RestRegistrar, AdminMenu, ShortcodeRegistrar. Payment is the deferred seam (#7): enrolment lands active, payment_id null. JS left untested for parity with the repo's no-build vanilla-JS posture. Tests: tests/Unit/GroupClass/ (Enrollment, EnrollmentRepository). composer test (121), cs, and PHPStan level 6 all pass. Refs #4 Co-Authored-By: Claude Opus 4.8 --- assets/js/group-classes.js | 162 ++++++++++++++++++ docs/features/group-classes.md | 14 +- src/AdminMenu.php | 17 +- src/GroupClass/Enrollment.php | 54 ++++++ src/GroupClass/EnrollmentEndpoint.php | 141 +++++++++++++++ src/GroupClass/EnrollmentRepository.php | 129 ++++++++++++++ src/GroupClass/GroupClassController.php | 37 ++++ src/GroupClass/GroupClassPage.php | 36 ++++ src/Plugin.php | 6 +- src/RestRegistrar.php | 7 +- src/Schema.php | 15 ++ src/ShortcodeRegistrar.php | 23 ++- templates/admin/group-classes.php | 36 ++++ templates/frontend/group-classes-page.php | 16 ++ .../GroupClass/EnrollmentRepositoryTest.php | 113 ++++++++++++ tests/Unit/GroupClass/EnrollmentTest.php | 52 ++++++ 16 files changed, 842 insertions(+), 16 deletions(-) create mode 100644 assets/js/group-classes.js create mode 100644 src/GroupClass/Enrollment.php create mode 100644 src/GroupClass/EnrollmentEndpoint.php create mode 100644 src/GroupClass/EnrollmentRepository.php create mode 100644 src/GroupClass/GroupClassController.php create mode 100644 src/GroupClass/GroupClassPage.php create mode 100644 templates/admin/group-classes.php create mode 100644 templates/frontend/group-classes-page.php create mode 100644 tests/Unit/GroupClass/EnrollmentRepositoryTest.php create mode 100644 tests/Unit/GroupClass/EnrollmentTest.php 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)}

+
+ ${questions.map(questionField).join('')} + ${policies.map(policyField).join('')} +

+ + +

+
+
`; + + 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); + } + } +}