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
+107 -32
View File
@@ -13,6 +13,7 @@ class AvailabilityController {
public function __construct(
private AvailabilityRepository $repository,
private OfferingRepository $offerings,
private WindowValidator $validator,
) {}
public function renderPage(): void {
@@ -21,9 +22,11 @@ class AvailabilityController {
}
$instructorId = get_current_user_id();
$notice = '';
$error = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_availability_action' ) ) {
$this->handleFormAction( $instructorId );
[ $notice, $error ] = $this->handleFormAction( $instructorId );
}
$slots = $this->repository->findByInstructor( $instructorId );
@@ -44,72 +47,144 @@ class AvailabilityController {
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
}
private function handleFormAction( int $instructorId ): void {
/**
* Run the submitted action and report what happened. Every branch returns a
* message: a form that silently reloads leaves the instructor unable to tell
* "saved 41 slots" from "saved nothing".
*
* @return array{string, string} Success notice and error message; each is
* empty when it does not apply.
*/
private function handleFormAction( int $instructorId ): array {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
if ( 'add' === $action ) {
$this->addSlot( $instructorId );
return $this->addSlot( $instructorId );
}
if ( 'delete' === $action ) {
$this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId );
return $this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId )
? [ __( 'Availability slot deleted.', 'unsupervised-schedular' ), '' ]
: [ '', __( 'That slot could not be deleted. It may already be booked, or belong to someone else.', 'unsupervised-schedular' ) ];
}
if ( 'bulk_delete' === $action ) {
// The array itself carries no data; each element is coerced and
// absint-sanitized individually below.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
$rawIds = $_POST['slot_ids'] ?? [];
$rawIds = $_POST['slot_ids'] ?? [];
$deleted = 0;
$failed = 0;
foreach ( is_array( $rawIds ) ? $rawIds : [] as $rawId ) {
$this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId );
if ( $this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId ) ) {
++$deleted;
continue;
}
++$failed;
}
return $this->bulkDeleteResult( $deleted, $failed );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
return [ '', '' ];
}
/**
* Wording for a bulk delete, which can partly succeed.
*
* @return array{string, string}
*/
private function bulkDeleteResult( int $deleted, int $failed ): array {
$notice = $deleted > 0
? sprintf(
/* translators: %d: number of availability slots deleted. */
_n( '%d slot deleted.', '%d slots deleted.', $deleted, 'unsupervised-schedular' ),
$deleted
)
: '';
$error = $failed > 0
? sprintf(
/* translators: %d: number of slots that could not be deleted. */
_n(
'%d slot could not be deleted — it may already be booked.',
'%d slots could not be deleted — they may already be booked.',
$failed,
'unsupervised-schedular'
),
$failed
)
: '';
if ( 0 === $deleted && 0 === $failed ) {
$error = __( 'No slots were selected.', 'unsupervised-schedular' );
}
return [ $notice, $error ];
}
/**
* Delete a slot only when it exists and belongs to the given instructor.
* The repository additionally refuses to delete booked slots.
* The repository additionally refuses to delete booked slots. Returns whether
* the row actually went away.
*/
private function deleteOwnSlot( int $slotId, int $instructorId ): void {
private function deleteOwnSlot( int $slotId, int $instructorId ): bool {
if ( $slotId <= 0 ) {
return;
return false;
}
$slot = $this->repository->findById( $slotId );
if ( $slot && $slot->instructorId === $instructorId ) {
$this->repository->delete( $slotId );
if ( null === $slot || $slot->instructorId !== $instructorId ) {
return false;
}
return $this->repository->delete( $slotId );
}
private function addSlot( int $instructorId ): void {
/**
* Validate and persist a submitted window.
*
* @return array{string, string}
*/
private function addSlot( int $instructorId ): array {
// phpcs:disable WordPress.Security.NonceVerification.Missing
$startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) );
$endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ) );
// A window must start and end on the same day (weekly repeat covers longer
// ranges) and fit at least one lesson; it is stored as lesson-length slots.
if ( null === $startDt || null === $endDt || $endDt <= $startDt || substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
return;
}
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
$window = new AvailabilitySlot(
instructorId: $instructorId,
startDt: $startDt,
endDt: $endDt,
durationMinutes: $duration > 0 ? $duration : 60,
offeringId: $offeringId > 0 ? $offeringId : null,
$window = $this->validator->validate(
$instructorId,
sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ),
sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ),
absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ),
absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
);
if ( $window instanceof \WP_Error ) {
return [ '', $window->get_error_message() ];
}
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) );
$weeks = absint( Val::int( $_POST['weeks'] ?? 1 ) );
$this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
// phpcs:enable WordPress.Security.NonceVerification.Missing
$ids = $this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
// The window was valid, so it split into at least one slot — an empty
// result means every insert failed.
if ( [] === $ids ) {
return [ '', __( 'The availability could not be saved. Please try again.', 'unsupervised-schedular' ) ];
}
return [
sprintf(
/* translators: %d: number of bookable slots created. */
_n( 'Added %d bookable slot.', 'Added %d bookable slots.', count( $ids ), 'unsupervised-schedular' ),
count( $ids )
),
'',
];
}
}