Add invite-only group classes #96
@@ -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`).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' );
|
||||||
|
|
||||||
|
|||||||
@@ -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 );
|
||||||
|
|
||||||
|
|||||||
@@ -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 );
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' ]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,7 +61,13 @@ class GroupClassController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$instructorId = get_current_user_id();
|
$instructorId = get_current_user_id();
|
||||||
$enrollments = $this->enrollments->findByInstructor( $instructorId );
|
|
||||||
|
$notice = '';
|
||||||
|
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_group_action' ) ) {
|
||||||
|
$notice = $this->handleFormAction( $instructorId );
|
||||||
|
}
|
||||||
|
|
||||||
|
$enrollments = $this->enrollments->findByInstructor( $instructorId );
|
||||||
|
|
||||||
$classes = array_map(
|
$classes = array_map(
|
||||||
function ( Offering $offering ) use ( $enrollments ): array {
|
function ( Offering $offering ) use ( $enrollments ): array {
|
||||||
@@ -76,15 +94,280 @@ class GroupClassController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'title' => $offering->title,
|
'id' => $offering->id,
|
||||||
'capacity' => $offering->capacity,
|
'title' => $offering->title,
|
||||||
'enrolled' => $enrolled,
|
'capacity' => $offering->capacity,
|
||||||
'roster' => $roster,
|
'enrolled' => $enrolled,
|
||||||
|
'invite_only' => $offering->isInviteOnly(),
|
||||||
|
'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( '/' ) );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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};",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') : '—'; ?></td>
|
<td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '—'; ?></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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,19 +23,38 @@ 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
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
$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')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user