diff --git a/assets/js/booking.js b/assets/js/booking.js index e5f14ad..b458604 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -5,9 +5,9 @@ const app = document.getElementById('us-booking-app'); if (!app) return; - const slotList = document.getElementById('us-slot-list'); - const confirm = document.getElementById('us-booking-confirmation'); - const errorBox = document.getElementById('us-booking-error'); + const slotList = document.getElementById('us-slot-list'); + const confirm = document.getElementById('us-booking-confirmation'); + const errorBox = document.getElementById('us-booking-error'); const { restUrl, nonce } = usScheduler; function apiFetch(path, options = {}) { @@ -30,14 +30,21 @@ errorBox.style.display = 'block'; } - function dayKey(dt) { - return String(dt).slice(0, 10); + function clearError() { + errorBox.style.display = 'none'; } - function timeOf(dt) { - return String(dt).slice(11, 16); + function escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); } + const dayKey = (dt) => String(dt).slice(0, 10); + const timeOf = (dt) => String(dt).slice(11, 16); + function dayLabel(key) { const date = new Date(key + 'T00:00:00'); if (Number.isNaN(date.getTime())) return key; @@ -56,9 +63,7 @@ return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); } - // Agenda-style calendar: available slots grouped by day. The richer booking - // UX (offering selection, intake questions, policy acceptance) lands with the - // booking-flow work. + // Agenda-style calendar: available slots grouped by day. function renderSlots(slots) { if (!slots.length) { slotList.innerHTML = '

No available lesson slots at this time.

'; @@ -78,33 +83,117 @@ `).join(''); slotList.querySelectorAll('.us-book-btn').forEach((btn) => { - btn.addEventListener('click', () => bookSlot(Number(btn.dataset.slotId))); + const slot = slots.find((s) => String(s.id) === btn.dataset.slotId); + btn.addEventListener('click', () => openRegistration(slot)); }); } - 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 bookSlot(slotId) { - errorBox.style.display = 'none'; + function policyField(p) { + return ` +
+

${escHtml(p.title)}

+
${p.body || ''}
+ +
`; + } - apiFetch('bookings', { - method: 'POST', - body: JSON.stringify({ slot_id: slotId }), - }) - .then(() => { - slotList.style.display = 'none'; - confirm.style.display = 'block'; + function openRegistration(slot) { + clearError(); + + const offeringId = Number(slot.offering_id) || 0; + const qPath = offeringId ? `offerings/${offeringId}/questions` : null; + + Promise.all([ + qPath ? apiFetch(qPath) : Promise.resolve([]), + apiFetch('policies?scope=booking'), + ]) + .then(([questions, policies]) => { + renderRegistration(slot, offeringId, questions, policies); }) .catch((err) => showError(err.message)); } - apiFetch('availability') - .then(renderSlots) - .catch((err) => showError(err.message)); + function renderRegistration(slot, offeringId, questions, policies) { + const weekly = slot.recurrence_group + ? `

` + : ''; + + slotList.innerHTML = ` +
+

${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))}

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

+ + +

