1d6ac46ba3
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.3) (pull_request) Successful in 52s
CI / Coding Standards (pull_request) Successful in 57s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m1s
CI / PHPStan (pull_request) Successful in 1m11s
CI / Build Plugin Zip (pull_request) Has been skipped
- Bump phpstan/phpstan ^2.0 and szepeviktor/phpstan-wordpress ^2.0 - Move the analysis level into phpstan.neon (single source) and raise it to 10 - Add Val, a runtime coercion helper that narrows untyped WordPress boundary values (wpdb rows, REST params, superglobals, options) with explicit checks instead of blind casts, plus unit tests - Type value-object fromRow() params as stdClass (what wpdb returns) and map columns through Val so unexpected shapes degrade safely - Use %i identifier placeholders for table names in all wpdb::prepare() calls so every query string is a literal and identifiers are escaped by WordPress; raises the minimum WordPress version to 6.2 where %i was introduced - Guard wpdb::prepare() null result before wpdb::query() in updateTax() - Fix nullable get_permalink()/strtotime() handling, list types at REST and capability call sites, dead null-coalescing on checked superglobals, and narrow get_users() results before mapping - Register Val method names with the ValidatedSanitizedInput sniff so it validates the real sanitizer around each superglobal read - Update repository unit tests for the %i placeholder arguments Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
206 lines
5.1 KiB
PHP
206 lines
5.1 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Availability;
|
|
|
|
class AvailabilityRepository {
|
|
|
|
private string $table;
|
|
|
|
public function __construct( private \wpdb $db ) {
|
|
$this->table = $db->prefix . 'us_availability';
|
|
}
|
|
|
|
public function insert( AvailabilitySlot $slot ): int {
|
|
$this->db->insert(
|
|
$this->table,
|
|
[
|
|
'instructor_id' => $slot->instructorId,
|
|
'offering_id' => $slot->offeringId,
|
|
'start_dt' => $slot->startDt,
|
|
'end_dt' => $slot->endDt,
|
|
'duration_minutes' => $slot->durationMinutes,
|
|
'is_booked' => 0,
|
|
'recurrence_group' => $slot->recurrenceGroup,
|
|
'created_at' => current_time( 'mysql' ),
|
|
],
|
|
[ '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
|
);
|
|
|
|
return $this->db->insert_id;
|
|
}
|
|
|
|
/**
|
|
* Create a weekly-recurring series from a template slot. Each occurrence is a
|
|
* separate row one week apart, all sharing a `recurrence_group` (the id of the
|
|
* first row).
|
|
*
|
|
* @return list<int> Inserted slot IDs.
|
|
*/
|
|
public function createWeeklySeries( AvailabilitySlot $first, int $occurrences ): array {
|
|
$occurrences = max( 1, $occurrences );
|
|
$start = new \DateTimeImmutable( $first->startDt );
|
|
$end = new \DateTimeImmutable( $first->endDt );
|
|
|
|
$ids = [];
|
|
$groupId = 0;
|
|
|
|
for ( $week = 0; $week < $occurrences; $week++ ) {
|
|
$shift = '+' . ( 7 * $week ) . ' days';
|
|
|
|
$id = $this->insert(
|
|
new AvailabilitySlot(
|
|
instructorId: $first->instructorId,
|
|
startDt: $start->modify( $shift )->format( 'Y-m-d H:i:s' ),
|
|
endDt: $end->modify( $shift )->format( 'Y-m-d H:i:s' ),
|
|
durationMinutes: $first->durationMinutes,
|
|
offeringId: $first->offeringId,
|
|
recurrenceGroup: $groupId > 0 ? $groupId : null,
|
|
)
|
|
);
|
|
|
|
if ( 0 === $groupId ) {
|
|
$groupId = $id;
|
|
$this->setRecurrenceGroup( $id, $groupId );
|
|
}
|
|
|
|
$ids[] = $id;
|
|
}
|
|
|
|
return $ids;
|
|
}
|
|
|
|
private function setRecurrenceGroup( int $id, int $groupId ): void {
|
|
$this->db->update(
|
|
$this->table,
|
|
[ 'recurrence_group' => $groupId ],
|
|
[ 'id' => $id ],
|
|
[ '%d' ],
|
|
[ '%d' ]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Find unbooked slots, optionally filtered by instructor, offering, lesson
|
|
* length, and date range.
|
|
*
|
|
* @return list<AvailabilitySlot>
|
|
*/
|
|
public function findAvailable( int $instructorId = 0, int $offeringId = 0, int $durationMinutes = 0, string $from = '', string $to = '' ): array {
|
|
$where = [ 'is_booked = 0' ];
|
|
$params = [];
|
|
|
|
if ( $instructorId > 0 ) {
|
|
$where[] = 'instructor_id = %d';
|
|
$params[] = $instructorId;
|
|
}
|
|
|
|
if ( $offeringId > 0 ) {
|
|
$where[] = 'offering_id = %d';
|
|
$params[] = $offeringId;
|
|
}
|
|
|
|
if ( $durationMinutes > 0 ) {
|
|
$where[] = 'duration_minutes = %d';
|
|
$params[] = $durationMinutes;
|
|
}
|
|
|
|
if ( '' !== $from ) {
|
|
$where[] = 'start_dt >= %s';
|
|
$params[] = $from;
|
|
}
|
|
|
|
if ( '' !== $to ) {
|
|
$where[] = 'end_dt <= %s';
|
|
$params[] = $to;
|
|
}
|
|
|
|
$whereClause = implode( ' AND ', $where );
|
|
$sql = "SELECT * FROM %i WHERE {$whereClause} ORDER BY start_dt ASC";
|
|
|
|
$rows = $this->db->get_results(
|
|
$this->db->prepare( $sql, array_merge( [ $this->table ], $params ) )
|
|
);
|
|
|
|
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
|
|
}
|
|
|
|
/**
|
|
* Find all slots for an instructor (booked and unbooked).
|
|
*
|
|
* @return list<AvailabilitySlot>
|
|
*/
|
|
public function findByInstructor( int $instructorId ): array {
|
|
$rows = $this->db->get_results(
|
|
$this->db->prepare(
|
|
'SELECT * FROM %i WHERE instructor_id = %d ORDER BY start_dt ASC',
|
|
$this->table,
|
|
$instructorId
|
|
)
|
|
);
|
|
|
|
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
|
|
}
|
|
|
|
/**
|
|
* Unbooked slots that belong to a weekly-recurring group, ordered by start.
|
|
*
|
|
* @return list<AvailabilitySlot>
|
|
*/
|
|
public function findUnbookedInGroup( int $recurrenceGroup ): array {
|
|
$rows = $this->db->get_results(
|
|
$this->db->prepare(
|
|
'SELECT * FROM %i WHERE recurrence_group = %d AND is_booked = 0 ORDER BY start_dt ASC',
|
|
$this->table,
|
|
$recurrenceGroup
|
|
)
|
|
);
|
|
|
|
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
|
|
}
|
|
|
|
public function findById( int $id ): ?AvailabilitySlot {
|
|
$row = $this->db->get_row(
|
|
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
|
|
);
|
|
|
|
return $row ? AvailabilitySlot::fromRow( $row ) : null;
|
|
}
|
|
|
|
/**
|
|
* Atomically claim an unbooked slot. The `is_booked = 0` guard in the WHERE
|
|
* clause makes this the single point of truth for reserving a slot: only one
|
|
* concurrent request can transition it from unbooked to booked, so two students
|
|
* cannot book (and be charged for) the same slot. Returns true only when this
|
|
* call is the one that claimed it.
|
|
*/
|
|
public function claim( int $id ): bool {
|
|
$updated = $this->db->update(
|
|
$this->table,
|
|
[ 'is_booked' => 1 ],
|
|
[
|
|
'id' => $id,
|
|
'is_booked' => 0,
|
|
],
|
|
[ '%d' ],
|
|
[ '%d', '%d' ]
|
|
);
|
|
|
|
return 1 === $updated;
|
|
}
|
|
|
|
/**
|
|
* Delete an unbooked slot. Returns false if the slot is already booked.
|
|
*/
|
|
public function delete( int $id ): bool {
|
|
return (bool) $this->db->delete(
|
|
$this->table,
|
|
[
|
|
'id' => $id,
|
|
'is_booked' => 0,
|
|
],
|
|
[ '%d', '%d' ]
|
|
);
|
|
}
|
|
}
|