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 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 */ 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 */ 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 */ 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' ] ); } }