Files
Reservair/includes/Services/RsvReservationService.php
T
2026-06-16 10:54:00 +02:00

177 lines
6.4 KiB
PHP

<?php
use Reservair\Database\Db;
use Reservair\Logger\Logger;
class RsvReservationService {
private RsvReservationRepository $repo;
public function __construct() {
$this->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<RsvTimetableReservation> $timetable_reservations
* @return list<RsvTimetableReservation>
*/
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);
}
}