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
106 lines
3.8 KiB
PHP
106 lines
3.8 KiB
PHP
<?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;
|
|
}
|
|
}
|