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

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:
2026-07-28 23:19:49 -03:00
parent d9dd576630
commit 171b655bb8
15 changed files with 878 additions and 88 deletions
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Availability;
use Unsupervised\Schedular\Offering\OfferingRepository;
/**
* Validates a submitted availability window.
*
* The admin form and the REST endpoint both accept the same window, and used to
* check it independently — the endpoint returning a specific 400 for each
* failure while the form simply returned, saving nothing and saying nothing. A
* 30-minute window submitted with the default 60-minute lesson length was the
* visible symptom: no rows, no error, no clue. Both callers now come through
* here, so neither can drift from the other again.
*
* Every rejection is a `WP_Error` carrying a message written for the person who
* submitted the form: the endpoint returns it as-is (the `status` data makes it
* a 400), and the admin screen shows `get_error_message()` in a notice.
*/
class WindowValidator {
public function __construct( private OfferingRepository $offerings ) {}
/**
* Check a submitted window and return it ready to persist.
*
* @param int $instructorId Instructor the window belongs to.
* @param string $rawStart Submitted start, in any form {@see AvailabilitySlot::normalizeDateTime()} accepts.
* @param string $rawEnd Submitted end, likewise.
* @param int $durationMinutes Lesson length the window is split into; 0 falls back to the 60-minute default.
* @param int $offeringId Offering the slots are tied to, or 0 for any private lesson.
*
* @return AvailabilitySlot|\WP_Error The window, or why it was rejected.
*/
public function validate( int $instructorId, string $rawStart, string $rawEnd, int $durationMinutes, int $offeringId ): AvailabilitySlot|\WP_Error {
$startDt = AvailabilitySlot::normalizeDateTime( $rawStart );
$endDt = AvailabilitySlot::normalizeDateTime( $rawEnd );
if ( null === $startDt || null === $endDt ) {
return new \WP_Error(
'invalid_datetime',
__( 'Enter a valid start and end date and time.', 'unsupervised-schedular' ),
[ 'status' => 400 ]
);
}
if ( $endDt <= $startDt ) {
return new \WP_Error(
'invalid_datetime',
__( 'The end time must be after the start time.', 'unsupervised-schedular' ),
[ 'status' => 400 ]
);
}
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
return new \WP_Error(
'invalid_window',
__( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ),
[ 'status' => 400 ]
);
}
// A slot may only be tied to an offering the instructor owns, so it can
// never inherit another instructor's price or payment routing at booking.
if ( $offeringId > 0 ) {
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering || $offering->instructorId !== $instructorId ) {
return new \WP_Error(
'invalid_offering',
__( 'That offering is not available.', 'unsupervised-schedular' ),
[ 'status' => 400 ]
);
}
}
$duration = $durationMinutes > 0 ? $durationMinutes : AvailabilitySlot::DEFAULT_DURATION_MINUTES;
$window = new AvailabilitySlot(
instructorId: $instructorId,
startDt: $startDt,
endDt: $endDt,
durationMinutes: $duration,
offeringId: $offeringId > 0 ? $offeringId : null,
);
// The window is stored as lesson-length slots, so one that cannot fit a
// single lesson would persist nothing at all.
if ( [] === $window->splitByDuration() ) {
return new \WP_Error(
'invalid_window',
sprintf(
/* translators: %d: the selected lesson length, in minutes. */
__( 'This window is shorter than the %d-minute lesson length, so it holds no bookable slots. Choose a shorter lesson length or a longer window.', 'unsupervised-schedular' ),
$duration
),
[ 'status' => 400 ]
);
}
return $window;
}
}