Stop the availability form failing in silence
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
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
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
This commit is contained in:
@@ -11,8 +11,14 @@ class AvailabilityRepository {
|
||||
$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 {
|
||||
$this->db->insert(
|
||||
$written = $this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'instructor_id' => $slot->instructorId,
|
||||
@@ -27,7 +33,7 @@ class AvailabilityRepository {
|
||||
[ '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
return false === $written ? 0 : $this->db->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,9 +48,17 @@ class AvailabilityRepository {
|
||||
$ids = [];
|
||||
|
||||
foreach ( $window->splitByDuration() as $slot ) {
|
||||
$ids = $weekly
|
||||
? array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) )
|
||||
: [ ...$ids, $this->insert( $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;
|
||||
@@ -55,10 +69,14 @@ class AvailabilityRepository {
|
||||
* 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, $occurrences );
|
||||
$occurrences = max( 1, min( AvailabilitySlot::MAX_WEEKLY_OCCURRENCES, $occurrences ) );
|
||||
$start = new \DateTimeImmutable( $first->startDt );
|
||||
$end = new \DateTimeImmutable( $first->endDt );
|
||||
|
||||
@@ -79,6 +97,13 @@ class AvailabilityRepository {
|
||||
)
|
||||
);
|
||||
|
||||
// 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 );
|
||||
|
||||
Reference in New Issue
Block a user