Add invite-only group classes
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Group classes can now be marked invite-only (us_offerings.access_mode). Invite-only classes are hidden from the public catalog and reachable only when the instructor lets someone in via one of three paths, managed from My Lessons -> My Group Classes: - Add students directly: enrols them now with a pending payment. - Make available: grants registered students access to self-enrol through the normal paid flow (multi-select, emailed a notice). - Invite by email: tokenised registration invite tied to the class for a non-account address; after they register the class becomes enrollable. Reuses an existing pending invite instead of sending a second link. New us_group_access table records grants; GET /offerings merges granted invite-only classes for the caller; enrolment requires a grant (403 invite_required) and flips it to enrolled on success. composer test (487), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
+4
-3
@@ -18,6 +18,7 @@ use Unsupervised\Schedular\Auth\StudentHistory;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassController;
|
||||
use Unsupervised\Schedular\Offering\OfferingController;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
@@ -53,15 +54,15 @@ class AdminMenu {
|
||||
private PaymentController $paymentController;
|
||||
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->lessonController = new LessonController( $bookings, $payments, $availability );
|
||||
$this->offeringController = new OfferingController( $offerings );
|
||||
$this->questionController = new QuestionController( $questions, $offerings );
|
||||
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
|
||||
$this->registrationController = new RegistrationController( $invites );
|
||||
$this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() );
|
||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments );
|
||||
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
|
||||
$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->instructorController = new InstructorController();
|
||||
$this->settings = $settings;
|
||||
|
||||
@@ -51,6 +51,7 @@ class Invite {
|
||||
public readonly ?string $createdAt = null,
|
||||
public readonly string $kind = self::KIND_PERSONAL,
|
||||
public readonly ?string $expiresAt = null,
|
||||
public readonly ?int $offeringId = null,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
@@ -66,6 +67,7 @@ class Invite {
|
||||
createdAt: Val::stringOrNull( $row->created_at ?? null ),
|
||||
kind: '' !== Val::string( $row->kind ?? '' ) ? Val::string( $row->kind ) : self::KIND_PERSONAL,
|
||||
expiresAt: Val::stringOrNull( $row->expires_at ?? null ),
|
||||
offeringId: Val::intOrNull( $row->offering_id ?? null ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
@@ -132,6 +134,7 @@ class Invite {
|
||||
'accepted_user_id' => $this->acceptedUserId,
|
||||
'accepted_at' => $this->acceptedAt,
|
||||
'expires_at' => $this->expiresAt,
|
||||
'offering_id' => $this->offeringId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ class InviteRepository {
|
||||
'token' => $invite->token,
|
||||
'role' => $invite->role,
|
||||
'kind' => $invite->kind,
|
||||
'offering_id' => $invite->offeringId,
|
||||
'status' => $invite->status,
|
||||
'invited_by' => $invite->invitedBy,
|
||||
'accepted_user_id' => $invite->acceptedUserId,
|
||||
@@ -30,7 +31,7 @@ class InviteRepository {
|
||||
'accepted_at' => $invite->acceptedAt,
|
||||
'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;
|
||||
|
||||
@@ -105,6 +105,56 @@ class RegistrationMailer {
|
||||
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 {
|
||||
$name = (string) get_bloginfo( 'name' );
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
@@ -38,6 +39,7 @@ class RegistrationPage {
|
||||
private RegistrationMailer $mailer,
|
||||
private QuestionRepository $questions,
|
||||
private AnswerRepository $answers,
|
||||
private GroupAccessRepository $access,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -203,6 +205,11 @@ class RegistrationPage {
|
||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||
$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_auth_cookie( (int) $userId );
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class EnrollmentEndpoint {
|
||||
private OfferingRepository $offerings,
|
||||
private RegistrationGate $gate,
|
||||
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 ] );
|
||||
}
|
||||
|
||||
// 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 ) {
|
||||
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() );
|
||||
|
||||
// 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;
|
||||
if ( $offering->price > 0.0 ) {
|
||||
$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;
|
||||
|
||||
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\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class GroupClassController {
|
||||
|
||||
@@ -14,6 +21,10 @@ class GroupClassController {
|
||||
private EnrollmentRepository $enrollments,
|
||||
private OfferingRepository $offerings,
|
||||
private PaymentRepository $payments,
|
||||
private GroupAccessRepository $access,
|
||||
private PaymentService $paymentService,
|
||||
private InviteRepository $invites,
|
||||
private RegistrationMailer $mailer,
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
@@ -41,7 +52,8 @@ class GroupClassController {
|
||||
/**
|
||||
* Instructor view: their own group classes with per-class rosters. Each class
|
||||
* 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 {
|
||||
if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) {
|
||||
@@ -49,7 +61,13 @@ class GroupClassController {
|
||||
}
|
||||
|
||||
$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(
|
||||
function ( Offering $offering ) use ( $enrollments ): array {
|
||||
@@ -76,15 +94,280 @@ class GroupClassController {
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $offering->title,
|
||||
'capacity' => $offering->capacity,
|
||||
'enrolled' => $enrolled,
|
||||
'roster' => $roster,
|
||||
'id' => $offering->id,
|
||||
'title' => $offering->title,
|
||||
'capacity' => $offering->capacity,
|
||||
'enrolled' => $enrolled,
|
||||
'invite_only' => $offering->isInviteOnly(),
|
||||
'roster' => $roster,
|
||||
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
|
||||
];
|
||||
},
|
||||
$this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS )
|
||||
);
|
||||
|
||||
$students = $this->studentOptions();
|
||||
|
||||
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 ];
|
||||
|
||||
/** 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 readonly int $instructorId,
|
||||
public readonly string $kind,
|
||||
@@ -43,10 +56,19 @@ class Offering {
|
||||
public readonly ?string $scheduleNote = null,
|
||||
public readonly ?string $etransferEmail = null,
|
||||
public readonly ?int $cancellationCutoffHours = null,
|
||||
public readonly string $accessMode = self::ACCESS_PUBLIC,
|
||||
public readonly bool $isActive = true,
|
||||
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
|
||||
* not a real calendar date. Round-trips through DateTimeImmutable so
|
||||
@@ -85,6 +107,7 @@ class Offering {
|
||||
scheduleNote: Val::stringOrNull( $row->schedule_note ),
|
||||
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
|
||||
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 ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
@@ -116,6 +139,7 @@ class Offering {
|
||||
'term_end' => $this->termEnd,
|
||||
'schedule_note' => $this->scheduleNote,
|
||||
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
|
||||
'access_mode' => $this->accessMode,
|
||||
'is_active' => $this->isActive,
|
||||
];
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ class OfferingController {
|
||||
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'] ?? '' ) ) ) ),
|
||||
cancellationCutoffHours: $cutoffHours,
|
||||
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
|
||||
isActive: isset( $_POST['is_active'] ),
|
||||
id: $existing?->id,
|
||||
);
|
||||
|
||||
@@ -4,11 +4,15 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Offering;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class OfferingEndpoint {
|
||||
|
||||
public function __construct( private OfferingRepository $repository ) {}
|
||||
public function __construct(
|
||||
private OfferingRepository $repository,
|
||||
private GroupAccessRepository $access,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registers this endpoint's REST routes.
|
||||
@@ -62,16 +66,53 @@ class OfferingEndpoint {
|
||||
}
|
||||
|
||||
public function index( \WP_REST_Request $request ): \WP_REST_Response {
|
||||
$offerings = $this->repository->findAll(
|
||||
Val::int( $request->get_param( 'instructor_id' ) ),
|
||||
Val::string( $request->get_param( 'kind' ) ),
|
||||
activeOnly: true,
|
||||
);
|
||||
$instructorId = Val::int( $request->get_param( 'instructor_id' ) );
|
||||
$kind = Val::string( $request->get_param( 'kind' ) );
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
$title = sanitize_text_field( Val::string( $request->get_param( 'title' ) ) );
|
||||
if ( '' === $title ) {
|
||||
@@ -104,6 +145,7 @@ class OfferingEndpoint {
|
||||
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
|
||||
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
|
||||
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' ),
|
||||
);
|
||||
|
||||
@@ -150,6 +192,7 @@ class OfferingEndpoint {
|
||||
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,
|
||||
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,
|
||||
id: $id,
|
||||
);
|
||||
@@ -212,6 +255,12 @@ class OfferingEndpoint {
|
||||
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 {
|
||||
return ( null === $value || '' === $value ) ? null : Val::int( $value );
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ class OfferingRepository {
|
||||
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
|
||||
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
|
||||
* capacity, term_start, term_end, schedule_note, etransfer_email,
|
||||
* cancellation_cutoff_hours, is_active).
|
||||
* cancellation_cutoff_hours, access_mode, is_active).
|
||||
*
|
||||
* @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 {
|
||||
$this->db->insert(
|
||||
@@ -63,16 +63,19 @@ class OfferingRepository {
|
||||
'schedule_note' => $offering->scheduleNote,
|
||||
'etransfer_email' => $offering->etransferEmail,
|
||||
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
|
||||
'access_mode' => $offering->accessMode,
|
||||
'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>
|
||||
*/
|
||||
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' ];
|
||||
$params = [];
|
||||
|
||||
@@ -91,6 +94,11 @@ class OfferingRepository {
|
||||
$params[] = $activeOnly ? 1 : 0;
|
||||
}
|
||||
|
||||
if ( null !== $accessMode ) {
|
||||
$where[] = 'access_mode = %s';
|
||||
$params[] = $accessMode;
|
||||
}
|
||||
|
||||
$whereClause = implode( ' AND ', $where );
|
||||
$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\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||
@@ -58,6 +59,7 @@ class Plugin {
|
||||
$acceptances = new AcceptanceRepository( $wpdb );
|
||||
$invites = new InviteRepository( $wpdb );
|
||||
$enrollments = new EnrollmentRepository( $wpdb );
|
||||
$groupAccess = new GroupAccessRepository( $wpdb );
|
||||
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
|
||||
|
||||
$paymentRepo = new PaymentRepository( $wpdb );
|
||||
@@ -72,15 +74,15 @@ class Plugin {
|
||||
|
||||
$bookingPage = new BookingPage();
|
||||
$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();
|
||||
|
||||
( new UpdateChecker() )->register();
|
||||
( new RoleManager() )->register();
|
||||
( new RegistrationLoginGate() )->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 RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->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, $groupAccess, $paymentService ) )->register();
|
||||
( new ShortcodeRegistrar( $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\CancellationPolicy;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
@@ -35,13 +36,13 @@ class RestRegistrar {
|
||||
private EnrollmentEndpoint $enrollmentEndpoint;
|
||||
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->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->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 );
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ class Schema {
|
||||
schedule_note VARCHAR(191) DEFAULT NULL,
|
||||
etransfer_email VARCHAR(191) 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,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
@@ -190,6 +191,7 @@ class Schema {
|
||||
token VARCHAR(64) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL DEFAULT 'us_student',
|
||||
kind VARCHAR(10) NOT NULL DEFAULT 'personal',
|
||||
offering_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
invited_by BIGINT UNSIGNED DEFAULT NULL,
|
||||
accepted_user_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
@@ -201,6 +203,22 @@ class Schema {
|
||||
KEY email (email),
|
||||
KEY status (status)
|
||||
) {$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};",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user