Files
unsupervised-scheduler/src/Availability/AvailabilityRepository.php
T
thatguygriffandClaude Opus 4.8 b066bef353
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
Add group-class scheduling, instructor assignment, and details/invite management
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]>
2026-07-23 17:29:48 -03:00

304 lines
8.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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:0016: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' ]
);
}
}