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:
@@ -4,14 +4,13 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Availability;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class AvailabilityEndpoint {
|
||||
|
||||
public function __construct(
|
||||
private AvailabilityRepository $repository,
|
||||
private OfferingRepository $offerings,
|
||||
private WindowValidator $validator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -113,40 +112,18 @@ class AvailabilityEndpoint {
|
||||
}
|
||||
|
||||
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||
$instructorId = get_current_user_id();
|
||||
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
|
||||
$duration = absint( Val::int( $request->get_param( 'duration_minutes' ) ) );
|
||||
|
||||
// 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 ] );
|
||||
}
|
||||
}
|
||||
|
||||
$startDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'start_dt' ) ) );
|
||||
$endDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'end_dt' ) ) );
|
||||
|
||||
if ( null === $startDt || null === $endDt || $endDt <= $startDt ) {
|
||||
return new \WP_Error( 'invalid_datetime', __( 'Provide a valid start and end, with the end after the start.', '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 ] );
|
||||
}
|
||||
|
||||
$window = new AvailabilitySlot(
|
||||
instructorId: $instructorId,
|
||||
startDt: $startDt,
|
||||
endDt: $endDt,
|
||||
durationMinutes: $duration > 0 ? $duration : 60,
|
||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
||||
// Validation lives in WindowValidator so this endpoint and the admin form
|
||||
// enforce exactly the same rules.
|
||||
$window = $this->validator->validate(
|
||||
get_current_user_id(),
|
||||
Val::string( $request->get_param( 'start_dt' ) ),
|
||||
Val::string( $request->get_param( 'end_dt' ) ),
|
||||
absint( Val::int( $request->get_param( 'duration_minutes' ) ) ),
|
||||
absint( Val::int( $request->get_param( 'offering_id' ) ) ),
|
||||
);
|
||||
|
||||
if ( [] === $window->splitByDuration() ) {
|
||||
return new \WP_Error( 'invalid_window', __( 'The availability window is shorter than the lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
if ( $window instanceof \WP_Error ) {
|
||||
return $window;
|
||||
}
|
||||
|
||||
$ids = $this->repository->createFromWindow(
|
||||
@@ -155,6 +132,12 @@ class AvailabilityEndpoint {
|
||||
absint( Val::int( $request->get_param( 'weeks' ) ) )
|
||||
);
|
||||
|
||||
// A valid window splits into at least one slot, so nothing written means
|
||||
// every insert failed.
|
||||
if ( [] === $ids ) {
|
||||
return new \WP_Error( 'not_saved', __( 'The availability could not be saved.', 'unsupervised-schedular' ), [ 'status' => 500 ] );
|
||||
}
|
||||
|
||||
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user