handleFormAction( $instructorId, $manageAll ); } // Studio admins may assign any instructor to a class; a plain instructor // only ever creates classes for themselves, so the picker is theirs alone. $instructors = $manageAll ? $this->instructorOptions() : []; // View-state query param only (which offering the form is editing) — // nothing is mutated from it, so no nonce applies. // phpcs:disable WordPress.Security.NonceVerification.Recommended $editId = absint( Val::int( $_GET['usc_edit'] ?? 0 ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $editing = null; if ( $editId > 0 ) { $candidate = $this->repository->findById( $editId ); if ( $candidate && ( $manageAll || $candidate->instructorId === $instructorId ) ) { $editing = $candidate; } } $offerings = $manageAll ? $this->repository->findAll() : $this->repository->findAll( $instructorId ); include USC_PLUGIN_DIR . 'templates/admin/offerings.php'; } /** * Process the posted add/update/delete action, returning a status notice for * display (e.g. how many booking slots a scheduled class cleared, or that a * booked lesson clashes with it). An empty string means nothing to report. */ private function handleFormAction( int $instructorId, bool $manageAll ): string { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); if ( 'add' === $action ) { $offering = $this->offeringFromPost( $instructorId, $manageAll ); if ( null !== $offering ) { $this->repository->insert( $offering ); return $this->reconcileNotice( $offering ); } } if ( 'update' === $action ) { $offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) ); if ( $offeringId > 0 ) { $existing = $this->repository->findById( $offeringId ); if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) { $offering = $this->offeringFromPost( $instructorId, $manageAll, $existing ); if ( null !== $offering ) { $this->repository->update( $offeringId, $offering ); return $this->reconcileNotice( $offering ); } } } } if ( 'delete' === $action ) { $offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) ); if ( $offeringId > 0 ) { $offering = $this->repository->findById( $offeringId ); if ( $offering && ( $manageAll || $offering->instructorId === $instructorId ) ) { $this->repository->delete( $offeringId ); } } } // phpcs:enable WordPress.Security.NonceVerification.Missing return ''; } /** * Clear the assigned instructor's open booking slots that collide with a * scheduled group class and describe the result, warning about any booked * lesson that clashes (which the studio must resolve by hand). */ private function reconcileNotice( Offering $offering ): string { if ( Offering::KIND_GROUP_CLASS !== $offering->kind ) { return ''; } $result = $this->reconciler->reconcile( $offering ); $parts = []; if ( $result['removed'] > 0 ) { $parts[] = sprintf( /* translators: %d: number of open booking slots removed. */ _n( '%d open booking slot was removed to hold the class time.', '%d open booking slots were removed to hold the class time.', $result['removed'], 'unsupervised-schedular' ), $result['removed'] ); } foreach ( $result['conflicts'] as $startDt ) { $parts[] = sprintf( /* translators: %s: date and time of the already-booked lesson that clashes. */ esc_html__( 'Conflict: a lesson is already booked at %s during this class.', 'unsupervised-schedular' ), (string) mysql2date( 'M j, Y g:i a', $startDt ) ); } return implode( ' ', $parts ); } /** * Instructors offered in the assignment select, by display name. * * Includes everyone holding the `us_instructor` role plus, when the site owner * has left administrators acting as instructors (the default single-account * setup), WordPress administrators — who teach through the dynamic capability * grant rather than the role. Without them a solo studio owner running the * business from an admin account would find no one to assign a class to. * * @return list */ private function instructorOptions(): array { $roles = [ RoleManager::INSTRUCTOR ]; if ( $this->access->adminsAreInstructors() ) { $roles[] = 'administrator'; } $users = array_filter( get_users( [ 'role__in' => $roles, 'orderby' => 'display_name', 'order' => 'ASC', ] ), static fn( mixed $u ): bool => $u instanceof \WP_User ); return array_values( array_map( static fn( \WP_User $u ): array => [ 'id' => (int) $u->ID, 'name' => '' !== (string) $u->display_name ? (string) $u->display_name : (string) $u->user_email, ], $users ) ); } /** * Build an offering from the submitted add/edit form, or null when the * submission is invalid. When `$existing` is given the result is an edit: * it keeps the existing id and currency so an update can never rewrite those * from whoever submits the form. * * The owning instructor normally stays fixed (the creator on add, the existing * owner on edit). A studio admin (`$manageAll`) may instead assign the class to * any instructor via the picker; a blank or absent choice keeps the default. */ private function offeringFromPost( int $instructorId, bool $manageAll, ?Offering $existing = null ): ?Offering { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing $title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) ); $kind = sanitize_key( Val::string( wp_unslash( $_POST['kind'] ?? '' ) ) ); if ( '' === $title || ! in_array( $kind, Offering::VALID_KINDS, true ) ) { return null; } $scheduleNote = $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ); $etransferEmail = $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ); // Reject over-long fixed-size fields rather than let the DB silently drop them. if ( mb_strlen( $title ) > Offering::MAX_TITLE_LENGTH || ( null !== $scheduleNote && mb_strlen( $scheduleNote ) > Offering::MAX_SCHEDULE_NOTE_LENGTH ) || ( null !== $etransferEmail && mb_strlen( $etransferEmail ) > Offering::MAX_ETRANSFER_EMAIL_LENGTH ) ) { return null; } $billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) ); if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) { $billingMode = Offering::BILLING_ONE_TIME; } $duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ); $capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) ); // A blank cutoff means "use the studio default" (null); any entered value // (including 0 — cancel any time) is a per-offering override. $cutoffRaw = trim( sanitize_text_field( Val::string( wp_unslash( $_POST['cancellation_cutoff_hours'] ?? '' ) ) ) ); $cutoffHours = '' === $cutoffRaw ? null : absint( Val::int( $cutoffRaw ) ); // Term dates: a class either meets once (term ends the day it starts) // or repeats weekly for a set number of sessions. $termStart = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['term_start'] ?? '' ) ) ) ); $termEnd = null; if ( null !== $termStart ) { $recurrence = sanitize_key( Val::string( wp_unslash( $_POST['term_recurrence'] ?? 'single' ) ) ); $sessions = absint( Val::int( $_POST['term_sessions'] ?? 1 ) ); $termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart; } $classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) ); // A blank (or invalid) deadline means "use the default" — the first class // day (term_start), applied by Offering::effectiveEnrollmentDeadline(). $enrollmentDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['enrollment_deadline'] ?? '' ) ) ) ); // A blank (or invalid) withdrawal deadline leaves the column NULL, which // keeps self-withdrawal open for the class's whole life // (Offering::isWithdrawalOpen()). A set date closes it after that day. $withdrawalDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['withdrawal_deadline'] ?? '' ) ) ) ); return new Offering( instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ), kind: $kind, title: $title, price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ), currency: null !== $existing ? $existing->currency : 'CAD', billingMode: $billingMode, description: $this->nullableText( sanitize_textarea_field( Val::string( wp_unslash( $_POST['description'] ?? '' ) ) ) ), durationMinutes: $duration > 0 ? $duration : null, allowWeekly: isset( $_POST['allow_weekly'] ), capacity: $capacity > 0 ? $capacity : null, termStart: $termStart, termEnd: $termEnd, classTime: $classTime, enrollmentDeadline: $enrollmentDeadline, withdrawalDeadline: $withdrawalDeadline, scheduleNote: $scheduleNote, etransferEmail: $etransferEmail, cancellationCutoffHours: $cutoffHours, accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC, isActive: isset( $_POST['is_active'] ), id: $existing?->id, ); // phpcs:enable WordPress.Security.NonceVerification.Missing } /** * The instructor the offering should belong to. A studio admin may reassign it * via the posted `class_instructor_id`; otherwise it stays with the existing * owner (edit) or the current user (add). A plain instructor can never change * the owner, so the posted value is ignored unless `$manageAll` is set. */ private function resolveInstructorId( int $instructorId, bool $manageAll, ?Offering $existing ): int { $fallback = null !== $existing ? $existing->instructorId : $instructorId; if ( ! $manageAll ) { return $fallback; } // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:ignore WordPress.Security.NonceVerification.Missing $posted = absint( Val::int( $_POST['class_instructor_id'] ?? 0 ) ); return $posted > 0 ? $posted : $fallback; } private function nullableText( string $value ): ?string { return '' === $value ? null : $value; } }