Add invite-only group classes #96

Merged
thatguygriff merged 1 commits from feature/invite-only-group-classes into main 2026-07-23 16:55:37 +00:00
33 changed files with 1598 additions and 38 deletions
+2 -1
View File
@@ -69,6 +69,7 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
| `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) | | `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) |
| `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) | | `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) |
| `kind` | VARCHAR(10) | `personal` (single-use, per email) or `group` (multi-use link) | | `kind` | VARCHAR(10) | `personal` (single-use, per email) or `group` (multi-use link) |
| `offering_id` | BIGINT UNSIGNED | Set when a personal invite is tied to an invite-only group class (see `group-classes.md`); NULL otherwise |
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` (group links stay `pending` until revoked/expired) | | `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` (group links stay `pending` until revoked/expired) |
| `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited | | `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited |
| `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending / for group links | | `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending / for group links |
@@ -96,7 +97,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite. 1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite.
2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash. 2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash.
3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on. 3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on.
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. 4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. If the invite carries an `offering_id` (a group-class email invite), the new account is linked to the matching access grant so the invite-only class becomes enrollable for them — see `group-classes.md`.
## Flow (self-approval mode) ## Flow (self-approval mode)
1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`). 1. Studio admin enables **Studio Settings → Registration** and selects the registration page (shared with invites, `us_registration_page_id`).
+56 -2
View File
@@ -3,6 +3,8 @@
## Overview ## Overview
Students enrol in a group class — an offering of kind `group_class` — as a commitment for the year. Enrolment is capacity-enforced and billed full-term upfront. Registration reuses the same flow as private lessons (intake questions + policy acceptance + payment). Students enrol in a group class — an offering of kind `group_class` — as a commitment for the year. Enrolment is capacity-enforced and billed full-term upfront. Registration reuses the same flow as private lessons (intake questions + policy acceptance + payment).
A group class can be marked **invite-only** (`us_offerings.access_mode = invite_only`, see `offerings.md`). Invite-only classes are hidden from the public catalog — they never appear in the student booking/group-class list — and can only be enrolled in by students the instructor has let in. See **Invite-only access** below.
## Data Model — `{prefix}us_group_enrollments` ## Data Model — `{prefix}us_group_enrollments`
| Column | Type | Notes | | Column | Type | Notes |
@@ -53,14 +55,62 @@ payment step).
`GET /enrollments` returns the caller's own enrolments, or all enrolments for the `GET /enrollments` returns the caller's own enrolments, or all enrolments for the
instructor's group classes if the caller has `view_own_lessons` on those offerings. instructor's group classes if the caller has `view_own_lessons` on those offerings.
`GET /offerings` (the catalog that feeds the group-class list) returns public
offerings **plus** any invite-only offerings the caller has an access grant for, so a
granted student sees the private class alongside public ones. Ungranted students never
receive it. Enrolling in an invite-only class requires a grant: `POST /enrollments`
rejects an ungranted student with `403 invite_required`, and a successful enrolment
flips their grant from `invited` to `enrolled`.
## Invite-only access
Access to an invite-only class is recorded in `{prefix}us_group_access` — a grant per
person, separate from the enrolment itself. The instructor manages access from
**My Lessons → My Group Classes**, which renders three controls under each invite-only
class:
1. **Add students directly** — the selected registered students are enrolled immediately
(`status = active`) with a **pending payment** at the class price (comp students are
settled at once by `PaymentService`). No access grant is needed — this writes straight
to `us_group_enrollments` + `us_payments`.
2. **Make available** — the selected registered students get an `invited` grant so the
class appears in their own group-class list; they then self-enrol through the normal
paid flow. Each is emailed a "you've been added" notice.
3. **Invite by email** — for an address with no account yet: a tokenised personal invite
(`us_invites`, carrying `offering_id`) is created and the registration link emailed,
alongside an `invited` grant keyed by `email` + `invite_id`. If the address already has
a **pending** invite, the grant is attached to that invite and **no second link is
sent**. An address that already has an account is treated as **Make available** instead.
When an email-invited person completes registration, `RegistrationPage` links their new
account to the grant (`GroupAccessRepository::linkStudentByEmail`), so the invite-only
class becomes enrollable for them — they choose whether to enrol.
### Data Model — `{prefix}us_group_access`
| Column | Type | Notes |
|---------------|-----------------|-----------------------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `offering_id` | BIGINT UNSIGNED | FK → `us_offerings.id` (an invite-only group class) |
| `student_id` | BIGINT UNSIGNED | WordPress user ID; NULL until an email invitee registers |
| `email` | VARCHAR(191) | Email-invite grants only; used to link the account once it registers |
| `invite_id` | BIGINT UNSIGNED | FK → `us_invites.id` for email-invite grants; NULL otherwise |
| `status` | VARCHAR(20) | `invited` / `enrolled` / `revoked` |
| `invited_by` | BIGINT UNSIGNED | Instructor who granted access |
| `created_at` | DATETIME | Insertion time |
## Admin Interface ## Admin Interface
- **Group Classes** (`view_all_lessons` / studio admin): all active enrolments across instructors - **Group Classes** (`view_all_lessons` / studio admin): all active enrolments across instructors
- **My Lessons → My Group Classes** (`view_own_lessons` / instructor): the instructor's - **My Lessons → My Group Classes** (`view_own_lessons` / instructor): the instructor's
own group classes, each showing its active-enrolment count against capacity and a own group classes, each showing its active-enrolment count against capacity and a
per-class roster of enrolled students with enrolment and payment status per-class roster of enrolled students with enrolment and payment status. Invite-only
classes additionally list who has been invited but not yet enrolled and carry the
add/make-available/invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to
the owning instructor)
## Implementation ## Implementation
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates) - Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
- Access grants: `Unsupervised\Schedular\GroupClass\GroupAccess` + `GroupAccessRepository` (`hasGrant`, `findGrantedOfferingIds`, `markEnrolled`, `linkStudentByEmail`)
- Model: `Unsupervised\Schedular\GroupClass\Enrollment` - Model: `Unsupervised\Schedular\GroupClass\Enrollment`
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController``renderPage` (studio admin, `view_all_lessons`) and `renderInstructorPage` (instructor, `view_own_lessons`) - Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController``renderPage` (studio admin, `view_all_lessons`) and `renderInstructorPage` (instructor, `view_own_lessons`)
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint` - REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
@@ -73,7 +123,11 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin
> for the card/e-transfer/comp flows. > for the card/e-transfer/comp flows.
## Tests ## Tests
- `tests/Unit/GroupClass/GroupClassControllerTest.php` - `tests/Unit/GroupClass/GroupClassControllerTest.php` (roster + add/make-available/invite actions)
- `tests/Unit/GroupClass/EnrollmentTest.php` - `tests/Unit/GroupClass/EnrollmentTest.php`
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php` - `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
- `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (invite-only gating)
- `tests/Unit/GroupClass/GroupAccessTest.php`
- `tests/Unit/GroupClass/GroupAccessRepositoryTest.php`
- `tests/Unit/GroupClass/GroupClassPageTest.php` - `tests/Unit/GroupClass/GroupClassPageTest.php`
- `tests/Unit/Offering/OfferingEndpointTest.php` (catalog merges granted invite-only classes)
+1
View File
@@ -22,6 +22,7 @@ An offering is anything a student can register for: a private-lesson type (30 or
| `term_end` | DATE | Group / term offerings — last day; NULL otherwise | | `term_end` | DATE | Group / term offerings — last day; NULL otherwise |
| `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"| | `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"|
| `cancellation_cutoff_hours` | SMALLINT UNSIGNED | Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see `cancellation-cutoff.md`) | | `cancellation_cutoff_hours` | SMALLINT UNSIGNED | Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see `cancellation-cutoff.md`) |
| `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) |
| `is_active` | TINYINT(1) | 0 = hidden from registration, 1 = bookable | | `is_active` | TINYINT(1) | 0 = hidden from registration, 1 = bookable |
| `created_at` | DATETIME | Insertion time | | `created_at` | DATETIME | Insertion time |
+4 -3
View File
@@ -18,6 +18,7 @@ use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\LessonController; use Unsupervised\Schedular\Booking\LessonController;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController; use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -53,15 +54,15 @@ class AdminMenu {
private PaymentController $paymentController; private PaymentController $paymentController;
private PaymentReportController $paymentReportController; private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) { public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer ) {
$this->availabilityController = new AvailabilityController( $availability, $offerings ); $this->availabilityController = new AvailabilityController( $availability, $offerings );
$this->lessonController = new LessonController( $bookings, $payments, $availability ); $this->lessonController = new LessonController( $bookings, $payments, $availability );
$this->offeringController = new OfferingController( $offerings ); $this->offeringController = new OfferingController( $offerings );
$this->questionController = new QuestionController( $questions, $offerings ); $this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites ); $this->registrationController = new RegistrationController( $invites );
$this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() ); $this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments ); $this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) ); $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) );
$this->instructorController = new InstructorController(); $this->instructorController = new InstructorController();
$this->settings = $settings; $this->settings = $settings;
+3
View File
@@ -51,6 +51,7 @@ class Invite {
public readonly ?string $createdAt = null, public readonly ?string $createdAt = null,
public readonly string $kind = self::KIND_PERSONAL, public readonly string $kind = self::KIND_PERSONAL,
public readonly ?string $expiresAt = null, public readonly ?string $expiresAt = null,
public readonly ?int $offeringId = null,
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
@@ -66,6 +67,7 @@ class Invite {
createdAt: Val::stringOrNull( $row->created_at ?? null ), createdAt: Val::stringOrNull( $row->created_at ?? null ),
kind: '' !== Val::string( $row->kind ?? '' ) ? Val::string( $row->kind ) : self::KIND_PERSONAL, kind: '' !== Val::string( $row->kind ?? '' ) ? Val::string( $row->kind ) : self::KIND_PERSONAL,
expiresAt: Val::stringOrNull( $row->expires_at ?? null ), expiresAt: Val::stringOrNull( $row->expires_at ?? null ),
offeringId: Val::intOrNull( $row->offering_id ?? null ),
id: Val::int( $row->id ), id: Val::int( $row->id ),
); );
} }
@@ -132,6 +134,7 @@ class Invite {
'accepted_user_id' => $this->acceptedUserId, 'accepted_user_id' => $this->acceptedUserId,
'accepted_at' => $this->acceptedAt, 'accepted_at' => $this->acceptedAt,
'expires_at' => $this->expiresAt, 'expires_at' => $this->expiresAt,
'offering_id' => $this->offeringId,
]; ];
} }
} }
+2 -1
View File
@@ -23,6 +23,7 @@ class InviteRepository {
'token' => $invite->token, 'token' => $invite->token,
'role' => $invite->role, 'role' => $invite->role,
'kind' => $invite->kind, 'kind' => $invite->kind,
'offering_id' => $invite->offeringId,
'status' => $invite->status, 'status' => $invite->status,
'invited_by' => $invite->invitedBy, 'invited_by' => $invite->invitedBy,
'accepted_user_id' => $invite->acceptedUserId, 'accepted_user_id' => $invite->acceptedUserId,
@@ -30,7 +31,7 @@ class InviteRepository {
'accepted_at' => $invite->acceptedAt, 'accepted_at' => $invite->acceptedAt,
'expires_at' => $invite->expiresAt, 'expires_at' => $invite->expiresAt,
], ],
[ '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ] [ '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s' ]
); );
return false === $result ? 0 : $this->db->insert_id; return false === $result ? 0 : $this->db->insert_id;
+50
View File
@@ -105,6 +105,56 @@ class RegistrationMailer {
return (bool) wp_mail( $email, $subject, $body ); return (bool) wp_mail( $email, $subject, $body );
} }
/**
* Tell a registered student they have been given access to an invite-only
* group class and can now enrol. Returns false when there is no recipient.
*/
public function sendClassAccessGranted( \WP_User $user, string $className ): bool {
if ( '' === (string) $user->user_email ) {
return false;
}
$subject = sprintf(
/* translators: %s: class title */
__( 'You have been invited to %s', 'unsupervised-schedular' ),
$className
);
$body = sprintf(
/* translators: 1: class title, 2: site name, 3: login URL */
__( "You have been given access to the group class \"%1\$s\" at %2\$s.\n\nLog in and open the group classes page to enrol:\n%3\$s", 'unsupervised-schedular' ),
$className,
$this->siteName(),
wp_login_url()
);
return (bool) wp_mail( $user->user_email, $subject, $body );
}
/**
* Email a tokenised registration link to someone invited to a group class who
* does not yet have an account. Returns false when there is no recipient.
*/
public function sendClassInvite( string $email, string $link, string $className ): bool {
if ( '' === $email ) {
return false;
}
$subject = sprintf(
/* translators: %s: class title */
__( 'You are invited to join %s', 'unsupervised-schedular' ),
$className
);
$body = sprintf(
/* translators: 1: class title, 2: site name, 3: registration URL */
__( "You have been invited to the group class \"%1\$s\" at %2\$s.\n\nCreate your account using this link, then choose to enrol in the class:\n%3\$s", 'unsupervised-schedular' ),
$className,
$this->siteName(),
$link
);
return (bool) wp_mail( $email, $subject, $body );
}
private function siteName(): string { private function siteName(): string {
$name = (string) get_bloginfo( 'name' ); $name = (string) get_bloginfo( 'name' );
+7
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth; namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy; use Unsupervised\Schedular\Policy\Policy;
@@ -38,6 +39,7 @@ class RegistrationPage {
private RegistrationMailer $mailer, private RegistrationMailer $mailer,
private QuestionRepository $questions, private QuestionRepository $questions,
private AnswerRepository $answers, private AnswerRepository $answers,
private GroupAccessRepository $access,
) {} ) {}
/** /**
@@ -203,6 +205,11 @@ class RegistrationPage {
if ( $inviteValid && ! $invite->isGroup() ) { if ( $inviteValid && ! $invite->isGroup() ) {
$this->invites->markAccepted( (int) $invite->id, (int) $userId ); $this->invites->markAccepted( (int) $invite->id, (int) $userId );
// A personal invite may carry a group-class grant (invited by email);
// point any grants for this address at the new account so the class
// becomes enrollable for them.
$this->access->linkStudentByEmail( $email, (int) $userId );
wp_set_current_user( (int) $userId ); wp_set_current_user( (int) $userId );
wp_set_auth_cookie( (int) $userId ); wp_set_auth_cookie( (int) $userId );
+13
View File
@@ -19,6 +19,7 @@ class EnrollmentEndpoint {
private OfferingRepository $offerings, private OfferingRepository $offerings,
private RegistrationGate $gate, private RegistrationGate $gate,
private PaymentService $payments, private PaymentService $payments,
private GroupAccessRepository $access,
) {} ) {}
/** /**
@@ -88,6 +89,12 @@ class EnrollmentEndpoint {
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
} }
// Invite-only classes can only be enrolled in by students who were granted
// access (or added directly); everyone else never sees the class at all.
if ( $offering->isInviteOnly() && ! $this->access->hasGrant( $offeringId, $studentId ) ) {
return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) { if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
} }
@@ -110,6 +117,12 @@ class EnrollmentEndpoint {
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); $this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
// Mark the access grant used so instructor rosters distinguish invited
// students from enrolled ones (a no-op for public classes).
if ( $offering->isInviteOnly() ) {
$this->access->markEnrolled( $offeringId, $studentId );
}
$payment = null; $payment = null;
if ( $offering->price > 0.0 ) { if ( $offering->price > 0.0 ) {
$payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); $payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Val;
/**
* A grant of access to an invite-only group class. Registered students who have
* been "made available" a class hold an `invited` grant (`student_id` set);
* email-invited people who do not yet have an account hold a grant keyed by
* `email` and linked to a `us_invites` row, which is pointed at the new
* `student_id` once they register. A grant flips to `enrolled` when the student
* enrols through the normal flow.
*/
class GroupAccess {
public const STATUS_INVITED = 'invited';
public const STATUS_ENROLLED = 'enrolled';
public const STATUS_REVOKED = 'revoked';
/**
* All valid grant statuses.
*
* @var list<string>
*/
public const VALID_STATUSES = [ self::STATUS_INVITED, self::STATUS_ENROLLED, self::STATUS_REVOKED ];
public function __construct(
public readonly int $offeringId,
public readonly ?int $studentId = null,
public readonly string $email = '',
public readonly ?int $inviteId = null,
public readonly string $status = self::STATUS_INVITED,
public readonly ?int $invitedBy = null,
public readonly ?int $id = null,
) {}
public static function fromRow( \stdClass $row ): self {
return new self(
offeringId: Val::int( $row->offering_id ),
studentId: Val::intOrNull( $row->student_id ),
email: Val::string( $row->email ?? '' ),
inviteId: Val::intOrNull( $row->invite_id ?? null ),
status: Val::string( $row->status ),
invitedBy: Val::intOrNull( $row->invited_by ?? null ),
id: Val::int( $row->id ),
);
}
/**
* Returns a plain array representation of the grant.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'offering_id' => $this->offeringId,
'student_id' => $this->studentId,
'email' => $this->email,
'invite_id' => $this->inviteId,
'status' => $this->status,
'invited_by' => $this->invitedBy,
];
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
class GroupAccessRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_group_access';
}
public function insert( GroupAccess $access ): int {
$this->db->insert(
$this->table,
[
'offering_id' => $access->offeringId,
'student_id' => $access->studentId,
'email' => $access->email,
'invite_id' => $access->inviteId,
'status' => $access->status,
'invited_by' => $access->invitedBy,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%s', '%d', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
}
/**
* Whether a student holds a live (invited or enrolled) grant for an offering.
*/
public function hasGrant( int $offeringId, int $studentId ): bool {
$count = (int) $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND student_id = %d AND status IN ( %s, %s )',
$this->table,
$offeringId,
$studentId,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
);
return $count > 0;
}
/**
* The offering ids a student holds a live grant for — the invite-only classes
* to fold into their catalogue view.
*
* @return list<int>
*/
public function findGrantedOfferingIds( int $studentId ): array {
$rows = $this->db->get_col(
$this->db->prepare(
'SELECT DISTINCT offering_id FROM %i WHERE student_id = %d AND status IN ( %s, %s )',
$this->table,
$studentId,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
);
return array_values( array_map( \Unsupervised\Schedular\Val::int( ... ), $rows ) );
}
/**
* All grants for an offering, newest first.
*
* @return list<GroupAccess>
*/
public function findByOffering( int $offeringId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE offering_id = %d ORDER BY id DESC',
$this->table,
$offeringId
)
);
return array_map( GroupAccess::fromRow( ... ), $rows ?? [] );
}
/**
* Point email-invite grants for an address at the account created when the
* invitation was accepted, so the granted class unlocks for the new student.
* Only grants still awaiting an account (`student_id` NULL) are linked.
*/
public function linkStudentByEmail( string $email, int $studentId ): bool {
if ( '' === $email ) {
return false;
}
$sql = $this->db->prepare(
'UPDATE %i SET student_id = %d WHERE email = %s AND student_id IS NULL',
$this->table,
$studentId,
$email
);
return null !== $sql && false !== $this->db->query( $sql );
}
/**
* Flip a student's live grant for an offering to enrolled.
*/
public function markEnrolled( int $offeringId, int $studentId ): bool {
return false !== $this->db->update(
$this->table,
[ 'status' => GroupAccess::STATUS_ENROLLED ],
[
'offering_id' => $offeringId,
'student_id' => $studentId,
],
[ '%s' ],
[ '%d', '%d' ]
);
}
public function revoke( int $id ): bool {
return false !== $this->db->update(
$this->table,
[ 'status' => GroupAccess::STATUS_REVOKED ],
[ 'id' => $id ],
[ '%s' ],
[ '%d' ]
);
}
}
+284 -1
View File
@@ -3,10 +3,17 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass; namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Auth\Invite;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Val;
class GroupClassController { class GroupClassController {
@@ -14,6 +21,10 @@ class GroupClassController {
private EnrollmentRepository $enrollments, private EnrollmentRepository $enrollments,
private OfferingRepository $offerings, private OfferingRepository $offerings,
private PaymentRepository $payments, private PaymentRepository $payments,
private GroupAccessRepository $access,
private PaymentService $paymentService,
private InviteRepository $invites,
private RegistrationMailer $mailer,
) {} ) {}
public function renderPage(): void { public function renderPage(): void {
@@ -41,7 +52,8 @@ class GroupClassController {
/** /**
* Instructor view: their own group classes with per-class rosters. Each class * Instructor view: their own group classes with per-class rosters. Each class
* shows its enrolment count against capacity plus a roster of enrolled * shows its enrolment count against capacity plus a roster of enrolled
* students with enrolment and payment status. * students with enrolment and payment status. Invite-only classes also carry
* controls to add, grant access to, or email-invite students.
*/ */
public function renderInstructorPage(): void { public function renderInstructorPage(): void {
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) { if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
@@ -49,6 +61,12 @@ class GroupClassController {
} }
$instructorId = get_current_user_id(); $instructorId = get_current_user_id();
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
$notice = $this->handleFormAction( $instructorId );
}
$enrollments = $this->enrollments->findByInstructor( $instructorId ); $enrollments = $this->enrollments->findByInstructor( $instructorId );
$classes = array_map( $classes = array_map(
@@ -76,15 +94,280 @@ class GroupClassController {
} }
return [ return [
'id' => $offering->id,
'title' => $offering->title, 'title' => $offering->title,
'capacity' => $offering->capacity, 'capacity' => $offering->capacity,
'enrolled' => $enrolled, 'enrolled' => $enrolled,
'invite_only' => $offering->isInviteOnly(),
'roster' => $roster, 'roster' => $roster,
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
]; ];
}, },
$this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS ) $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS )
); );
$students = $this->studentOptions();
include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php'; include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php';
} }
/**
* Pending (not-yet-enrolled) access grants for an invite-only class, shown so
* the instructor can see who has been invited but has not enrolled yet.
*
* @return list<array{who: string, kind: string}>
*/
private function pendingInvites( int $offeringId ): array {
$out = [];
foreach ( $this->access->findByOffering( $offeringId ) as $grant ) {
if ( GroupAccess::STATUS_INVITED !== $grant->status ) {
continue;
}
if ( null !== $grant->studentId ) {
$user = get_userdata( $grant->studentId );
$out[] = [
'who' => $user ? $user->display_name : (string) $grant->studentId,
'kind' => __( 'Granted', 'unsupervised-schedular' ),
];
} else {
$out[] = [
'who' => $grant->email,
'kind' => __( 'Email invite', 'unsupervised-schedular' ),
];
}
}
return $out;
}
/**
* Handle a posted management action, returning a status notice for display.
* Every action is scoped to a group class the current instructor owns.
*/
private function handleFormAction( int $instructorId ): string {
// Nonce is verified by the caller before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
$offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null;
if ( null === $offering || $offering->instructorId !== $instructorId || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
return esc_html__( 'That group class was not found.', 'unsupervised-schedular' );
}
if ( 'add_direct' === $action ) {
return $this->addDirect( $offering, $this->postedStudentIds() );
}
if ( 'grant_access' === $action ) {
return $this->grantAccess( $offering, $this->postedStudentIds() );
}
if ( 'invite_email' === $action ) {
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
return $this->inviteEmail( $offering, $email );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
return '';
}
/**
* Directly enrol registered students, each with a pending payment at the
* class price (comp students are settled immediately by the payment service).
*
* @param list<int> $studentIds
*/
private function addDirect( Offering $offering, array $studentIds ): string {
$added = 0;
foreach ( $studentIds as $studentId ) {
if ( $this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId ) ) {
continue;
}
$enrollmentId = $this->enrollments->insert(
new Enrollment(
offeringId: (int) $offering->id,
studentId: $studentId,
instructorId: $offering->instructorId,
)
);
if ( $offering->price > 0.0 ) {
$payment = $this->paymentService->createForRegistration(
Payment::REG_ENROLLMENT,
$enrollmentId,
$studentId,
$offering->instructorId,
$offering->price,
$offering->currency,
$offering->etransferEmail
);
if ( null !== $payment && null !== $payment->id ) {
$this->enrollments->setPaymentId( $enrollmentId, $payment->id );
}
}
$this->access->markEnrolled( (int) $offering->id, $studentId );
++$added;
}
/* translators: %d: number of students added. */
return sprintf( esc_html__( '%d student(s) added to the class.', 'unsupervised-schedular' ), $added );
}
/**
* Grant registered students access to the class so it appears in their list
* for self-enrolment, notifying each by email.
*
* @param list<int> $studentIds
*/
private function grantAccess( Offering $offering, array $studentIds ): string {
$granted = 0;
foreach ( $studentIds as $studentId ) {
if (
$this->enrollments->hasActiveEnrollment( (int) $offering->id, $studentId )
|| $this->access->hasGrant( (int) $offering->id, $studentId )
) {
continue;
}
$this->access->insert(
new GroupAccess(
offeringId: (int) $offering->id,
studentId: $studentId,
status: GroupAccess::STATUS_INVITED,
invitedBy: get_current_user_id(),
)
);
$user = get_userdata( $studentId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendClassAccessGranted( $user, $offering->title );
}
++$granted;
}
/* translators: %d: number of students granted access. */
return sprintf( esc_html__( '%d student(s) granted access.', 'unsupervised-schedular' ), $granted );
}
/**
* Invite someone by email. A registered address is treated as a grant; an
* unknown address gets a tokenised registration invite tied to the class,
* reusing any pending invite already outstanding for that address (in which
* case no new link is sent).
*/
private function inviteEmail( Offering $offering, string $email ): string {
if ( ! is_email( $email ) ) {
return esc_html__( 'Enter a valid email address.', 'unsupervised-schedular' );
}
$existingUserId = email_exists( $email );
if ( false !== $existingUserId ) {
return $this->grantAccess( $offering, [ (int) $existingUserId ] );
}
// Reuse an outstanding invite rather than mailing a second link; still
// attach a class grant so enrolment unlocks once they register.
$pending = $this->invites->findPendingByEmail( $email );
if ( null !== $pending ) {
$this->access->insert(
new GroupAccess(
offeringId: (int) $offering->id,
email: $email,
inviteId: $pending->id,
status: GroupAccess::STATUS_INVITED,
invitedBy: get_current_user_id(),
)
);
return esc_html__( 'This person already has a pending invitation; the class was added to it. No new link was sent.', 'unsupervised-schedular' );
}
$rawToken = wp_generate_password( 32, false );
$inviteId = $this->invites->insert(
new Invite(
email: $email,
token: Invite::hashToken( $rawToken ),
invitedBy: get_current_user_id(),
offeringId: (int) $offering->id,
)
);
if ( $inviteId <= 0 ) {
return esc_html__( 'Could not create the invite. Deactivate and reactivate the plugin to update the database, then try again.', 'unsupervised-schedular' );
}
$this->access->insert(
new GroupAccess(
offeringId: (int) $offering->id,
email: $email,
inviteId: $inviteId,
status: GroupAccess::STATUS_INVITED,
invitedBy: get_current_user_id(),
)
);
$this->mailer->sendClassInvite( $email, $this->registrationLink( $rawToken ), $offering->title );
return esc_html__( 'Invitation sent.', 'unsupervised-schedular' );
}
/**
* Registered students to offer in the add/grant selects, by display name.
*
* @return list<array{id: int, name: string}>
*/
private function studentOptions(): array {
$users = array_filter(
get_users(
[
'role' => RoleManager::STUDENT,
'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
)
);
}
/**
* The de-duplicated positive student ids posted from a multi-select.
*
* @return list<int>
*/
private function postedStudentIds(): array {
// Nonce is verified by the caller before this method runs.
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int below; slashes cannot survive integer coercion.
$raw = (array) ( $_POST['student_ids'] ?? [] );
$ids = array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $raw ) );
return array_values( array_unique( $ids ) );
}
/**
* Build the registration URL for a raw invite token, mirroring the invites
* admin page so class invites land on the same registration page.
*/
private function registrationLink( string $rawToken ): string {
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
$linkBase = $pageId > 0 ? (string) get_permalink( $pageId ) : '';
return add_query_arg( 'us_invite', rawurlencode( $rawToken ), '' !== $linkBase ? $linkBase : home_url( '/' ) );
}
} }
+24
View File
@@ -27,6 +27,19 @@ class Offering {
*/ */
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ]; public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ];
/** Listed in the public catalogue; anyone with `book_lesson` may enrol. */
public const ACCESS_PUBLIC = 'public';
/** Hidden from the catalogue; only invited/added students may enrol (group classes). */
public const ACCESS_INVITE_ONLY = 'invite_only';
/**
* All valid access modes.
*
* @var list<string>
*/
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ];
public function __construct( public function __construct(
public readonly int $instructorId, public readonly int $instructorId,
public readonly string $kind, public readonly string $kind,
@@ -43,10 +56,19 @@ class Offering {
public readonly ?string $scheduleNote = null, public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null, public readonly ?string $etransferEmail = null,
public readonly ?int $cancellationCutoffHours = null, public readonly ?int $cancellationCutoffHours = null,
public readonly string $accessMode = self::ACCESS_PUBLIC,
public readonly bool $isActive = true, public readonly bool $isActive = true,
public readonly ?int $id = null, public readonly ?int $id = null,
) {} ) {}
/**
* Whether the offering is hidden from the public catalogue and reachable
* only by invited or directly-added students.
*/
public function isInviteOnly(): bool {
return self::ACCESS_INVITE_ONLY === $this->accessMode;
}
/** /**
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is * Normalise a submitted term date to canonical `Y-m-d`, or null when it is
* not a real calendar date. Round-trips through DateTimeImmutable so * not a real calendar date. Round-trips through DateTimeImmutable so
@@ -85,6 +107,7 @@ class Offering {
scheduleNote: Val::stringOrNull( $row->schedule_note ), scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ), etransferEmail: Val::stringOrNull( $row->etransfer_email ),
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ), cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
accessMode: '' !== Val::string( $row->access_mode ?? '' ) ? Val::string( $row->access_mode ) : self::ACCESS_PUBLIC,
isActive: Val::bool( $row->is_active ), isActive: Val::bool( $row->is_active ),
id: Val::int( $row->id ), id: Val::int( $row->id ),
); );
@@ -116,6 +139,7 @@ class Offering {
'term_end' => $this->termEnd, 'term_end' => $this->termEnd,
'schedule_note' => $this->scheduleNote, 'schedule_note' => $this->scheduleNote,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours, 'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode,
'is_active' => $this->isActive, 'is_active' => $this->isActive,
]; ];
+1
View File
@@ -135,6 +135,7 @@ class OfferingController {
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ), 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'] ?? '' ) ) ) ), etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
cancellationCutoffHours: $cutoffHours, cancellationCutoffHours: $cutoffHours,
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
isActive: isset( $_POST['is_active'] ), isActive: isset( $_POST['is_active'] ),
id: $existing?->id, id: $existing?->id,
); );
+55 -6
View File
@@ -4,11 +4,15 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering; namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Val; use Unsupervised\Schedular\Val;
class OfferingEndpoint { class OfferingEndpoint {
public function __construct( private OfferingRepository $repository ) {} public function __construct(
private OfferingRepository $repository,
private GroupAccessRepository $access,
) {}
/** /**
* Registers this endpoint's REST routes. * Registers this endpoint's REST routes.
@@ -62,16 +66,53 @@ class OfferingEndpoint {
} }
public function index( \WP_REST_Request $request ): \WP_REST_Response { public function index( \WP_REST_Request $request ): \WP_REST_Response {
$offerings = $this->repository->findAll( $instructorId = Val::int( $request->get_param( 'instructor_id' ) );
Val::int( $request->get_param( 'instructor_id' ) ), $kind = Val::string( $request->get_param( 'kind' ) );
Val::string( $request->get_param( 'kind' ) ),
activeOnly: true, // The public catalogue is public offerings only; invite-only classes are
); // hidden from it and surfaced separately to the students granted access.
$offerings = $this->repository->findAll( $instructorId, $kind, activeOnly: true, accessMode: Offering::ACCESS_PUBLIC );
foreach ( $this->grantedInviteOnly( $instructorId, $kind ) as $granted ) {
$offerings[] = $granted;
}
// Public listing: omit the private e-transfer destination email. // Public listing: omit the private e-transfer destination email.
return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray( includeEtransferEmail: false ), $offerings ), 200 ); return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray( includeEtransferEmail: false ), $offerings ), 200 );
} }
/**
* The active invite-only offerings the caller has been granted access to,
* matching the same instructor/kind filters as the public catalogue.
*
* @return list<Offering>
*/
private function grantedInviteOnly( int $instructorId, string $kind ): array {
$grantedIds = $this->access->findGrantedOfferingIds( get_current_user_id() );
if ( [] === $grantedIds ) {
return [];
}
$out = [];
foreach ( $grantedIds as $offeringId ) {
$offering = $this->repository->findById( $offeringId );
if (
null === $offering
|| ! $offering->isActive
|| ! $offering->isInviteOnly()
|| ( $instructorId > 0 && $offering->instructorId !== $instructorId )
|| ( '' !== $kind && $offering->kind !== $kind )
) {
continue;
}
$out[] = $offering;
}
return $out;
}
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ); $title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) );
if ( '' === $title ) { if ( '' === $title ) {
@@ -104,6 +145,7 @@ class OfferingEndpoint {
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ), scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ), etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ), cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ),
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ), isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
); );
@@ -150,6 +192,7 @@ class OfferingEndpoint {
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote, scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote,
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail, etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail,
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours, cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode,
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive, isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
id: $id, id: $id,
); );
@@ -212,6 +255,12 @@ class OfferingEndpoint {
return '' !== $email ? $email : null; return '' !== $email ? $email : null;
} }
private function accessMode( mixed $value, string $fallback ): string {
$mode = Val::string( $value );
return in_array( $mode, Offering::VALID_ACCESS_MODES, true ) ? $mode : $fallback;
}
private function nullableInt( mixed $value ): ?int { private function nullableInt( mixed $value ): ?int {
return ( null === $value || '' === $value ) ? null : Val::int( $value ); return ( null === $value || '' === $value ) ? null : Val::int( $value );
} }
+12 -4
View File
@@ -15,11 +15,11 @@ class OfferingRepository {
* Column formats aligned to {@see columns()} (instructor_id, kind, title, * Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly, * description, duration_minutes, price, currency, billing_mode, allow_weekly,
* capacity, term_start, term_end, schedule_note, etransfer_email, * capacity, term_start, term_end, schedule_note, etransfer_email,
* cancellation_cutoff_hours, is_active). * cancellation_cutoff_hours, access_mode, is_active).
* *
* @var list<string> * @var list<string>
*/ */
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%d' ]; private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
public function insert( Offering $offering ): int { public function insert( Offering $offering ): int {
$this->db->insert( $this->db->insert(
@@ -63,16 +63,19 @@ class OfferingRepository {
'schedule_note' => $offering->scheduleNote, 'schedule_note' => $offering->scheduleNote,
'etransfer_email' => $offering->etransferEmail, 'etransfer_email' => $offering->etransferEmail,
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours, 'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
'access_mode' => $offering->accessMode,
'is_active' => $offering->isActive ? 1 : 0, 'is_active' => $offering->isActive ? 1 : 0,
]; ];
} }
/** /**
* Find offerings, optionally filtered by instructor, kind, and active state. * Find offerings, optionally filtered by instructor, kind, active state, and
* access mode (e.g. `Offering::ACCESS_PUBLIC` to exclude invite-only classes
* from the public catalogue).
* *
* @return list<Offering> * @return list<Offering>
*/ */
public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null ): array { public function findAll( int $instructorId = 0, string $kind = '', ?bool $activeOnly = null, ?string $accessMode = null ): array {
$where = [ '1 = 1' ]; $where = [ '1 = 1' ];
$params = []; $params = [];
@@ -91,6 +94,11 @@ class OfferingRepository {
$params[] = $activeOnly ? 1 : 0; $params[] = $activeOnly ? 1 : 0;
} }
if ( null !== $accessMode ) {
$where[] = 'access_mode = %s';
$params[] = $accessMode;
}
$whereClause = implode( ' AND ', $where ); $whereClause = implode( ' AND ', $where );
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY title ASC"; $sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY title ASC";
+5 -3
View File
@@ -14,6 +14,7 @@ use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassPage; use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
@@ -58,6 +59,7 @@ class Plugin {
$acceptances = new AcceptanceRepository( $wpdb ); $acceptances = new AcceptanceRepository( $wpdb );
$invites = new InviteRepository( $wpdb ); $invites = new InviteRepository( $wpdb );
$enrollments = new EnrollmentRepository( $wpdb ); $enrollments = new EnrollmentRepository( $wpdb );
$groupAccess = new GroupAccessRepository( $wpdb );
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances ); $registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
$paymentRepo = new PaymentRepository( $wpdb ); $paymentRepo = new PaymentRepository( $wpdb );
@@ -72,15 +74,15 @@ class Plugin {
$bookingPage = new BookingPage(); $bookingPage = new BookingPage();
$loginPage = new LoginPage(); $loginPage = new LoginPage();
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers ); $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
$groupClassPage = new GroupClassPage(); $groupClassPage = new GroupClassPage();
( new UpdateChecker() )->register(); ( new UpdateChecker() )->register();
( new RoleManager() )->register(); ( new RoleManager() )->register();
( new RegistrationLoginGate() )->register(); ( new RegistrationLoginGate() )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register(); ( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register(); ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register(); ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
} }
+4 -3
View File
@@ -9,6 +9,7 @@ use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\CancellationPolicy; use Unsupervised\Schedular\Booking\CancellationPolicy;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -35,13 +36,13 @@ class RestRegistrar {
private EnrollmentEndpoint $enrollmentEndpoint; private EnrollmentEndpoint $enrollmentEndpoint;
private PaymentEndpoint $paymentEndpoint; private PaymentEndpoint $paymentEndpoint;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, PaymentService $paymentService ) { public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings ); $this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) ); $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) );
$this->offeringEndpoint = new OfferingEndpoint( $offerings ); $this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService ); $this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService ); $this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess );
$this->paymentEndpoint = new PaymentEndpoint( $paymentService ); $this->paymentEndpoint = new PaymentEndpoint( $paymentService );
} }
+18
View File
@@ -66,6 +66,7 @@ class Schema {
schedule_note VARCHAR(191) DEFAULT NULL, schedule_note VARCHAR(191) DEFAULT NULL,
etransfer_email VARCHAR(191) DEFAULT NULL, etransfer_email VARCHAR(191) DEFAULT NULL,
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL, cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
access_mode VARCHAR(20) NOT NULL DEFAULT 'public',
is_active TINYINT(1) NOT NULL DEFAULT 1, is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
@@ -190,6 +191,7 @@ class Schema {
token VARCHAR(64) NOT NULL, token VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'us_student', role VARCHAR(32) NOT NULL DEFAULT 'us_student',
kind VARCHAR(10) NOT NULL DEFAULT 'personal', kind VARCHAR(10) NOT NULL DEFAULT 'personal',
offering_id BIGINT UNSIGNED DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
invited_by BIGINT UNSIGNED DEFAULT NULL, invited_by BIGINT UNSIGNED DEFAULT NULL,
accepted_user_id BIGINT UNSIGNED DEFAULT NULL, accepted_user_id BIGINT UNSIGNED DEFAULT NULL,
@@ -201,6 +203,22 @@ class Schema {
KEY email (email), KEY email (email),
KEY status (status) KEY status (status)
) {$charset};", ) {$charset};",
"CREATE TABLE {$prefix}us_group_access (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
offering_id BIGINT UNSIGNED NOT NULL,
student_id BIGINT UNSIGNED DEFAULT NULL,
email VARCHAR(191) NOT NULL DEFAULT '',
invite_id BIGINT UNSIGNED DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'invited',
invited_by BIGINT UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY offering_id (offering_id),
KEY student_id (student_id),
KEY email (email),
KEY status (status)
) {$charset};",
]; ];
} }
} }
+66 -1
View File
@@ -5,18 +5,29 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
/** @var list<array{title: string, capacity: int|null, enrolled: int, roster: list<array{student: string, status: string, payment: string|null}>}> $classes */ /**
* @var list<array{id: int|null, title: string, capacity: int|null, enrolled: int, invite_only: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}> $classes
* @var list<array{id: int, name: string}> $students
* @var string $notice
*/
?> ?>
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e('My Group Classes', 'unsupervised-schedular'); ?></h1> <h1><?php esc_html_e('My Group Classes', 'unsupervised-schedular'); ?></h1>
<p class="description"><?php esc_html_e('Your group classes and their rosters.', 'unsupervised-schedular'); ?></p> <p class="description"><?php esc_html_e('Your group classes and their rosters.', 'unsupervised-schedular'); ?></p>
<?php if ('' !== $notice) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
<?php endif; ?>
<?php if (empty($classes)) : ?> <?php if (empty($classes)) : ?>
<p><?php esc_html_e('You have no group classes.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('You have no group classes.', 'unsupervised-schedular'); ?></p>
<?php else : ?> <?php else : ?>
<?php foreach ($classes as $class) : ?> <?php foreach ($classes as $class) : ?>
<h2> <h2>
<?php echo esc_html($class['title']); ?> <?php echo esc_html($class['title']); ?>
<?php if ($class['invite_only']) : ?>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
<?php endif; ?>
<span class="count"> <span class="count">
<?php <?php
if (null === $class['capacity']) { if (null === $class['capacity']) {
@@ -59,6 +70,60 @@ if (! defined('ABSPATH')) {
</tbody> </tbody>
</table> </table>
<?php endif; ?> <?php endif; ?>
<?php if ($class['invite_only']) : ?>
<?php if (! empty($class['invited'])) : ?>
<h4><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h4>
<ul class="ul-disc">
<?php foreach ($class['invited'] as $invitee) : ?>
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;">
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Add students directly', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('Enrols them now with a pending payment.', 'unsupervised-schedular'); ?></p>
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
<?php foreach ($students as $student) : ?>
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
<?php endforeach; ?>
</select>
<p>
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
</p>
</form>
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Make available to students', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('Grants access so they can enrol themselves.', 'unsupervised-schedular'); ?></p>
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
<?php foreach ($students as $student) : ?>
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
<?php endforeach; ?>
</select>
<p>
<button type="submit" name="usc_action" value="grant_access" class="button"><?php esc_html_e('Grant access', 'unsupervised-schedular'); ?></button>
</p>
</form>
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Invite by email', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('For someone without an account yet.', 'unsupervised-schedular'); ?></p>
<input type="email" name="email" class="regular-text" placeholder="<?php esc_attr_e('[email protected]', 'unsupervised-schedular'); ?>">
<p>
<button type="submit" name="usc_action" value="invite_email" class="button"><?php esc_html_e('Send invite', 'unsupervised-schedular'); ?></button>
</p>
</form>
</div>
<?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
+13 -1
View File
@@ -110,6 +110,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<p class="description"><?php esc_html_e('How many hours before a lesson a student may still cancel it. Leave blank to use the studio default; 0 lets students cancel any time.', 'unsupervised-schedular'); ?></p> <p class="description"><?php esc_html_e('How many hours before a lesson a student may still cancel it. Leave blank to use the studio default; 0 lets students cancel any time.', 'unsupervised-schedular'); ?></p>
</td> </td>
</tr> </tr>
<tr>
<th><?php esc_html_e('Invite only', 'unsupervised-schedular'); ?></th>
<td>
<label><input type="checkbox" name="invite_only" value="1" <?php echo $editing && $editing->isInviteOnly() ? 'checked' : ''; ?>> <?php esc_html_e('Hide from the booking list — students join by invitation only (group classes)', 'unsupervised-schedular'); ?></label>
<p class="description"><?php esc_html_e('Invite-only classes never appear in the student catalog. Add or invite students from My Lessons → My Group Classes.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr> <tr>
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th> <th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
<td><label><input type="checkbox" name="is_active" value="1" <?php echo null === $editing || $editing->isActive ? 'checked' : ''; ?>> <?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?></label></td> <td><label><input type="checkbox" name="is_active" value="1" <?php echo null === $editing || $editing->isActive ? 'checked' : ''; ?>> <?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?></label></td>
@@ -144,7 +151,12 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<?php foreach ($offerings as $offering) : ?> <?php foreach ($offerings as $offering) : ?>
<tr> <tr>
<td><?php echo esc_html((string) $offering->id); ?></td> <td><?php echo esc_html((string) $offering->id); ?></td>
<td><?php echo esc_html($offering->title); ?></td> <td>
<?php echo esc_html($offering->title); ?>
<?php if ($offering->isInviteOnly()) : ?>
<span class="dashicons dashicons-lock" title="<?php esc_attr_e('Invite only', 'unsupervised-schedular'); ?>"></span>
<?php endif; ?>
</td>
<td><?php echo esc_html($offering->kind); ?></td> <td><?php echo esc_html($offering->kind); ?></td>
<td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '&mdash;'; ?></td> <td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '&mdash;'; ?></td>
<td><?php echo esc_html(number_format($offering->price, 2) . ' ' . $offering->currency); ?></td> <td><?php echo esc_html(number_format($offering->price, 2) . ' ' . $offering->currency); ?></td>
+2 -1
View File
@@ -35,11 +35,12 @@ class InviteRepositoryTest extends TestCase
return $d['email'] === '[email protected]' return $d['email'] === '[email protected]'
&& $d['token'] === 'tok123' && $d['token'] === 'tok123'
&& $d['kind'] === Invite::KIND_PERSONAL && $d['kind'] === Invite::KIND_PERSONAL
&& $d['offering_id'] === null
&& $d['status'] === Invite::STATUS_PENDING && $d['status'] === Invite::STATUS_PENDING
&& $d['invited_by'] === 2 && $d['invited_by'] === 2
&& $d['expires_at'] === null; && $d['expires_at'] === null;
}), }),
['%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s'] ['%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s']
); );
$this->db->insert_id = 5; $this->db->insert_id = 5;
+35 -1
View File
@@ -155,8 +155,42 @@ class InviteTest extends TestCase
{ {
$arr = (new Invite('[email protected]', 'tok', id: 1))->toArray(); $arr = (new Invite('[email protected]', 'tok', id: 1))->toArray();
foreach (['id', 'email', 'token', 'role', 'kind', 'status', 'invited_by', 'accepted_user_id', 'accepted_at', 'expires_at'] as $key) { foreach (['id', 'email', 'token', 'role', 'kind', 'status', 'invited_by', 'accepted_user_id', 'accepted_at', 'expires_at', 'offering_id'] as $key) {
self::assertArrayHasKey($key, $arr); self::assertArrayHasKey($key, $arr);
} }
} }
public function testOfferingIdRoundTrips(): void
{
$invite = Invite::fromRow((object) [
'id' => '5',
'email' => '[email protected]',
'token' => 'tok123',
'role' => RoleManager::STUDENT,
'status' => Invite::STATUS_PENDING,
'invited_by' => '2',
'accepted_user_id' => null,
'accepted_at' => null,
'offering_id' => '8',
]);
self::assertSame(8, $invite->offeringId);
self::assertSame(8, $invite->toArray()['offering_id']);
}
public function testOfferingIdDefaultsToNullWhenColumnMissing(): void
{
$invite = Invite::fromRow((object) [
'id' => '5',
'email' => '[email protected]',
'token' => 'tok123',
'role' => RoleManager::STUDENT,
'status' => Invite::STATUS_PENDING,
'invited_by' => null,
'accepted_user_id' => null,
'accepted_at' => null,
]);
self::assertNull($invite->offeringId);
}
} }
@@ -81,4 +81,42 @@ class RegistrationMailerTest extends TestCase
{ {
self::assertFalse((new RegistrationMailer())->sendRejected('')); self::assertFalse((new RegistrationMailer())->sendRejected(''));
} }
public function testSendClassAccessGrantedEmailsTheStudent(): void
{
Functions\expect('wp_mail')
->once()
->with(
'[email protected]',
Mockery::on(static fn (string $subject): bool => str_contains($subject, 'Choir')),
Mockery::on(static fn (string $body): bool => str_contains($body, 'Choir'))
)
->andReturn(true);
self::assertTrue((new RegistrationMailer())->sendClassAccessGranted($this->user('[email protected]'), 'Choir'));
}
public function testSendClassAccessGrantedReturnsFalseWithoutRecipient(): void
{
self::assertFalse((new RegistrationMailer())->sendClassAccessGranted($this->user(''), 'Choir'));
}
public function testSendClassInviteIncludesTheLink(): void
{
Functions\expect('wp_mail')
->once()
->with(
'[email protected]',
Mockery::type('string'),
Mockery::on(static fn (string $body): bool => str_contains($body, 'http://join.test'))
)
->andReturn(true);
self::assertTrue((new RegistrationMailer())->sendClassInvite('[email protected]', 'http://join.test', 'Choir'));
}
public function testSendClassInviteReturnsFalseWithoutRecipient(): void
{
self::assertFalse((new RegistrationMailer())->sendClassInvite('', 'http://join.test', 'Choir'));
}
} }
+26
View File
@@ -9,6 +9,7 @@ use Unsupervised\Schedular\Auth\Invite;
use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository; use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\Policy; use Unsupervised\Schedular\Policy\Policy;
@@ -45,11 +46,15 @@ class RegistrationPageTest extends TestCase
$questions->shouldReceive('findByScope')->andReturn([])->byDefault(); $questions->shouldReceive('findByScope')->andReturn([])->byDefault();
$answers->shouldReceive('insert')->andReturn(1)->byDefault(); $answers->shouldReceive('insert')->andReturn(1)->byDefault();
$access = Mockery::mock(GroupAccessRepository::class);
$access->shouldReceive('linkStudentByEmail')->andReturn(true)->byDefault();
$this->ctx = [ $this->ctx = [
'invites' => $invites, 'invites' => $invites,
'policies' => $policies, 'policies' => $policies,
'questions' => $questions, 'questions' => $questions,
'answers' => $answers, 'answers' => $answers,
'access' => $access,
'mailer' => Mockery::mock(RegistrationMailer::class), 'mailer' => Mockery::mock(RegistrationMailer::class),
'settings' => Mockery::mock(StudioSettings::class), 'settings' => Mockery::mock(StudioSettings::class),
]; ];
@@ -63,6 +68,7 @@ class RegistrationPageTest extends TestCase
$this->ctx['mailer'], $this->ctx['mailer'],
$questions, $questions,
$answers, $answers,
$access,
); );
$_POST = []; $_POST = [];
@@ -110,6 +116,26 @@ class RegistrationPageTest extends TestCase
self::assertSame('invite', $this->submit($invite, false)); self::assertSame('invite', $this->submit($invite, false));
} }
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
Functions\when('is_wp_error')->justReturn(false);
$this->ctx['invites']->shouldReceive('markAccepted')->once();
Functions\when('wp_set_current_user')->justReturn(null);
Functions\when('wp_set_auth_cookie')->justReturn(null);
// A personal invite tied to a class grant links the new account to it.
$this->ctx['access']->shouldReceive('linkStudentByEmail')->once()->with('[email protected]', 42)->andReturn(true);
$invite = new Invite(email: '[email protected]', token: 'hash', offeringId: 8);
self::assertSame('invite', $this->submit($invite, false));
}
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
{ {
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ]; $_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
@@ -7,6 +7,7 @@ use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint; use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\Payment;
@@ -20,6 +21,7 @@ class EnrollmentEndpointTest extends TestCase
private OfferingRepository $offerings; private OfferingRepository $offerings;
private RegistrationGate $gate; private RegistrationGate $gate;
private PaymentService $payments; private PaymentService $payments;
private GroupAccessRepository $access;
private EnrollmentEndpoint $endpoint; private EnrollmentEndpoint $endpoint;
protected function setUp(): void protected function setUp(): void
@@ -35,12 +37,14 @@ class EnrollmentEndpointTest extends TestCase
$this->offerings = Mockery::mock(OfferingRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class);
$this->gate = Mockery::mock(RegistrationGate::class); $this->gate = Mockery::mock(RegistrationGate::class);
$this->payments = Mockery::mock(PaymentService::class); $this->payments = Mockery::mock(PaymentService::class);
$this->access = Mockery::mock(GroupAccessRepository::class);
$this->endpoint = new EnrollmentEndpoint( $this->endpoint = new EnrollmentEndpoint(
$this->enrollments, $this->enrollments,
$this->offerings, $this->offerings,
$this->gate, $this->gate,
$this->payments, $this->payments,
$this->access,
); );
} }
@@ -49,6 +53,11 @@ class EnrollmentEndpointTest extends TestCase
return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: $price, id: 8); return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: $price, id: 8);
} }
private function inviteOnlyOffering(): Offering
{
return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Private Choir', accessMode: Offering::ACCESS_INVITE_ONLY, id: 8);
}
private function expectSuccessfulEnrollment(): void private function expectSuccessfulEnrollment(): void
{ {
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
@@ -98,4 +107,31 @@ class EnrollmentEndpointTest extends TestCase
$result->get_data()['payment'] $result->get_data()['payment']
); );
} }
public function testInviteOnlyClassRejectsStudentWithoutGrant(): void
{
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(false);
$this->enrollments->shouldReceive('insert')->never();
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invite_required', $result->get_error_code());
self::assertSame(403, $result->error_data['invite_required']['status']);
}
public function testInviteOnlyClassAllowsGrantedStudentAndMarksEnrolled(): void
{
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
$this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(true);
$this->expectSuccessfulEnrollment();
$this->access->shouldReceive('markEnrolled')->once()->with(8, 5);
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
self::assertInstanceOf(\WP_REST_Response::class, $result);
self::assertSame(201, $result->get_status());
}
} }
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\GroupClass\GroupAccess;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GroupAccessRepositoryTest extends TestCase
{
private \wpdb $db;
private GroupAccessRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->db = Mockery::mock(\wpdb::class);
$this->db->prefix = 'wp_';
$this->repo = new GroupAccessRepository($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_access',
Mockery::on(static function (array $d): bool {
return $d['offering_id'] === 8
&& $d['student_id'] === 5
&& $d['status'] === GroupAccess::STATUS_INVITED;
}),
['%d', '%d', '%s', '%d', '%s', '%d', '%s']
);
$this->db->insert_id = 3;
self::assertSame(3, $this->repo->insert(new GroupAccess(offeringId: 8, studentId: 5, invitedBy: 2)));
}
public function testHasGrantTrueWhenCountPositive(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/offering_id = %d AND student_id = %d AND status IN/'),
'wp_us_group_access',
8,
5,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->andReturn('1');
self::assertTrue($this->repo->hasGrant(8, 5));
}
public function testHasGrantFalseWhenZero(): void
{
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->andReturn('0');
self::assertFalse($this->repo->hasGrant(8, 5));
}
public function testFindGrantedOfferingIdsReturnsInts(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/DISTINCT offering_id.*student_id = %d/s'),
'wp_us_group_access',
5,
GroupAccess::STATUS_INVITED,
GroupAccess::STATUS_ENROLLED
)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_col')->andReturn(['8', '9']);
self::assertSame([8, 9], $this->repo->findGrantedOfferingIds(5));
}
public function testFindByOfferingMapsRows(): void
{
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([
(object) [
'id' => '1',
'offering_id' => '8',
'student_id' => '5',
'email' => '',
'invite_id' => null,
'status' => GroupAccess::STATUS_INVITED,
'invited_by' => '2',
],
]);
$grants = $this->repo->findByOffering(8);
self::assertCount(1, $grants);
self::assertSame(5, $grants[0]->studentId);
}
public function testLinkStudentByEmailUpdatesNullStudentRows(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/UPDATE %i SET student_id = %d WHERE email = %s AND student_id IS NULL/'),
'wp_us_group_access',
5,
'[email protected]'
)
->andReturn('UPDATE ...');
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1);
self::assertTrue($this->repo->linkStudentByEmail('[email protected]', 5));
}
public function testLinkStudentByEmailIgnoresEmptyEmail(): void
{
$this->db->shouldReceive('prepare')->never();
self::assertFalse($this->repo->linkStudentByEmail('', 5));
}
public function testMarkEnrolledUpdatesStatus(): void
{
$this->db->shouldReceive('update')
->once()
->with(
'wp_us_group_access',
['status' => GroupAccess::STATUS_ENROLLED],
['offering_id' => 8, 'student_id' => 5],
['%s'],
['%d', '%d']
)
->andReturn(1);
self::assertTrue($this->repo->markEnrolled(8, 5));
}
public function testRevokeUpdatesStatus(): void
{
$this->db->shouldReceive('update')
->once()
->with('wp_us_group_access', ['status' => GroupAccess::STATUS_REVOKED], ['id' => 3], ['%s'], ['%d'])
->andReturn(1);
self::assertTrue($this->repo->revoke(3));
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Unsupervised\Schedular\GroupClass\GroupAccess;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GroupAccessTest extends TestCase
{
public function testDefaultsToInvitedStatusAndNullStudent(): void
{
$access = new GroupAccess(offeringId: 8);
self::assertSame(GroupAccess::STATUS_INVITED, $access->status);
self::assertNull($access->studentId);
self::assertSame('', $access->email);
}
public function testFromRowMapsColumns(): void
{
$row = (object) [
'id' => '3',
'offering_id' => '8',
'student_id' => '5',
'email' => '[email protected]',
'invite_id' => '9',
'status' => GroupAccess::STATUS_ENROLLED,
'invited_by' => '2',
];
$access = GroupAccess::fromRow($row);
self::assertSame(3, $access->id);
self::assertSame(8, $access->offeringId);
self::assertSame(5, $access->studentId);
self::assertSame('[email protected]', $access->email);
self::assertSame(9, $access->inviteId);
self::assertSame(GroupAccess::STATUS_ENROLLED, $access->status);
self::assertSame(2, $access->invitedBy);
}
public function testFromRowCastsNullStudentAndInvite(): void
{
$row = (object) [
'id' => '3',
'offering_id' => '8',
'student_id' => null,
'email' => '[email protected]',
'invite_id' => null,
'status' => GroupAccess::STATUS_INVITED,
'invited_by' => null,
];
$access = GroupAccess::fromRow($row);
self::assertNull($access->studentId);
self::assertNull($access->inviteId);
self::assertNull($access->invitedBy);
}
public function testToArrayContainsExpectedKeys(): void
{
$arr = (new GroupAccess(offeringId: 8, studentId: 5, id: 3))->toArray();
foreach (['id', 'offering_id', 'student_id', 'email', 'invite_id', 'status', 'invited_by'] as $key) {
self::assertArrayHasKey($key, $arr);
}
}
}
@@ -5,13 +5,17 @@ namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\GroupClass\Enrollment; use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController; use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository; use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Tests\Unit\TestCase; use Unsupervised\Schedular\Tests\Unit\TestCase;
class GroupClassControllerTest extends TestCase class GroupClassControllerTest extends TestCase
@@ -19,6 +23,10 @@ class GroupClassControllerTest extends TestCase
private EnrollmentRepository&Mockery\MockInterface $enrollments; private EnrollmentRepository&Mockery\MockInterface $enrollments;
private OfferingRepository&Mockery\MockInterface $offerings; private OfferingRepository&Mockery\MockInterface $offerings;
private PaymentRepository&Mockery\MockInterface $payments; private PaymentRepository&Mockery\MockInterface $payments;
private GroupAccessRepository&Mockery\MockInterface $access;
private PaymentService&Mockery\MockInterface $paymentService;
private InviteRepository&Mockery\MockInterface $invites;
private RegistrationMailer&Mockery\MockInterface $mailer;
private GroupClassController $controller; private GroupClassController $controller;
protected function setUp(): void protected function setUp(): void
@@ -28,10 +36,25 @@ class GroupClassControllerTest extends TestCase
$this->enrollments = Mockery::mock(EnrollmentRepository::class); $this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class); $this->offerings = Mockery::mock(OfferingRepository::class);
$this->payments = Mockery::mock(PaymentRepository::class); $this->payments = Mockery::mock(PaymentRepository::class);
$this->controller = new GroupClassController($this->enrollments, $this->offerings, $this->payments); $this->access = Mockery::mock(GroupAccessRepository::class);
$this->paymentService = Mockery::mock(PaymentService::class);
$this->invites = Mockery::mock(InviteRepository::class);
$this->mailer = Mockery::mock(RegistrationMailer::class);
$this->controller = new GroupClassController(
$this->enrollments,
$this->offerings,
$this->payments,
$this->access,
$this->paymentService,
$this->invites,
$this->mailer,
);
Functions\when('current_user_can')->justReturn(true); Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3); Functions\when('get_current_user_id')->justReturn(3);
Functions\when('get_users')->justReturn([]);
Functions\when('esc_attr')->returnArg();
Functions\when('esc_attr_e')->returnArg();
} }
private function offering(int $id, string $title, ?int $capacity): Offering private function offering(int $id, string $title, ?int $capacity): Offering
@@ -157,4 +180,170 @@ class GroupClassControllerTest extends TestCase
$this->controller->renderInstructorPage(); $this->controller->renderInstructorPage();
} }
protected function tearDown(): void
{
$_POST = [];
parent::tearDown();
}
private function inviteOnlyOffering(float $price = 0.0): Offering
{
return new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Private Choir',
price: $price,
accessMode: Offering::ACCESS_INVITE_ONLY,
id: 8,
);
}
/** Stub the form-processing helpers and the render tail shared by all action tests. */
private function stubActionContext(): void
{
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
Functions\when('wp_unslash')->returnArg();
Functions\when('sanitize_email')->returnArg();
Functions\when('absint')->alias(static fn ($v) => abs((int) $v));
// Render tail: no classes/enrolments to draw so the assertion targets the notice.
$this->offerings->shouldReceive('findAll')->with(3, Offering::KIND_GROUP_CLASS)->andReturn([]);
$this->enrollments->shouldReceive('findByInstructor')->with(3)->andReturn([]);
}
public function testAddDirectEnrolsStudentWithPendingPayment(): void
{
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->enrollments->shouldReceive('insert')->once()->andReturn(44);
$payment = new Payment(
studentId: 5,
instructorId: 3,
registrationType: Payment::REG_ENROLLMENT,
registrationId: 44,
amount: 100.0,
method: Payment::METHOD_ETRANSFER,
status: Payment::STATUS_PENDING,
id: 12,
);
$this->paymentService->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 100.0, 'CAD', null)
->andReturn($payment);
$this->enrollments->shouldReceive('setPaymentId')->once()->with(44, 12)->andReturn(true);
$this->access->shouldReceive('markEnrolled')->once()->with(8, 5);
$html = $this->renderInstructor();
self::assertStringContainsString('1 student(s) added to the class.', $html);
}
public function testGrantAccessCreatesGrantAndEmailsStudent(): void
{
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(false);
$this->access->shouldReceive('insert')->once()->andReturn(1);
$user = Mockery::mock(\WP_User::class);
$user->user_email = '[email protected]';
Functions\when('get_userdata')->justReturn($user);
$this->mailer->shouldReceive('sendClassAccessGranted')->once()->with($user, 'Private Choir')->andReturn(true);
$html = $this->renderInstructor();
self::assertStringContainsString('1 student(s) granted access.', $html);
}
public function testInviteEmailForNewAddressCreatesInviteAndSendsLink(): void
{
$_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => '[email protected]'];
$this->stubActionContext();
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
$this->invites->shouldReceive('findPendingByEmail')->with('[email protected]')->andReturn(null);
Functions\when('wp_generate_password')->justReturn('rawtoken');
Functions\when('get_option')->justReturn(0);
Functions\when('home_url')->justReturn('http://home.test/');
Functions\when('add_query_arg')->justReturn('http://home.test/?us_invite=rawtoken');
$this->invites->shouldReceive('insert')->once()->andReturn(7);
$this->access->shouldReceive('insert')->once()->andReturn(2);
$this->mailer->shouldReceive('sendClassInvite')->once()->with('[email protected]', 'http://home.test/?us_invite=rawtoken', 'Private Choir')->andReturn(true);
$html = $this->renderInstructor();
self::assertStringContainsString('Invitation sent.', $html);
}
public function testInviteEmailReusesPendingInviteWithoutSendingLink(): void
{
$_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => '[email protected]'];
$this->stubActionContext();
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
$this->invites->shouldReceive('findPendingByEmail')->with('[email protected]')->andReturn(
new \Unsupervised\Schedular\Auth\Invite(email: '[email protected]', token: 'hash', id: 9)
);
// No new invite row and no email — just a grant attached to the existing invite.
$this->invites->shouldReceive('insert')->never();
$this->mailer->shouldReceive('sendClassInvite')->never();
$this->access->shouldReceive('insert')->once()->andReturn(2);
$html = $this->renderInstructor();
self::assertStringContainsString('No new link was sent.', $html);
}
public function testInviteEmailForExistingAccountGrantsAccess(): void
{
$_POST = ['usc_action' => 'invite_email', 'offering_id' => 8, 'email' => '[email protected]'];
$this->stubActionContext();
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(55);
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 55)->andReturn(false);
$this->access->shouldReceive('hasGrant')->with(8, 55)->andReturn(false);
$this->access->shouldReceive('insert')->once()->andReturn(3);
$user = Mockery::mock(\WP_User::class);
$user->user_email = '[email protected]';
Functions\when('get_userdata')->justReturn($user);
$this->mailer->shouldReceive('sendClassAccessGranted')->once()->andReturn(true);
$html = $this->renderInstructor();
self::assertStringContainsString('1 student(s) granted access.', $html);
}
public function testActionRejectedForClassNotOwnedByInstructor(): void
{
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
// Offering owned by a different instructor (7, not the current user 3).
$foreign = new Offering(instructorId: 7, kind: Offering::KIND_GROUP_CLASS, title: 'Other', accessMode: Offering::ACCESS_INVITE_ONLY, id: 8);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($foreign);
$this->enrollments->shouldReceive('insert')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('That group class was not found.', $html);
}
} }
@@ -70,6 +70,39 @@ class OfferingControllerTest extends TestCase
$this->render(); $this->render();
} }
public function testAddInviteOnlyGroupClassStoresInviteOnlyAccess(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Private Choir',
'kind' => Offering::KIND_GROUP_CLASS,
'invite_only' => '1',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => Offering::ACCESS_INVITE_ONLY === $o->accessMode
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
}
public function testAddWithoutInviteOnlyDefaultsToPublicAccess(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Open Choir',
'kind' => Offering::KIND_GROUP_CLASS,
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => Offering::ACCESS_PUBLIC === $o->accessMode
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->render();
}
public function testAddOneOffGroupClassEndsOnItsStartDate(): void public function testAddOneOffGroupClassEndsOnItsStartDate(): void
{ {
$_POST = [ $_POST = [
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Offering;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class OfferingEndpointTest extends TestCase
{
private OfferingRepository&Mockery\MockInterface $repository;
private GroupAccessRepository&Mockery\MockInterface $access;
private OfferingEndpoint $endpoint;
protected function setUp(): void
{
parent::setUp();
Functions\when('get_current_user_id')->justReturn(5);
$this->repository = Mockery::mock(OfferingRepository::class);
$this->access = Mockery::mock(GroupAccessRepository::class);
$this->endpoint = new OfferingEndpoint($this->repository, $this->access);
}
private function group(int $id, string $access): Offering
{
return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: "Class $id", accessMode: $access, id: $id);
}
public function testIndexReturnsPublicOfferingsOnlyWhenNoGrants(): void
{
$this->repository->shouldReceive('findAll')
->once()
->with(0, '', Mockery::on(static fn ($v): bool => true === $v), Offering::ACCESS_PUBLIC)
->andReturn([$this->group(1, Offering::ACCESS_PUBLIC)]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([]);
$data = $this->endpoint->index(new \WP_REST_Request())->get_data();
self::assertCount(1, $data);
self::assertSame(1, $data[0]['id']);
}
public function testIndexMergesGrantedInviteOnlyOfferings(): void
{
$this->repository->shouldReceive('findAll')
->once()
->with(0, '', Mockery::any(), Offering::ACCESS_PUBLIC)
->andReturn([$this->group(1, Offering::ACCESS_PUBLIC)]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([8]);
$this->repository->shouldReceive('findById')->with(8)->andReturn($this->group(8, Offering::ACCESS_INVITE_ONLY));
$data = $this->endpoint->index(new \WP_REST_Request())->get_data();
self::assertSame([1, 8], array_column($data, 'id'));
}
public function testIndexOmitsGrantedOfferingThatIsNoLongerInviteOnly(): void
{
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([8]);
// Grant persists but the class was flipped back to public — it is already
// in the public list, so it must not be appended a second time.
$this->repository->shouldReceive('findById')->with(8)->andReturn($this->group(8, Offering::ACCESS_PUBLIC));
$data = $this->endpoint->index(new \WP_REST_Request())->get_data();
self::assertSame([], $data);
}
public function testIndexRespectsKindFilterForGrantedOfferings(): void
{
$this->repository->shouldReceive('findAll')
->with(0, Offering::KIND_PRIVATE_LESSON, Mockery::any(), Offering::ACCESS_PUBLIC)
->andReturn([]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([8]);
// Granted class is a group class; the request filters to private lessons.
$this->repository->shouldReceive('findById')->with(8)->andReturn($this->group(8, Offering::ACCESS_INVITE_ONLY));
$data = $this->endpoint->index(new \WP_REST_Request(['kind' => Offering::KIND_PRIVATE_LESSON]))->get_data();
self::assertSame([], $data);
}
public function testIndexOmitsEtransferEmailFromPublicListing(): void
{
$this->repository->shouldReceive('findAll')->andReturn([
new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', etransferEmail: '[email protected]', id: 1),
]);
$this->access->shouldReceive('findGrantedOfferingIds')->with(5)->andReturn([]);
$data = $this->endpoint->index(new \WP_REST_Request())->get_data();
self::assertArrayNotHasKey('etransfer_email', $data[0]);
}
}
@@ -154,6 +154,44 @@ class OfferingRepositoryTest extends TestCase
$this->repo->findAll(3, Offering::KIND_GROUP_CLASS); $this->repo->findAll(3, Offering::KIND_GROUP_CLASS);
} }
public function testFindAllFiltersByAccessMode(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/access_mode = %s/'),
Mockery::on(static fn (array $p): bool => $p === ['wp_us_offerings', Offering::ACCESS_PUBLIC])
)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([]);
self::assertSame([], $this->repo->findAll(accessMode: Offering::ACCESS_PUBLIC));
}
public function testInsertPersistsAccessMode(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_offerings',
Mockery::on(static fn (array $data): bool => $data['access_mode'] === Offering::ACCESS_INVITE_ONLY),
Mockery::type('array')
);
$this->db->insert_id = 1;
$offering = new Offering(
instructorId: 5,
kind: Offering::KIND_GROUP_CLASS,
title: 'Private Choir',
accessMode: Offering::ACCESS_INVITE_ONLY,
);
self::assertSame(1, $this->repo->insert($offering));
}
public function testDeleteCallsWpdbDelete(): void public function testDeleteCallsWpdbDelete(): void
{ {
$this->db->shouldReceive('delete') $this->db->shouldReceive('delete')
+43 -1
View File
@@ -132,11 +132,53 @@ class OfferingTest extends TestCase
$offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', id: 10); $offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', id: 10);
$arr = $offering->toArray(); $arr = $offering->toArray();
foreach (['id', 'instructor_id', 'kind', 'title', 'price', 'billing_mode', 'is_active'] as $key) { foreach (['id', 'instructor_id', 'kind', 'title', 'price', 'billing_mode', 'access_mode', 'is_active'] as $key) {
self::assertArrayHasKey($key, $arr); self::assertArrayHasKey($key, $arr);
} }
} }
public function testDefaultsToPublicAccess(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', id: 10);
self::assertSame(Offering::ACCESS_PUBLIC, $offering->accessMode);
self::assertFalse($offering->isInviteOnly());
}
public function testInviteOnlyAccessIsReported(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', accessMode: Offering::ACCESS_INVITE_ONLY, id: 10);
self::assertTrue($offering->isInviteOnly());
self::assertSame(Offering::ACCESS_INVITE_ONLY, $offering->toArray()['access_mode']);
}
public function testFromRowReadsInviteOnlyAccessMode(): void
{
$row = (object) [
'id' => '7',
'instructor_id' => '3',
'kind' => Offering::KIND_GROUP_CLASS,
'title' => 'Private Choir',
'description' => null,
'duration_minutes' => null,
'price' => '0.00',
'currency' => 'CAD',
'billing_mode' => Offering::BILLING_FULL_TERM,
'allow_weekly' => '0',
'capacity' => null,
'term_start' => null,
'term_end' => null,
'schedule_note' => null,
'etransfer_email' => null,
'cancellation_cutoff_hours' => null,
'access_mode' => Offering::ACCESS_INVITE_ONLY,
'is_active' => '1',
];
self::assertTrue(Offering::fromRow($row)->isInviteOnly());
}
public function testToArrayIncludesEtransferEmailByDefault(): void public function testToArrayIncludesEtransferEmailByDefault(): void
{ {
$offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', etransferEmail: '[email protected]', id: 10); $offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', etransferEmail: '[email protected]', id: 10);