CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m52s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
CI / Build Plugin Zip (pull_request) Skipped
Group classes now carry a specific class time (alongside date and duration) and an assigned instructor: - Schema: add `class_time` (TIME) to `us_offerings`; `Offering` gains `normalizeTime`/`sessionWindows`. (Rides the pending 1.0.0->1.1.0 dbDelta upgrade, so no version bump.) - Offering form: class-time field, plus a studio-admin instructor picker (plain instructors always own their own classes). - `ClassSlotReconciler`: assigning an instructor clears their open booking slots overlapping each session and flags already-booked lessons that clash (a booked lesson is never deleted). Uses new `AvailabilityRepository::findOverlapping`. - Front end: `GET /offerings` exposes `instructor_name`; the enrolment page shows who teaches each class and when it meets. Back-office group-class views redesigned: - Instructor **My Group Classes** and studio-admin **Group Classes** are now per-class summaries with enrolment counts, not flat student lists. - Each links through (`?class_id=<id>`) to a per-class **details page** (schedule panel, roster with payment status, and — for invite-only classes — the add/make-available/invite-by-email controls). Invite-only membership is managed entirely from this page. - Invite actions are allowed for the class's owning instructor or any `view_all_lessons` studio admin, so an owner-operator (studio admin who also teaches) can reach every class's roster and invites from the Group Classes page. Tests: composer test (508), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
304 lines
8.1 KiB
PHP
304 lines
8.1 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace Unsupervised\Schedular\Availability;
|
||
|
||
class AvailabilityRepository {
|
||
|
||
private string $table;
|
||
|
||
public function __construct( private \wpdb $db ) {
|
||
$this->table = $db->prefix . 'us_availability';
|
||
}
|
||
|
||
public function insert( AvailabilitySlot $slot ): int {
|
||
$this->db->insert(
|
||
$this->table,
|
||
[
|
||
'instructor_id' => $slot->instructorId,
|
||
'offering_id' => $slot->offeringId,
|
||
'start_dt' => $slot->startDt,
|
||
'end_dt' => $slot->endDt,
|
||
'duration_minutes' => $slot->durationMinutes,
|
||
'is_booked' => 0,
|
||
'recurrence_group' => $slot->recurrenceGroup,
|
||
'created_at' => current_time( 'mysql' ),
|
||
],
|
||
[ '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
||
);
|
||
|
||
return $this->db->insert_id;
|
||
}
|
||
|
||
/**
|
||
* Persist an availability window as individually bookable lesson-length slots.
|
||
* The window is split into consecutive `duration_minutes` chunks; each chunk
|
||
* becomes its own row (and, when weekly, its own weekly series) so students can
|
||
* book any open lesson-length block within the window.
|
||
*
|
||
* @return list<int> Inserted slot IDs.
|
||
*/
|
||
public function createFromWindow( AvailabilitySlot $window, bool $weekly = false, int $weeks = 1 ): array {
|
||
$ids = [];
|
||
|
||
foreach ( $window->splitByDuration() as $slot ) {
|
||
$ids = $weekly
|
||
? array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) )
|
||
: [ ...$ids, $this->insert( $slot ) ];
|
||
}
|
||
|
||
return $ids;
|
||
}
|
||
|
||
/**
|
||
* Create a weekly-recurring series from a template slot. Each occurrence is a
|
||
* separate row one week apart, all sharing a `recurrence_group` (the id of the
|
||
* first row).
|
||
*
|
||
* @return list<int> Inserted slot IDs.
|
||
*/
|
||
public function createWeeklySeries( AvailabilitySlot $first, int $occurrences ): array {
|
||
$occurrences = max( 1, $occurrences );
|
||
$start = new \DateTimeImmutable( $first->startDt );
|
||
$end = new \DateTimeImmutable( $first->endDt );
|
||
|
||
$ids = [];
|
||
$groupId = 0;
|
||
|
||
for ( $week = 0; $week < $occurrences; $week++ ) {
|
||
$shift = '+' . ( 7 * $week ) . ' days';
|
||
|
||
$id = $this->insert(
|
||
new AvailabilitySlot(
|
||
instructorId: $first->instructorId,
|
||
startDt: $start->modify( $shift )->format( 'Y-m-d H:i:s' ),
|
||
endDt: $end->modify( $shift )->format( 'Y-m-d H:i:s' ),
|
||
durationMinutes: $first->durationMinutes,
|
||
offeringId: $first->offeringId,
|
||
recurrenceGroup: $groupId > 0 ? $groupId : null,
|
||
)
|
||
);
|
||
|
||
if ( 0 === $groupId ) {
|
||
$groupId = $id;
|
||
$this->setRecurrenceGroup( $id, $groupId );
|
||
}
|
||
|
||
$ids[] = $id;
|
||
}
|
||
|
||
return $ids;
|
||
}
|
||
|
||
private function setRecurrenceGroup( int $id, int $groupId ): void {
|
||
$this->db->update(
|
||
$this->table,
|
||
[ 'recurrence_group' => $groupId ],
|
||
[ 'id' => $id ],
|
||
[ '%d' ],
|
||
[ '%d' ]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Find unbooked slots, optionally filtered by instructor, offering, lesson
|
||
* length, and date range.
|
||
*
|
||
* @return list<AvailabilitySlot>
|
||
*/
|
||
public function findAvailable( int $instructorId = 0, int $offeringId = 0, int $durationMinutes = 0, string $from = '', string $to = '' ): array {
|
||
// A slot whose start has passed can no longer be booked, so it is never
|
||
// "available" regardless of the requested range.
|
||
$where = [ 'is_booked = 0', 'start_dt >= %s' ];
|
||
$params = [ current_time( 'mysql' ) ];
|
||
|
||
if ( $instructorId > 0 ) {
|
||
$where[] = 'instructor_id = %d';
|
||
$params[] = $instructorId;
|
||
}
|
||
|
||
if ( $offeringId > 0 ) {
|
||
$where[] = 'offering_id = %d';
|
||
$params[] = $offeringId;
|
||
}
|
||
|
||
if ( $durationMinutes > 0 ) {
|
||
$where[] = 'duration_minutes = %d';
|
||
$params[] = $durationMinutes;
|
||
}
|
||
|
||
if ( '' !== $from ) {
|
||
$where[] = 'start_dt >= %s';
|
||
$params[] = $from;
|
||
}
|
||
|
||
if ( '' !== $to ) {
|
||
$where[] = 'end_dt <= %s';
|
||
$params[] = $to;
|
||
}
|
||
|
||
$whereClause = implode( ' AND ', $where );
|
||
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY start_dt ASC";
|
||
|
||
$rows = $this->db->get_results(
|
||
$this->db->prepare( $sql, array_merge( [ $this->table ], $params ) )
|
||
);
|
||
|
||
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
|
||
}
|
||
|
||
/**
|
||
* Find all slots for an instructor (booked and unbooked).
|
||
*
|
||
* @return list<AvailabilitySlot>
|
||
*/
|
||
public function findByInstructor( int $instructorId ): array {
|
||
$rows = $this->db->get_results(
|
||
$this->db->prepare(
|
||
'SELECT * FROM %i WHERE instructor_id = %d ORDER BY start_dt ASC',
|
||
$this->table,
|
||
$instructorId
|
||
)
|
||
);
|
||
|
||
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
|
||
}
|
||
|
||
/**
|
||
* Unbooked slots that belong to a weekly-recurring group, ordered by start.
|
||
*
|
||
* @return list<AvailabilitySlot>
|
||
*/
|
||
public function findUnbookedInGroup( int $recurrenceGroup ): array {
|
||
$rows = $this->db->get_results(
|
||
$this->db->prepare(
|
||
'SELECT * FROM %i WHERE recurrence_group = %d AND is_booked = 0 ORDER BY start_dt ASC',
|
||
$this->table,
|
||
$recurrenceGroup
|
||
)
|
||
);
|
||
|
||
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
|
||
}
|
||
|
||
/**
|
||
* An instructor's slots (booked and unbooked) that overlap a time window —
|
||
* they share any time with the half-open interval [$start, $end). Used when a
|
||
* group class is scheduled to find the private-booking slots that collide with
|
||
* it, so open ones can be cleared and booked ones flagged as conflicts.
|
||
*
|
||
* @return list<AvailabilitySlot>
|
||
*/
|
||
public function findOverlapping( int $instructorId, string $start, string $end ): array {
|
||
$rows = $this->db->get_results(
|
||
$this->db->prepare(
|
||
'SELECT * FROM %i WHERE instructor_id = %d AND start_dt < %s AND end_dt > %s ORDER BY start_dt ASC',
|
||
$this->table,
|
||
$instructorId,
|
||
$end,
|
||
$start
|
||
)
|
||
);
|
||
|
||
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
|
||
}
|
||
|
||
public function findById( int $id ): ?AvailabilitySlot {
|
||
$row = $this->db->get_row(
|
||
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
|
||
);
|
||
|
||
return $row ? AvailabilitySlot::fromRow( $row ) : null;
|
||
}
|
||
|
||
/**
|
||
* Atomically claim an unbooked slot. The `is_booked = 0` guard in the WHERE
|
||
* clause makes this the single point of truth for reserving a slot: only one
|
||
* concurrent request can transition it from unbooked to booked, so two students
|
||
* cannot book (and be charged for) the same slot. Returns true only when this
|
||
* call is the one that claimed it.
|
||
*/
|
||
public function claim( int $id ): bool {
|
||
$updated = $this->db->update(
|
||
$this->table,
|
||
[ 'is_booked' => 1 ],
|
||
[
|
||
'id' => $id,
|
||
'is_booked' => 0,
|
||
],
|
||
[ '%d' ],
|
||
[ '%d', '%d' ]
|
||
);
|
||
|
||
return 1 === $updated;
|
||
}
|
||
|
||
/**
|
||
* Free a slot whose lesson was cancelled so the time can be booked again.
|
||
*/
|
||
public function release( int $id ): bool {
|
||
return false !== $this->db->update(
|
||
$this->table,
|
||
[ 'is_booked' => 0 ],
|
||
[ 'id' => $id ],
|
||
[ '%d' ],
|
||
[ '%d' ]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* One-time upgrade for rows created before windows were split on save: a
|
||
* window stored as a single row (e.g. 09:00–16:00 with 60-minute lessons)
|
||
* showed to students as one giant slot. Rewrites every unbooked same-day
|
||
* window longer than its lesson length as lesson-length rows: the original
|
||
* row is trimmed to the first chunk (keeping its id and any recurrence
|
||
* group), and the remaining chunks are inserted as one-off rows.
|
||
*/
|
||
public function splitOversizedWindows(): void {
|
||
$rows = $this->db->get_results(
|
||
$this->db->prepare(
|
||
'SELECT * FROM %i
|
||
WHERE is_booked = 0
|
||
AND DATE(start_dt) = DATE(end_dt)
|
||
AND TIMESTAMPDIFF(MINUTE, start_dt, end_dt) > duration_minutes',
|
||
$this->table
|
||
)
|
||
);
|
||
|
||
foreach ( $rows ?? [] as $row ) {
|
||
$window = AvailabilitySlot::fromRow( $row );
|
||
$chunks = $window->splitByDuration();
|
||
|
||
if ( [] === $chunks ) {
|
||
continue;
|
||
}
|
||
|
||
$this->db->update(
|
||
$this->table,
|
||
[ 'end_dt' => $chunks[0]->endDt ],
|
||
[ 'id' => $window->id ],
|
||
[ '%s' ],
|
||
[ '%d' ]
|
||
);
|
||
|
||
foreach ( array_slice( $chunks, 1 ) as $chunk ) {
|
||
$this->insert( $chunk );
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Delete an unbooked slot. Returns false if the slot is already booked.
|
||
*/
|
||
public function delete( int $id ): bool {
|
||
return (bool) $this->db->delete(
|
||
$this->table,
|
||
[
|
||
'id' => $id,
|
||
'is_booked' => 0,
|
||
],
|
||
[ '%d', '%d' ]
|
||
);
|
||
}
|
||
}
|