CI / No Debug Code (push) Successful in 3s
CI / Coding Standards (push) Successful in 46s
CI / Tests (PHP 8.1) (push) Successful in 45s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / PHPStan (push) Successful in 1m11s
CI / Tests (PHP 8.3) (push) Successful in 1m0s
CI / Build Plugin Zip (push) Successful in 1m10s
Adds POST /bookings/{id}/cancel (owner-only, idempotent): marks the lesson
cancelled, releases the availability slot for rebooking, and voids a
still-pending payment so it leaves the admin confirmation queue. Paid
payments are untouched — refunds stay a manual admin decision.
The instructor PATCH /bookings/{id}/status path now does the same slot
release and payment voiding on cancellation (previously cancelled lessons
left their slot permanently booked), and reinstating a cancelled lesson
re-claims the slot, rejecting with 409 if the freed time was rebooked.
The "Your upcoming lessons" panel gets a Cancel button with a confirm
prompt; on success both the lesson list and the slot calendar refresh.
Co-Authored-By: Claude Fable 5 <[email protected]>
282 lines
7.4 KiB
PHP
282 lines
7.4 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 ?? [] );
|
||
}
|
||
|
||
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' ]
|
||
);
|
||
}
|
||
}
|