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]>
153 lines
5.4 KiB
PHP
153 lines
5.4 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Offering;
|
|
|
|
use Unsupervised\Schedular\Val;
|
|
|
|
class Offering {
|
|
|
|
public const KIND_PRIVATE_LESSON = 'private_lesson';
|
|
public const KIND_GROUP_CLASS = 'group_class';
|
|
|
|
/**
|
|
* All valid offering kinds.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
public const VALID_KINDS = [ self::KIND_PRIVATE_LESSON, self::KIND_GROUP_CLASS ];
|
|
|
|
public const BILLING_ONE_TIME = 'one_time';
|
|
public const BILLING_FULL_TERM = 'full_term';
|
|
|
|
/**
|
|
* All valid billing modes.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
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,
|
|
public readonly string $title,
|
|
public readonly float $price = 0.0,
|
|
public readonly string $currency = 'CAD',
|
|
public readonly string $billingMode = self::BILLING_ONE_TIME,
|
|
public readonly ?string $description = null,
|
|
public readonly ?int $durationMinutes = null,
|
|
public readonly bool $allowWeekly = false,
|
|
public readonly ?int $capacity = null,
|
|
public readonly ?string $termStart = null,
|
|
public readonly ?string $termEnd = null,
|
|
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
|
|
* strings PHP would silently coerce (e.g. `2026-02-30`) are rejected.
|
|
*/
|
|
public static function normalizeDate( string $value ): ?string {
|
|
$date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
|
|
|
|
return false !== $date && $date->format( 'Y-m-d' ) === $value ? $date->format( 'Y-m-d' ) : null;
|
|
}
|
|
|
|
/**
|
|
* Last class date of a weekly term: the start date plus `$occurrences - 1`
|
|
* weeks. A one-off class (one occurrence) ends the day it starts.
|
|
*/
|
|
public static function weeklyTermEnd( string $termStart, int $occurrences ): string {
|
|
$weeks = max( 1, $occurrences ) - 1;
|
|
|
|
return ( new \DateTimeImmutable( $termStart ) )->modify( '+' . ( 7 * $weeks ) . ' days' )->format( 'Y-m-d' );
|
|
}
|
|
|
|
public static function fromRow( \stdClass $row ): self {
|
|
return new self(
|
|
instructorId: Val::int( $row->instructor_id ),
|
|
kind: Val::string( $row->kind ),
|
|
title: Val::string( $row->title ),
|
|
price: Val::float( $row->price ),
|
|
currency: Val::string( $row->currency ),
|
|
billingMode: Val::string( $row->billing_mode ),
|
|
description: Val::stringOrNull( $row->description ),
|
|
durationMinutes: Val::intOrNull( $row->duration_minutes ),
|
|
allowWeekly: Val::bool( $row->allow_weekly ),
|
|
capacity: Val::intOrNull( $row->capacity ),
|
|
termStart: Val::stringOrNull( $row->term_start ),
|
|
termEnd: Val::stringOrNull( $row->term_end ),
|
|
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 ),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns a plain array representation of the offering.
|
|
*
|
|
* The e-transfer destination email is a private payment-routing detail, so it
|
|
* is only included when $includeEtransferEmail is true (e.g. manager-only
|
|
* responses). The public offerings listing must omit it.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function toArray( bool $includeEtransferEmail = true ): array {
|
|
$out = [
|
|
'id' => $this->id,
|
|
'instructor_id' => $this->instructorId,
|
|
'kind' => $this->kind,
|
|
'title' => $this->title,
|
|
'description' => $this->description,
|
|
'duration_minutes' => $this->durationMinutes,
|
|
'price' => $this->price,
|
|
'currency' => $this->currency,
|
|
'billing_mode' => $this->billingMode,
|
|
'allow_weekly' => $this->allowWeekly,
|
|
'capacity' => $this->capacity,
|
|
'term_start' => $this->termStart,
|
|
'term_end' => $this->termEnd,
|
|
'schedule_note' => $this->scheduleNote,
|
|
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
|
|
'access_mode' => $this->accessMode,
|
|
'is_active' => $this->isActive,
|
|
];
|
|
|
|
if ( $includeEtransferEmail ) {
|
|
$out['etransfer_email'] = $this->etransferEmail;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
}
|