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
191 lines
6.4 KiB
PHP
191 lines
6.4 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Availability;
|
|
|
|
use Unsupervised\Schedular\Auth\RoleManager;
|
|
use Unsupervised\Schedular\Offering\Offering;
|
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
|
use Unsupervised\Schedular\Val;
|
|
|
|
class AvailabilityController {
|
|
|
|
public function __construct(
|
|
private AvailabilityRepository $repository,
|
|
private OfferingRepository $offerings,
|
|
private WindowValidator $validator,
|
|
) {}
|
|
|
|
public function renderPage(): void {
|
|
if ( ! current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
|
|
wp_die( esc_html__( 'You do not have permission to manage availability.', 'unsupervised-schedular' ) );
|
|
}
|
|
|
|
$instructorId = get_current_user_id();
|
|
$notice = '';
|
|
$error = '';
|
|
|
|
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_availability_action' ) ) {
|
|
[ $notice, $error ] = $this->handleFormAction( $instructorId );
|
|
}
|
|
|
|
$slots = $this->repository->findByInstructor( $instructorId );
|
|
$offeringChoices = $this->offerings->findAll( $instructorId, Offering::KIND_PRIVATE_LESSON, true );
|
|
|
|
// View-state query params only (which view, which week) — nothing is
|
|
// mutated from them, so no nonce applies.
|
|
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
|
$view = 'list' === sanitize_key( Val::string( wp_unslash( $_GET['usc_view'] ?? '' ) ) ) ? 'list' : 'week';
|
|
$requestedWeek = sanitize_text_field( Val::string( wp_unslash( $_GET['usc_week'] ?? '' ) ) );
|
|
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
|
|
|
$weekStart = WeekCalendar::weekStart( $requestedWeek, Val::int( get_option( 'start_of_week', 1 ) ), current_time( 'Y-m-d' ) );
|
|
$weekDays = WeekCalendar::days( $weekStart, $slots );
|
|
$prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' );
|
|
$nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' );
|
|
|
|
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
|
|
}
|
|
|
|
/**
|
|
* 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 ) {
|
|
return $this->addSlot( $instructorId );
|
|
}
|
|
|
|
if ( 'delete' === $action ) {
|
|
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'] ?? [];
|
|
$deleted = 0;
|
|
$failed = 0;
|
|
|
|
foreach ( is_array( $rawIds ) ? $rawIds : [] as $rawId ) {
|
|
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. Returns whether
|
|
* the row actually went away.
|
|
*/
|
|
private function deleteOwnSlot( int $slotId, int $instructorId ): bool {
|
|
if ( $slotId <= 0 ) {
|
|
return false;
|
|
}
|
|
|
|
$slot = $this->repository->findById( $slotId );
|
|
|
|
if ( null === $slot || $slot->instructorId !== $instructorId ) {
|
|
return false;
|
|
}
|
|
|
|
return $this->repository->delete( $slotId );
|
|
}
|
|
|
|
/**
|
|
* Validate and persist a submitted window.
|
|
*
|
|
* @return array{string, string}
|
|
*/
|
|
private function addSlot( int $instructorId ): array {
|
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
|
$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 ) );
|
|
// 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 )
|
|
),
|
|
'',
|
|
];
|
|
}
|
|
}
|