repo = new RsvReservationRepository(); } public function get_all(?int $limit = null, int $skip = 0) { return $this->repo->get_all($limit, $skip); } public function count_all(): int { return $this->repo->count_all(); } public function get(int $id) { return $this->repo->get($id); } public function get_detail(int $id): ?array { return $this->repo->get_detail($id); } public function create(RsvReservation $reservation) { $reservation->timetable_reservations = $this->merge_adjacent($reservation->timetable_reservations); // Serialise the availability-check + insert per timetable so two // concurrent bookings for the last free slot can't both pass the // capacity check and oversell. Locks are taken in a stable order to // avoid lock-ordering deadlocks and released only after the commit. $timetable_ids = array_values(array_unique(array_map( fn($tr) => $tr->timetable_id, $reservation->timetable_reservations ))); sort($timetable_ids); foreach ($timetable_ids as $tid) { if (!Db::acquire_lock($this->booking_lock_name($tid))) { throw new \RuntimeException('Could not acquire booking lock for timetable ' . $tid); } } $timetable_reservation_service = new RsvTimetableReservationService(); try { Db::begin_transaction(); try { $reservation_id = $this->repo->insert($reservation->to_array()); foreach ($reservation->timetable_reservations as $timetable_reservation) { $timetable_reservation_service->create($reservation_id, $timetable_reservation); } Db::commit(); } catch (Exception $e) { Db::rollback(); Logger::error($e); throw new \Exception($e); } // Only now that the rows are durably committed do we let listeners // (maintainer emails, calendar sync) observe the new reservation. $timetable_reservation_service->flush_deferred_events(); $this->confirmation_state_changed($reservation_id); return $reservation_id; } finally { foreach (array_reverse($timetable_ids) as $tid) { Db::release_lock($this->booking_lock_name($tid)); } } } private function booking_lock_name(int $timetable_id): string { return Db::prefix() . 'rsv_booking_' . $timetable_id; } /** * Collapse runs of touching reservations into single spans, so a sequence * of back-to-back blocks is stored and notified as one booking. * * @param list $timetable_reservations * @return list */ private function merge_adjacent(array $timetable_reservations): array { $by_timetable = []; foreach ($timetable_reservations as $tr) { $by_timetable[$tr->timetable_id][] = $tr; } $merged = []; foreach ($by_timetable as $group) { usort($group, fn($a, $b) => $a->start_utc <=> $b->start_utc); $current = null; foreach ($group as $tr) { if ($current !== null && $tr->start_utc <= $current->end_utc) { if ($tr->end_utc > $current->end_utc) { $current->end_utc = $tr->end_utc; } continue; } if ($current !== null) { $merged[] = $current; } $current = new RsvTimetableReservation( null, $tr->timetable_id, $tr->start_utc, $tr->end_utc ); } if ($current !== null) { $merged[] = $current; } } return $merged; } /** Delete a reservation and its dependent timetable reservations and confirmations. */ public function delete(int $id): void { $this->repo->delete($id); } public function confirmation_state_changed(int $reservation_id): void { // Terminal state is persisted, so this is idempotent: once a reservation // is confirmed or refused, repeat calls (e.g. a Google Calendar re-sync) // short-circuit and never re-dispatch the closing events. if ($this->repo->is_resolved($reservation_id)) { Logger::info('Reservation already resolved: ' . $reservation_id); return; } // A reservation with no timetable items has nothing to confirm or refuse; // bailing out avoids mislabelling it as refused. if ($this->repo->count_subitems($reservation_id) === 0) { Logger::info('Reservation has not subitems: ' . $reservation_id); return; } $timetable_service = new RsvTimetableReservationService(); $all_confirmed = $this->repo->are_subitems_confirmed($reservation_id); $any_pending = $timetable_service->has_pending_confirmation($reservation_id); if ($all_confirmed) { $this->repo->set_resolved_state($reservation_id, true); RsvEventDispatcher::dispatch(new RsvReservationConfirmedEvent($reservation_id)); $form_submit_id = $this->repo->get_form_submit_id($reservation_id); if ($form_submit_id !== null) { RsvEventDispatcher::dispatch(new RsvFormSubmitClosedEvent($form_submit_id, $reservation_id, true)); } return; } if (!$any_pending) { // All items resolved but not all confirmed — at least one refused. $this->repo->set_resolved_state($reservation_id, false); RsvEventDispatcher::dispatch(new RsvReservationRefusedEvent($reservation_id)); $form_submit_id = $this->repo->get_form_submit_id($reservation_id); if ($form_submit_id !== null) { RsvEventDispatcher::dispatch(new RsvFormSubmitClosedEvent($form_submit_id, $reservation_id, false)); } return; } Logger::info('Waiting for pending confirmations on reservation: ' . $reservation_id); } }