+
+
`; + + document.getElementById('us-cancel').addEventListener('click', loadSlots); + document.getElementById('us-register-form').addEventListener('submit', (e) => { + e.preventDefault(); + submitBooking(e.target, slot, offeringId, questions); + }); + } + + function submitBooking(form, slot, offeringId, 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)); + const weeklyEl = document.getElementById('us-weekly'); + + apiFetch('bookings', { + method: 'POST', + body: JSON.stringify({ + slot_id: slot.id, + offering_id: offeringId, + recurrence: weeklyEl && weeklyEl.checked ? 'weekly' : 'single', + answers, + accepted_policy_version_ids: accepted, + }), + }) + .then(() => { + slotList.style.display = 'none'; + confirm.style.display = 'block'; + }) + .catch((err) => showError(err.message)); + } + + function loadSlots() { + clearError(); + slotList.style.display = 'block'; + confirm.style.display = 'none'; + apiFetch('availability') + .then(renderSlots) + .catch((err) => showError(err.message)); + } + + loadSlots(); }()); diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index 535a31b..a22ef4c 100644 --- a/docs/features/lesson-booking.md +++ b/docs/features/lesson-booking.md @@ -61,12 +61,19 @@ kind `group_class`; see `group-classes.md`. - `[us_student_login]` — front-end login form for students ## Implementation -- Repository: `Unsupervised\Schedular\Booking\BookingRepository` +- Repository: `Unsupervised\Schedular\Booking\BookingRepository` (`insertSeries()` builds a weekly series sharing a `series_id`) - Model: `Unsupervised\Schedular\Booking\Lesson` +- Registration gate: `Unsupervised\Schedular\Registration\RegistrationGate` — validates and records intake answers + booking-scoped policy acceptances; shared with group enrolment - Admin controller: `Unsupervised\Schedular\Booking\LessonController` - REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint` - Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage` +> **Payment seam:** payment is deferred to the Payments feature (#7). For now a +> booking is created with `status = pending` and `payment_id = null`; the +> instructor confirms via `PATCH /bookings/{id}/status`. When payments land, the +> pay→confirm + receipt step plugs into this seam. `GET /policies?scope=booking` +> returns just the booking-gate policies the form must collect. + ## Tests - `tests/Unit/Booking/BookingRepositoryTest.php` - `tests/Unit/Booking/LessonTest.php` diff --git a/src/Availability/AvailabilityRepository.php b/src/Availability/AvailabilityRepository.php index 236e640..a368978 100644 --- a/src/Availability/AvailabilityRepository.php +++ b/src/Availability/AvailabilityRepository.php @@ -141,6 +141,22 @@ class AvailabilityRepository { return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] ); } + /** + * Unbooked slots that belong to a weekly-recurring group, ordered by start. + * + * @return list + */ + public function findUnbookedInGroup( int $recurrenceGroup ): array { + $rows = $this->db->get_results( + $this->db->prepare( + "SELECT * FROM {$this->table} WHERE recurrence_group = %d AND is_booked = 0 ORDER BY start_dt ASC", + $recurrenceGroup + ) + ); + + return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] ); + } + public function findById( int $id ): ?AvailabilitySlot { $row = $this->db->get_row( $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index 5de8cf5..b019507 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -5,12 +5,17 @@ namespace Unsupervised\Schedular\Booking; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Policy\PolicyAcceptance; +use Unsupervised\Schedular\Registration\RegistrationGate; class BookingEndpoint { public function __construct( private AvailabilityRepository $availability, private BookingRepository $bookings, + private OfferingRepository $offerings, + private RegistrationGate $gate, ) {} public function registerRoutes( string $route_namespace ): void { @@ -28,12 +33,28 @@ class BookingEndpoint { 'callback' => [ $this, 'book' ], 'permission_callback' => [ $this, 'canBook' ], 'args' => [ - 'slot_id' => [ + 'slot_id' => [ 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ], - 'notes' => [ + 'offering_id' => [ + 'type' => 'integer', + 'default' => 0, + ], + 'recurrence' => [ + 'type' => 'string', + 'default' => 'single', + ], + 'answers' => [ + 'type' => 'object', + 'default' => [], + ], + 'accepted_policy_version_ids' => [ + 'type' => 'array', + 'default' => [], + ], + 'notes' => [ 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_textarea_field', @@ -84,26 +105,83 @@ class BookingEndpoint { return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } - $notes = (string) $request->get_param( 'notes' ); - $lesson = new Lesson( + $offeringId = absint( $request->get_param( 'offering_id' ) ); + if ( 0 === $offeringId ) { + $offeringId = (int) ( $slot->offeringId ?? 0 ); + } + if ( $offeringId > 0 && null === $this->offerings->findById( $offeringId ) ) { + return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); + } + + $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; + } + + $studentId = get_current_user_id(); + $notes = (string) $request->get_param( 'notes' ); + $recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) + ? Lesson::RECURRENCE_WEEKLY + : Lesson::RECURRENCE_SINGLE; + + $template = new Lesson( slotId: $slotId, - studentId: get_current_user_id(), + studentId: $studentId, instructorId: $slot->instructorId, + offeringId: $offeringId > 0 ? $offeringId : null, + recurrence: $recurrence, notes: '' !== $notes ? $notes : null, ); - $id = $this->bookings->insert( $lesson ); - $this->availability->markBooked( $slotId ); + // Weekly reservation across the slot's recurring group; otherwise a single lesson. + if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) { + $slotIds = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) ); + $ids = $this->bookings->insertSeries( $template, $slotIds ); + foreach ( $slotIds as $reservedSlotId ) { + $this->availability->markBooked( $reservedSlotId ); + } + $anchorId = $ids[0] ?? 0; + } else { + $anchorId = $this->bookings->insert( $template ); + $this->availability->markBooked( $slotId ); + $ids = [ $anchorId ]; + } + + $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); return new \WP_REST_Response( [ - 'id' => $id, + 'ids' => $ids, 'status' => Lesson::STATUS_PENDING, ], 201 ); } + /** + * 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; + } + public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $id = absint( $request->get_param( 'id' ) ); $lesson = $this->bookings->findById( $id ); diff --git a/src/Booking/BookingRepository.php b/src/Booking/BookingRepository.php index 6fb74bd..2ccb713 100644 --- a/src/Booking/BookingRepository.php +++ b/src/Booking/BookingRepository.php @@ -16,18 +16,68 @@ class BookingRepository { $this->table, [ 'slot_id' => $lesson->slotId, + 'offering_id' => $lesson->offeringId, 'student_id' => $lesson->studentId, 'instructor_id' => $lesson->instructorId, + 'recurrence' => $lesson->recurrence, + 'series_id' => $lesson->seriesId, 'status' => $lesson->status, + 'payment_id' => $lesson->paymentId, 'notes' => $lesson->notes, 'created_at' => current_time( 'mysql' ), ], - [ '%d', '%d', '%d', '%s', '%s', '%s' ] + [ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s' ] ); return $this->db->insert_id; } + /** + * Create a weekly lesson series — one lesson per slot, all sharing a + * `series_id` (the id of the first lesson row). + * + * @param list $slotIds Availability slot IDs to reserve, in order. + * @return list Inserted lesson IDs. + */ + public function insertSeries( Lesson $template, array $slotIds ): array { + $ids = []; + $seriesId = 0; + + foreach ( $slotIds as $slotId ) { + $id = $this->insert( + new Lesson( + slotId: $slotId, + studentId: $template->studentId, + instructorId: $template->instructorId, + offeringId: $template->offeringId, + recurrence: Lesson::RECURRENCE_WEEKLY, + seriesId: $seriesId > 0 ? $seriesId : null, + status: $template->status, + notes: $template->notes, + ) + ); + + if ( 0 === $seriesId ) { + $seriesId = $id; + $this->setSeriesId( $id, $seriesId ); + } + + $ids[] = $id; + } + + return $ids; + } + + private function setSeriesId( int $id, int $seriesId ): void { + $this->db->update( + $this->table, + [ 'series_id' => $seriesId ], + [ 'id' => $id ], + [ '%d' ], + [ '%d' ] + ); + } + public function findById( int $id ): ?Lesson { $row = $this->db->get_row( $this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ) diff --git a/src/Booking/Lesson.php b/src/Booking/Lesson.php index 052f7b9..073b73d 100644 --- a/src/Booking/Lesson.php +++ b/src/Booking/Lesson.php @@ -16,11 +16,25 @@ class Lesson { */ public const VALID_STATUSES = [ self::STATUS_PENDING, self::STATUS_CONFIRMED, self::STATUS_CANCELLED ]; + public const RECURRENCE_SINGLE = 'single'; + public const RECURRENCE_WEEKLY = 'weekly'; + + /** + * All valid recurrence values. + * + * @var list + */ + public const VALID_RECURRENCES = [ self::RECURRENCE_SINGLE, self::RECURRENCE_WEEKLY ]; + public function __construct( public readonly int $slotId, public readonly int $studentId, public readonly int $instructorId, + public readonly ?int $offeringId = null, + public readonly string $recurrence = self::RECURRENCE_SINGLE, + public readonly ?int $seriesId = null, public readonly string $status = self::STATUS_PENDING, + public readonly ?int $paymentId = null, public readonly ?string $notes = null, public readonly ?int $id = null, ) {} @@ -30,7 +44,11 @@ class Lesson { slotId: (int) $row->slot_id, studentId: (int) $row->student_id, instructorId: (int) $row->instructor_id, + offeringId: null !== $row->offering_id ? (int) $row->offering_id : null, + recurrence: $row->recurrence, + seriesId: null !== $row->series_id ? (int) $row->series_id : null, status: $row->status, + paymentId: null !== $row->payment_id ? (int) $row->payment_id : null, notes: $row->notes, id: (int) $row->id, ); @@ -45,9 +63,13 @@ class Lesson { return [ 'id' => $this->id, 'slot_id' => $this->slotId, + 'offering_id' => $this->offeringId, 'student_id' => $this->studentId, 'instructor_id' => $this->instructorId, + 'recurrence' => $this->recurrence, + 'series_id' => $this->seriesId, 'status' => $this->status, + 'payment_id' => $this->paymentId, 'notes' => $this->notes, ]; } diff --git a/src/Plugin.php b/src/Plugin.php index 5cfe69f..539cc38 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -12,7 +12,9 @@ use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\PolicyRepository; use Unsupervised\Schedular\Policy\PolicyService; use Unsupervised\Schedular\Policy\PolicyVersionRepository; +use Unsupervised\Schedular\Registration\AnswerRepository; use Unsupervised\Schedular\Registration\QuestionRepository; +use Unsupervised\Schedular\Registration\RegistrationGate; class Plugin { @@ -20,19 +22,21 @@ class Plugin { load_plugin_textdomain( 'unsupervised-schedular', false, dirname( plugin_basename( USC_PLUGIN_FILE ) ) . '/languages' ); global $wpdb; - $availability = new AvailabilityRepository( $wpdb ); - $bookings = new BookingRepository( $wpdb ); - $offerings = new OfferingRepository( $wpdb ); - $questions = new QuestionRepository( $wpdb ); - $policies = new PolicyRepository( $wpdb ); - $policyVersions = new PolicyVersionRepository( $wpdb ); - $policyService = new PolicyService( $policies, $policyVersions ); - $acceptances = new AcceptanceRepository( $wpdb ); - $invites = new InviteRepository( $wpdb ); + $availability = new AvailabilityRepository( $wpdb ); + $bookings = new BookingRepository( $wpdb ); + $offerings = new OfferingRepository( $wpdb ); + $questions = new QuestionRepository( $wpdb ); + $answers = new AnswerRepository( $wpdb ); + $policies = new PolicyRepository( $wpdb ); + $policyVersions = new PolicyVersionRepository( $wpdb ); + $policyService = new PolicyService( $policies, $policyVersions ); + $acceptances = new AcceptanceRepository( $wpdb ); + $invites = new InviteRepository( $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 ) )->register(); + ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate ) )->register(); ( new ShortcodeRegistrar( $invites, $policies, $policyVersions, $acceptances ) )->register(); } } diff --git a/src/Policy/PolicyEndpoint.php b/src/Policy/PolicyEndpoint.php index 11c15b4..50da003 100644 --- a/src/Policy/PolicyEndpoint.php +++ b/src/Policy/PolicyEndpoint.php @@ -69,12 +69,19 @@ class PolicyEndpoint { } /** - * Public: the current published version of every policy (the registration gate). + * Public: the current published version of every policy (the registration + * gate). Pass `?scope=signup|booking` to limit to that gate (includes + * `both`-scoped policies). */ - public function index(): \WP_REST_Response { + public function index( \WP_REST_Request $request ): \WP_REST_Response { + $scope = (string) $request->get_param( 'scope' ); + $policies = in_array( $scope, [ Policy::SCOPE_SIGNUP, Policy::SCOPE_BOOKING ], true ) + ? $this->policies->findForScope( $scope ) + : $this->policies->findAll(); + $out = []; - foreach ( $this->policies->findAll() as $policy ) { + foreach ( $policies as $policy ) { if ( null === $policy->currentVersionId ) { continue; } diff --git a/src/Registration/RegistrationGate.php b/src/Registration/RegistrationGate.php new file mode 100644 index 0000000..f40242c --- /dev/null +++ b/src/Registration/RegistrationGate.php @@ -0,0 +1,120 @@ + $answers question_id => answer value + * @param list $acceptedVersionIds Accepted policy version IDs + */ + public function validate( int $offeringId, array $answers, array $acceptedVersionIds ): ?\WP_Error { + foreach ( $this->questions->findByOffering( $offeringId, true ) as $question ) { + if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) { + return new \WP_Error( + 'missing_answer', + __( 'Please answer all required questions.', 'unsupervised-schedular' ), + [ 'status' => 400 ] + ); + } + } + + foreach ( $this->requiredPolicyVersionIds() as $versionId ) { + if ( ! in_array( $versionId, $acceptedVersionIds, true ) ) { + return new \WP_Error( + 'policy_required', + __( 'You must accept all required policies.', 'unsupervised-schedular' ), + [ 'status' => 400 ] + ); + } + } + + return null; + } + + /** + * Persist answers and policy acceptances for a created registration. + * + * @param array $answers question_id => answer value + * @param list $acceptedVersionIds Accepted policy version IDs + */ + public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null ): void { + foreach ( $this->questions->findByOffering( $offeringId, true ) as $question ) { + $value = (string) ( $answers[ (int) $question->id ] ?? '' ); + if ( '' === $value ) { + continue; + } + + $this->answers->insert( + new Answer( + questionId: (int) $question->id, + registrationType: $registrationType, + registrationId: $registrationId, + studentId: $studentId, + answerValue: $value, + ) + ); + } + + foreach ( $this->requiredPolicyVersionIds() as $versionId ) { + if ( ! in_array( $versionId, $acceptedVersionIds, true ) ) { + continue; + } + + $this->acceptances->insert( + new PolicyAcceptance( + policyVersionId: $versionId, + studentId: $studentId, + registrationType: $registrationType, + registrationId: $registrationId, + ipAddress: $ipAddress, + ) + ); + } + } + + /** + * Current published version IDs of every booking-scoped policy. + * + * @return list + */ + private function requiredPolicyVersionIds(): array { + $ids = []; + + foreach ( $this->policies->findForScope( Policy::SCOPE_BOOKING ) as $policy ) { + if ( null === $policy->currentVersionId ) { + continue; + } + + $version = $this->versions->findById( $policy->currentVersionId ); + if ( null !== $version && $version->isPublished() ) { + $ids[] = (int) $version->id; + } + } + + return $ids; + } +} diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php index bc3ac49..494e00a 100644 --- a/src/RestRegistrar.php +++ b/src/RestRegistrar.php @@ -15,6 +15,7 @@ use Unsupervised\Schedular\Policy\PolicyService; use Unsupervised\Schedular\Policy\PolicyVersionRepository; use Unsupervised\Schedular\Registration\QuestionEndpoint; use Unsupervised\Schedular\Registration\QuestionRepository; +use Unsupervised\Schedular\Registration\RegistrationGate; class RestRegistrar { @@ -26,9 +27,9 @@ class RestRegistrar { private QuestionEndpoint $questionEndpoint; private PolicyEndpoint $policyEndpoint; - public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService ) { + public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate ) { $this->availabilityEndpoint = new AvailabilityEndpoint( $availability ); - $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings ); + $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 ); diff --git a/src/Schema.php b/src/Schema.php index 964b4f1..c45d786 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -32,15 +32,21 @@ class Schema { "CREATE TABLE {$prefix}us_lessons ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, slot_id BIGINT UNSIGNED NOT NULL, + offering_id BIGINT UNSIGNED DEFAULT NULL, student_id BIGINT UNSIGNED NOT NULL, instructor_id BIGINT UNSIGNED NOT NULL, + recurrence VARCHAR(10) NOT NULL DEFAULT 'single', + series_id BIGINT UNSIGNED DEFAULT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', + payment_id BIGINT UNSIGNED DEFAULT NULL, notes TEXT, created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY slot_id (slot_id), + KEY offering_id (offering_id), KEY student_id (student_id), - KEY instructor_id (instructor_id) + KEY instructor_id (instructor_id), + KEY series_id (series_id) ) {$charset};", "CREATE TABLE {$prefix}us_offerings ( diff --git a/tests/Unit/Booking/BookingRepositoryTest.php b/tests/Unit/Booking/BookingRepositoryTest.php index 913735b..006f81a 100644 --- a/tests/Unit/Booking/BookingRepositoryTest.php +++ b/tests/Unit/Booking/BookingRepositoryTest.php @@ -34,19 +34,43 @@ class BookingRepositoryTest extends TestCase Mockery::on(static function (array $data): bool { return $data['slot_id'] === 10 && $data['student_id'] === 5 + && $data['offering_id'] === 7 + && $data['recurrence'] === Lesson::RECURRENCE_SINGLE && $data['status'] === Lesson::STATUS_PENDING; }), - ['%d', '%d', '%d', '%s', '%s', '%s'] + ['%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s'] ); $this->db->insert_id = 77; - $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3); + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 7); $result = $this->repo->insert($lesson); self::assertSame(77, $result); } + public function testInsertSeriesSharesSeriesIdAcrossSlots(): void + { + Functions\when('current_time')->justReturn('2026-04-01 12:00:00'); + + $ids = [40, 41, 42]; + $this->db->shouldReceive('insert') + ->times(3) + ->andReturnUsing(function () use (&$ids): void { + $this->db->insert_id = array_shift($ids); + }); + + // The first lesson is back-filled with its own id as the series id. + $this->db->shouldReceive('update') + ->once() + ->with('wp_us_lessons', ['series_id' => 40], ['id' => 40], ['%d'], ['%d']); + + $template = new Lesson(slotId: 0, studentId: 5, instructorId: 3, offeringId: 7); + $result = $this->repo->insertSeries($template, [100, 101, 102]); + + self::assertSame([40, 41, 42], $result); + } + public function testFindByIdReturnsNullWhenNotFound(): void { $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); @@ -60,9 +84,13 @@ class BookingRepositoryTest extends TestCase $row = (object) [ 'id' => '15', 'slot_id' => '10', + 'offering_id' => null, 'student_id' => '5', 'instructor_id' => '3', + 'recurrence' => Lesson::RECURRENCE_SINGLE, + 'series_id' => null, 'status' => 'pending', + 'payment_id' => null, 'notes' => null, ]; @@ -109,9 +137,13 @@ class BookingRepositoryTest extends TestCase $row = (object) [ 'id' => '1', 'slot_id' => '2', + 'offering_id' => null, 'student_id' => '5', 'instructor_id' => '3', + 'recurrence' => Lesson::RECURRENCE_SINGLE, + 'series_id' => null, 'status' => 'pending', + 'payment_id' => null, 'notes' => null, ]; diff --git a/tests/Unit/Booking/LessonTest.php b/tests/Unit/Booking/LessonTest.php index f925808..1c5cfb0 100644 --- a/tests/Unit/Booking/LessonTest.php +++ b/tests/Unit/Booking/LessonTest.php @@ -28,41 +28,52 @@ class LessonTest extends TestCase $lesson = new Lesson(slotId: 1, studentId: 2, instructorId: 3); self::assertSame(Lesson::STATUS_PENDING, $lesson->status); + self::assertNull($lesson->offeringId); + self::assertSame(Lesson::RECURRENCE_SINGLE, $lesson->recurrence); + self::assertNull($lesson->seriesId); + self::assertNull($lesson->paymentId); self::assertNull($lesson->notes); self::assertNull($lesson->id); } + public function testRecurrenceConstants(): void + { + self::assertContains(Lesson::RECURRENCE_SINGLE, Lesson::VALID_RECURRENCES); + self::assertContains(Lesson::RECURRENCE_WEEKLY, Lesson::VALID_RECURRENCES); + } + public function testFromRowMapsCorrectly(): void { $row = (object) [ 'id' => '99', 'slot_id' => '10', + 'offering_id' => '7', 'student_id' => '20', 'instructor_id' => '30', + 'recurrence' => Lesson::RECURRENCE_WEEKLY, + 'series_id' => '99', 'status' => 'confirmed', + 'payment_id' => null, 'notes' => 'Bring your guitar.', ]; $lesson = Lesson::fromRow($row); self::assertSame(99, $lesson->id); - self::assertSame(10, $lesson->slotId); - self::assertSame(20, $lesson->studentId); - self::assertSame(30, $lesson->instructorId); + self::assertSame(7, $lesson->offeringId); + self::assertSame(Lesson::RECURRENCE_WEEKLY, $lesson->recurrence); + self::assertSame(99, $lesson->seriesId); + self::assertNull($lesson->paymentId); self::assertSame('confirmed', $lesson->status); - self::assertSame('Bring your guitar.', $lesson->notes); } public function testToArrayContainsExpectedKeys(): void { - $lesson = new Lesson(1, 2, 3, Lesson::STATUS_PENDING, 'Note', 5); + $lesson = new Lesson(slotId: 1, studentId: 2, instructorId: 3, notes: 'Note', id: 5); $arr = $lesson->toArray(); - self::assertArrayHasKey('id', $arr); - self::assertArrayHasKey('slot_id', $arr); - self::assertArrayHasKey('student_id', $arr); - self::assertArrayHasKey('instructor_id', $arr); - self::assertArrayHasKey('status', $arr); - self::assertArrayHasKey('notes', $arr); + foreach (['id', 'slot_id', 'offering_id', 'student_id', 'instructor_id', 'recurrence', 'series_id', 'status', 'payment_id', 'notes'] as $key) { + self::assertArrayHasKey($key, $arr); + } } } diff --git a/tests/Unit/Registration/RegistrationGateTest.php b/tests/Unit/Registration/RegistrationGateTest.php new file mode 100644 index 0000000..133d02f --- /dev/null +++ b/tests/Unit/Registration/RegistrationGateTest.php @@ -0,0 +1,105 @@ +questions = Mockery::mock(QuestionRepository::class); + $this->answers = Mockery::mock(AnswerRepository::class); + $this->policies = Mockery::mock(PolicyRepository::class); + $this->versions = Mockery::mock(PolicyVersionRepository::class); + $this->acceptances = Mockery::mock(AcceptanceRepository::class); + + $this->gate = new RegistrationGate( + $this->questions, + $this->answers, + $this->policies, + $this->versions, + $this->acceptances + ); + } + + private function requiredQuestion(): Question + { + return new Question(offeringId: 7, label: 'Level?', isRequired: true, id: 3); + } + + private function bookingPolicy(): Policy + { + return new Policy('Cancellation', 'cancellation', currentVersionId: 9, acceptanceScope: Policy::SCOPE_BOOKING, id: 1); + } + + public function testValidatePassesWhenAnswersAndPoliciesProvided(): void + { + $this->questions->shouldReceive('findByOffering')->with(7, true)->andReturn([$this->requiredQuestion()]); + $this->policies->shouldReceive('findForScope')->with(Policy::SCOPE_BOOKING)->andReturn([$this->bookingPolicy()]); + $this->versions->shouldReceive('findById')->with(9)->andReturn(new PolicyVersion(1, 1, 'body', PolicyVersion::STATUS_PUBLISHED, null, 9)); + + self::assertNull($this->gate->validate(7, [3 => 'Beginner'], [9])); + } + + public function testValidateFailsWhenRequiredQuestionUnanswered(): void + { + $this->questions->shouldReceive('findByOffering')->with(7, true)->andReturn([$this->requiredQuestion()]); + + $result = $this->gate->validate(7, [3 => ' '], [9]); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('missing_answer', $result->get_error_code()); + } + + public function testValidateFailsWhenPolicyNotAccepted(): void + { + $this->questions->shouldReceive('findByOffering')->with(7, true)->andReturn([$this->requiredQuestion()]); + $this->policies->shouldReceive('findForScope')->with(Policy::SCOPE_BOOKING)->andReturn([$this->bookingPolicy()]); + $this->versions->shouldReceive('findById')->with(9)->andReturn(new PolicyVersion(1, 1, 'body', PolicyVersion::STATUS_PUBLISHED, null, 9)); + + $result = $this->gate->validate(7, [3 => 'Beginner'], []); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('policy_required', $result->get_error_code()); + } + + public function testRecordPersistsAnswersAndAcceptances(): void + { + $this->questions->shouldReceive('findByOffering')->with(7, true)->andReturn([$this->requiredQuestion()]); + $this->policies->shouldReceive('findForScope')->with(Policy::SCOPE_BOOKING)->andReturn([$this->bookingPolicy()]); + $this->versions->shouldReceive('findById')->with(9)->andReturn(new PolicyVersion(1, 1, 'body', PolicyVersion::STATUS_PUBLISHED, null, 9)); + + $this->answers->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (Answer $a): bool => $a->questionId === 3 && $a->registrationId === 50 && $a->answerValue === 'Beginner')); + + $this->acceptances->shouldReceive('insert') + ->once() + ->with(Mockery::on(static fn (PolicyAcceptance $a): bool => $a->policyVersionId === 9 && $a->registrationType === PolicyAcceptance::REG_LESSON && $a->registrationId === 50)); + + $this->gate->record(PolicyAcceptance::REG_LESSON, 50, 5, 7, [3 => 'Beginner'], [9], '203.0.113.7'); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8c90501..6c9ecb8 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -13,3 +13,36 @@ define('USC_VERSION', '1.0.0'); define('USC_PLUGIN_FILE', dirname(__DIR__) . '/unsupervised-schedular.php'); define('USC_PLUGIN_DIR', dirname(__DIR__) . '/'); define('USC_PLUGIN_URL', 'http://example.com/wp-content/plugins/unsupervised-schedular/'); + +// Minimal WP_Error stub for code under test that returns error objects. +if (! class_exists('WP_Error')) { + class WP_Error + { + /** @var array> */ + public array $errors = []; + + /** @var array */ + public array $error_data = []; + + public function __construct(string $code = '', string $message = '', mixed $data = '') + { + if ('' !== $code) { + $this->errors[$code][] = $message; + if ('' !== $data) { + $this->error_data[$code] = $data; + } + } + } + + public function get_error_code(): string + { + return (string) (array_key_first($this->errors) ?? ''); + } + + public function get_error_message(): string + { + $code = $this->get_error_code(); + return '' !== $code ? ($this->errors[$code][0] ?? '') : ''; + } + } +}