Files
unsupervised-scheduler/src/Availability/AvailabilityRepository.php
T
thatguygriff 19e663d6fa
CI / Coding Standards (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 1m2s
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.3) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Build Plugin Zip (pull_request) Has been skipped
Extend availability (durations, weekly recurrence, calendar); price offerings in dollars
Availability (#2):
- us_availability gains offering_id, duration_minutes (default 60), and
  recurrence_group; AvailabilitySlot carries the new fields.
- AvailabilityRepository::createWeeklySeries() generates N weekly rows
  sharing a recurrence_group; findAvailable() filters by offering and
  duration. Date math uses DateTimeImmutable::modify() (the no-debug CI
  regex `dd\(` matches `->add(`).
- REST GET filters by offering_id/duration_minutes; POST accepts
  duration_minutes, offering_id, recurrence (single|weekly) + weeks.
- Admin form adds duration, an offering picker, and one-off/weekly options
  (OfferingRepository wired into AvailabilityController).
- booking.js renders an agenda calendar (slots grouped by day, with
  duration). The richer booking UX lands with the booking-flow work.

Offering price in dollars:
- Switch us_offerings.price_cents (INT) to price DECIMAL(10,2); Offering
  uses float $price. Admin form and REST take dollars.
- Fix a pre-existing misalignment in the Offering insert/update $wpdb
  format arrays (billing_mode/capacity/is_active were mapped to the wrong
  specifiers, which would corrupt values) via a single COLUMN_FORMATS list.

Also bump PHPStan to --memory-limit=1G in the lint script; 128M now
crashes analysis as the codebase has grown.

Refs #2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:43:48 -03:00

176 lines
4.3 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 {$this->table} WHERE {$whereClause} ORDER BY start_dt ASC";
$rows = $params
? $this->db->get_results( $this->db->prepare( $sql, $params ) )
: $this->db->get_results( $sql );
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 {$this->table} WHERE instructor_id = %d ORDER BY start_dt ASC",
$instructorId
)
);
return array_map( AvailabilitySlot::fromRow( ... ), $rows ?? [] );
}
public function findById( int $id ): ?AvailabilitySlot {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
);
return $row ? AvailabilitySlot::fromRow( $row ) : null;
}
public function markBooked( int $id ): bool {
return (bool) $this->db->update(
$this->table,
[ 'is_booked' => 1 ],
[ 'id' => $id ],
[ '%d' ],
[ '%d' ]
);
}
/**
* 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' ]
);
}
}