Files
unsupervised-scheduler/src/Availability/AvailabilitySlot.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

124 lines
3.8 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;
use Unsupervised\Schedular\Val;
class AvailabilitySlot {
/** Lesson length used when none was submitted. */
public const DEFAULT_DURATION_MINUTES = 60;
/**
* Lesson lengths a window can be split into, offered by the availability
* form. The form hides the ones a given window is too short for.
*
* @var list<int>
*/
public const DURATION_CHOICES = [ 30, 60 ];
/**
* Ceiling on a weekly series, matching the form's `max`. Enforced in the
* repository too, so a hand-crafted POST cannot ask for ten thousand rows.
*/
public const MAX_WEEKLY_OCCURRENCES = 52;
public function __construct(
public readonly int $instructorId,
public readonly string $startDt,
public readonly string $endDt,
public readonly int $durationMinutes = 60,
public readonly ?int $offeringId = null,
public readonly bool $isBooked = false,
public readonly ?int $recurrenceGroup = null,
public readonly ?int $id = null,
) {}
/**
* Normalise a submitted slot datetime to canonical `Y-m-d H:i:s`, or null when
* it is not a real datetime. Accepts the HTML `datetime-local` form
* (`Y-m-d\TH:i`, optionally with seconds) and the canonical form (optionally
* without seconds). Anything else — including strings PHP would "helpfully"
* coerce — is rejected so garbage never reaches the DATETIME column or throws
* inside the weekly-series date arithmetic.
*/
public static function normalizeDateTime( string $value ): ?string {
foreach ( [ 'Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i' ] as $format ) {
$dt = \DateTimeImmutable::createFromFormat( '!' . $format, $value );
if ( false !== $dt && $dt->format( $format ) === $value ) {
return $dt->format( 'Y-m-d H:i:s' );
}
}
return null;
}
/**
* Split this window into consecutive lesson-length slots: 09:0016:00 with
* 60-minute lessons yields seven bookable slots. A trailing remainder shorter
* than the lesson length is dropped, and an empty list is returned when the
* window cannot fit a single lesson.
*
* @return list<self>
*/
public function splitByDuration(): array {
if ( $this->durationMinutes <= 0 ) {
return [];
}
$end = new \DateTimeImmutable( $this->endDt );
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
$cursor = new \DateTimeImmutable( $this->startDt );
$chunkEnd = $cursor->add( $step );
$slots = [];
while ( $chunkEnd <= $end ) {
$slots[] = new self(
instructorId: $this->instructorId,
startDt: $cursor->format( 'Y-m-d H:i:s' ),
endDt: $chunkEnd->format( 'Y-m-d H:i:s' ),
durationMinutes: $this->durationMinutes,
offeringId: $this->offeringId,
);
$cursor = $chunkEnd;
$chunkEnd = $cursor->add( $step );
}
return $slots;
}
public static function fromRow( \stdClass $row ): self {
return new self(
instructorId: Val::int( $row->instructor_id ),
startDt: Val::string( $row->start_dt ),
endDt: Val::string( $row->end_dt ),
durationMinutes: Val::int( $row->duration_minutes ),
offeringId: Val::intOrNull( $row->offering_id ),
isBooked: Val::bool( $row->is_booked ),
recurrenceGroup: Val::intOrNull( $row->recurrence_group ),
id: Val::int( $row->id ),
);
}
/**
* Returns a plain array representation of the slot.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'instructor_id' => $this->instructorId,
'offering_id' => $this->offeringId,
'start_dt' => $this->startDt,
'end_dt' => $this->endDt,
'duration_minutes' => $this->durationMinutes,
'is_booked' => $this->isBooked,
'recurrence_group' => $this->recurrenceGroup,
];
}
}