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:
@@ -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 )
|
||||
),
|
||||
'',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user