Files
unsupervised-scheduler/src/Availability/AvailabilityRepository.php
T
thatguygriff 171b655bb8
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 3m3s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m50s
CI / Build Plugin Zip (pull_request) Skipped
Stop the availability form failing in silence
Adding availability for 5:30-6:00 PM with the lesson length left on its
60-minute default saved nothing and said nothing. A window is stored as
consecutive lesson-length slots, so one that fits no lesson splits into
none: splitByDuration() returned [], createFromWindow() inserted
nothing, and addSlot() discarded the result and re-rendered the page
unchanged.

The REST endpoint already rejected that window with a 400. The admin
form checked the same rules separately, and its copy was both laxer and
mute — an unreadable date, an end before the start, and a two-day window
were bare `return`s, and it never checked offering ownership at all, so
a crafted POST could tie a slot to another instructor's offering and
inherit their price and payment routing.

Both callers now go through WindowValidator, which returns the window or
a WP_Error explaining the refusal. The endpoint returns that error as
is; the page renders its message as a notice. handleFormAction returns
a [notice, error] pair so deletes report themselves too, and a
successful add says how many slots it created.

Two failures could also go unnoticed underneath: wpdb::insert's result
was ignored, and insert_id still holds the previous statement's id after
a failed write, so a failure looked like a success — and could become
the recurrence group of a weekly series, orphaning every later
occurrence. weeks was unbounded server-side despite the form's max=52.

availability-admin.js narrows the lesson-length choices to those that
fit the window and blocks submission when none do, which is what makes
the original mistake hard to repeat. It is a convenience: the server
validates regardless.

Closes #130
2026-07-28 23:19:49 -03:00

329 lines
9.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';
}
/**
* Insert one slot row. Returns its id, or 0 when the write failed —
* `insert_id` still holds the *previous* statement's id after a failed
* insert, so returning it unconditionally made a failed write look like a
* successful one.
*/
public function insert( AvailabilitySlot $slot ): int {
$written = $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 false === $written ? 0 : $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 ) {
if ( $weekly ) {
$ids = array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) );
continue;
}
$id = $this->insert( $slot );
// A failed insert returns 0; it must not reach the caller as an id.
if ( $id > 0 ) {
$ids[] = $id;
}
}
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).
*
* The count is clamped to `AvailabilitySlot::MAX_WEEKLY_OCCURRENCES`. The
* form's `max` attribute says the same, but only this is binding — a
* hand-crafted POST used to be able to ask for an unbounded number of rows.
*
* @return list<int> Inserted slot IDs.
*/
public function createWeeklySeries( AvailabilitySlot $first, int $occurrences ): array {
$occurrences = max( 1, min( AvailabilitySlot::MAX_WEEKLY_OCCURRENCES, $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,
)
);
// A failed insert returns 0. Skipping it keeps a bogus id out of the
// returned list and, more importantly, stops 0 becoming the series'
// recurrence group — which would orphan every later occurrence.
if ( $id <= 0 ) {
continue;
}
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' ]
);
}
}