Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a0b75f4
feat(copies): manage physical copies from the book summary
fabiodalez-dev Aug 14, 2026
96876f5
fix(copies): harden copy management from the book summary
fabiodalez-dev Aug 14, 2026
9d83ed6
fix(copies): address self-review findings on copy management
fabiodalez-dev Aug 14, 2026
4004ab1
fix(books): reject a non-integer copie_totali before casting (store)
fabiodalez-dev Aug 14, 2026
936187c
test(email): raise Mailpit wait to 30s to stop the deep-regression flake
fabiodalez-dev Aug 14, 2026
f5a90ac
fix(books): make book + initial copies creation atomic (store)
fabiodalez-dev Aug 14, 2026
ef7045b
fix(copies): make inventory mutations fully atomic
fabiodalez-dev Aug 14, 2026
4a2b4f0
fix(copies): close final review findings
fabiodalez-dev Aug 14, 2026
f9b4f15
fix(loans): align reservations with physical copies
fabiodalez-dev Aug 14, 2026
343a3a1
fix(copies): harden final persistence edge cases
fabiodalez-dev Aug 14, 2026
d2733e7
test(ci): stabilize cross-browser accessibility audit
fabiodalez-dev Aug 14, 2026
0438de4
test(ci): reuse admin session in browser audit
fabiodalez-dev Aug 14, 2026
fa73643
test(ci): audit stable accessibility styles
fabiodalez-dev Aug 14, 2026
a4397a0
test(ci): wait for accessibility animations
fabiodalez-dev Aug 14, 2026
e1fd9db
test(ci): settle animations before axe scan
fabiodalez-dev Aug 14, 2026
9a27233
test(loans): align coverage guard with 64 cases
fabiodalez-dev Aug 14, 2026
ed89ad1
test(ci): avoid asynchronous Mailpit purge races
fabiodalez-dev Aug 14, 2026
795aecd
fix(copies): keep admin routes literal (M1) and make increase-copies …
fabiodalez-dev Aug 15, 2026
bc4d4dd
fix(loans/copies): close remaining review findings
fabiodalez-dev Aug 15, 2026
401bacf
test(regression): E2E coverage for the v0.7.59 fixes #333/#336/#338
fabiodalez-dev Aug 15, 2026
6b82c9e
fix(copies): preserve circulation invariants
fabiodalez-dev Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
354 changes: 289 additions & 65 deletions app/Controllers/CopyController.php

Large diffs are not rendered by default.

253 changes: 102 additions & 151 deletions app/Controllers/LibriController.php

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion app/Controllers/PrestitiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1317,7 +1317,9 @@ public function processReturn(Request $request, Response $response, mysqli $db,
// delle prenotazioni: solo così copie_disponibili e libri.stato riflettono lo
// stato finale e un libro restituito torna correttamente prestabile (TXN-002,
// TXN-005, A2). insideTransaction:true mantiene l'atomicità della transazione.
$integrity->recalculateBookAvailability($libro_id, insideTransaction: true);
if (!$integrity->recalculateBookAvailability($libro_id, insideTransaction: true)) {
throw new \RuntimeException('Failed to recalculate availability after loan return.');
}

$db->commit();

Expand Down
49 changes: 39 additions & 10 deletions app/Controllers/ReservationManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,26 @@ public function processBookAvailability($bookId)
FROM prenotazioni r
JOIN utenti u ON r.utente_id = u.id
WHERE r.libro_id = ? AND r.stato = 'attiva'
AND r.data_inizio_richiesta <= ?
AND (
-- Real requested start that has arrived. '1000-01-01' is
-- MySQL's minimum valid DATE: the floor rejects 0000-00-00
-- dump rows (which would otherwise be promoted back-dated)
-- without embedding the literal '0000-00-00', which a
-- NO_ZERO_DATE server refuses even at prepare time.
(r.data_inizio_richiesta >= '1000-01-01' AND r.data_inizio_richiesta <= ?)
-- Legacy row with no requested start: promote only while the
-- reservation deadline is still valid (today or future). An
-- already-expired deadline must be left for the expiry /
-- cancellation path, never converted into a loan back-dated
-- to the past.
OR (r.data_inizio_richiesta IS NULL
AND r.data_scadenza_prenotazione IS NOT NULL
AND DATE(r.data_scadenza_prenotazione) >= ?)
)
ORDER BY r.queue_position ASC
LIMIT 1
");
$stmt->bind_param('is', $bookId, $today);
$stmt->bind_param('iss', $bookId, $today, $today);
$stmt->execute();
$result = $stmt->get_result();
$nextReservation = $result->fetch_assoc();
Expand All @@ -204,13 +219,19 @@ public function processBookAvailability($bookId)
// R_END once: a legacy prenotazione may have data_fine_richiesta NULL but
// data_scadenza_prenotazione set — passing the raw NULL would make
// isDateRangeAvailable() return false and the row would never promote.
$startDate = $nextReservation['data_inizio_richiesta'];
$startDate = $nextReservation['data_inizio_richiesta']
?: (!empty($nextReservation['data_scadenza_prenotazione'])
? substr((string) $nextReservation['data_scadenza_prenotazione'], 0, 10)
: null);
$endDate = $nextReservation['data_fine_richiesta']
?: (!empty($nextReservation['data_scadenza_prenotazione'])
? substr((string) $nextReservation['data_scadenza_prenotazione'], 0, 10)
: $startDate);
// Feed the resolved end to createLoanFromReservation() too (it reads
// $reservation['data_fine_richiesta'] for the loan period).
// Feed both resolved bounds to createLoanFromReservation() too.
// Legacy rows can have a NULL requested start but a valid legacy
// deadline; selecting them without normalising the start would
// still make isDateRangeAvailable() reject them forever.
$nextReservation['data_inizio_richiesta'] = $startDate;
$nextReservation['data_fine_richiesta'] = $endDate;

// #157: pass the promoted reservation's queue_position so the
Expand Down Expand Up @@ -246,7 +267,9 @@ public function processBookAvailability($bookId)
// counted. Recalc again now that the reservation is 'completata', so the
// commitment is counted exactly once.
$integrity = new \App\Support\DataIntegrity($this->db);
$integrity->recalculateBookAvailability($bookId, true);
if (!$integrity->recalculateBookAvailability($bookId, true)) {
throw new \RuntimeException('Failed to recalculate availability after reservation promotion.');
}

// Update queue positions for remaining reservations.
// Pass the completed reservation's position: the converted
Expand Down Expand Up @@ -359,8 +382,10 @@ private function createLoanFromReservation($reservation)
$ownTransaction = $this->beginTransactionIfNeeded();

try {
// Find an available copy for this date range (no overlapping loans)
// Consider 'disponibile' and 'prenotato' copies (exclude perso/danneggiato/manutenzione)
// Find an available copy for this date range (no overlapping loans).
// Promotion happens only when the requested start has arrived, so a
// physically-out ('prestato') copy is intentionally excluded here;
// it becomes eligible through the return/reassignment path.
// The NOT EXISTS clause ensures no overlapping loans for the requested dates
// Note: 'da_ritirare' copies are still 'disponibile' but have a loan reservation
$copyStmt = $this->db->prepare("
Expand Down Expand Up @@ -452,7 +477,9 @@ private function createLoanFromReservation($reservation)

// Update book availability (inside transaction)
$integrity = new \App\Support\DataIntegrity($this->db);
$integrity->recalculateBookAvailability($bookId, true);
if (!$integrity->recalculateBookAvailability($bookId, true)) {
throw new \RuntimeException('Failed to recalculate availability for the promoted reservation.');
}

$this->commitIfOwned($ownTransaction);
return $loanId;
Expand Down Expand Up @@ -883,7 +910,9 @@ public function cancelExpiredReservations(): int
$integrity = new \App\Support\DataIntegrity($this->db);
foreach ($affectedBooks as $bookId) {
$this->reorderQueuePositions($bookId);
$integrity->recalculateBookAvailability($bookId, true);
if (!$integrity->recalculateBookAvailability($bookId, true)) {
throw new \RuntimeException('Failed to recalculate availability after reservation expiry.');
}
}

$this->commitIfOwned($ownTransaction);
Expand Down
8 changes: 6 additions & 2 deletions app/Controllers/ReservationsAdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ public function update(Request $request, Response $response, mysqli $db, int $id
}

$integrity = new \App\Support\DataIntegrity($db);
$integrity->recalculateBookAvailability($libroId, insideTransaction: true);
if (!$integrity->recalculateBookAvailability($libroId, insideTransaction: true)) {
throw new \RuntimeException('Failed to recalculate availability after reservation update.');
}

// Cancelling/completing an active reservation frees a slot: promote the next
// queued reservation(s) right away, exactly like every other release path
Expand Down Expand Up @@ -535,7 +537,9 @@ public function store(Request $request, Response $response, mysqli $db): Respons
$stmt->close();

$integrity = new \App\Support\DataIntegrity($db);
$integrity->recalculateBookAvailability($libroId, insideTransaction: true);
if (!$integrity->recalculateBookAvailability($libroId, insideTransaction: true)) {
throw new \RuntimeException('Failed to recalculate availability after reservation creation.');
}

$db->commit();
return $response->withHeader('Location', url('/admin/reservations') . '?created=1')->withStatus(302);
Expand Down
26 changes: 18 additions & 8 deletions app/Controllers/ReservationsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -434,17 +434,27 @@ public function createReservation($request, $response, $args)
}
$dupReservationStmt->close();

// The pre-transaction eligibility check is only a fast fail. Lock
// and re-check the patron before creating the durable request so a
// concurrent suspension/card expiry cannot slip through the gap.
$userLockStmt = $this->db->prepare("SELECT id FROM utenti WHERE id = ? FOR UPDATE");
$userLockStmt->bind_param('i', $userId);
$userLockStmt->execute();
$userLockStmt->get_result();
$userLockStmt->close();
$eligibilityError = \App\Support\LoanEligibility::checkUser($this->db, $userId);
if ($eligibilityError !== null) {
$this->db->rollback();
$response->getBody()->write(json_encode([
'success' => false,
'message' => \App\Support\LoanEligibility::errorMessage($eligibilityError),
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(403);
}

// Enforce max active loans per user (admin setting; 0 = no limit)
$maxLoans = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'max_active_loans_per_user', '0') ?? 0);
if ($maxLoans > 0) {
// Serialize concurrent same-user requests on different books: the
// per-book libri lock taken earlier does not mutually-exclude them,
// so without this both could pass the limit check and both commit.
$userLockStmt = $this->db->prepare("SELECT id FROM utenti WHERE id = ? FOR UPDATE");
$userLockStmt->bind_param('i', $userId);
$userLockStmt->execute();
$userLockStmt->close();

$cntStmt = $this->db->prepare("SELECT COUNT(*) FROM prestiti WHERE utente_id = ? AND attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')");
$cntStmt->bind_param('i', $userId);
$cntStmt->execute();
Expand Down
67 changes: 52 additions & 15 deletions app/Controllers/UserActionsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,9 @@ public function cancelLoan(Request $request, Response $response, mysqli $db): Re
// siamo dentro la transazione aperta in questo metodo, evita il
// commit implicito di una begin_transaction() annidata)
$integrity = new \App\Support\DataIntegrity($db);
$integrity->recalculateBookAvailability((int) $loan['libro_id'], insideTransaction: true);
if (!$integrity->recalculateBookAvailability((int) $loan['libro_id'], insideTransaction: true)) {
throw new \RuntimeException('Failed to recalculate availability after loan cancellation.');
}

$db->commit();

Expand Down Expand Up @@ -347,12 +349,28 @@ public function cancelReservation(Request $request, Response $response, mysqli $
$updatePos->close();
$reorderStmt->close();

// Recalculate book availability
// Cancelling a queue reservation frees a promised capacity unit.
// Promote every now-eligible row in the same transaction, matching
// the admin cancellation and physical-copy release paths.
$reservationManager = new \App\Controllers\ReservationManager($db);
$reservationManager->setExternalTransaction(true);
for ($promoGuard = 0; $promoGuard < 1000 && $reservationManager->processBookAvailability($libroId); $promoGuard++) {
// promote until the newly-freed capacity is exhausted
}

$integrity = new \App\Support\DataIntegrity($db);
$integrity->recalculateBookAvailability($libroId, insideTransaction: true);
if (!$integrity->recalculateBookAvailability($libroId, insideTransaction: true)) {
throw new \RuntimeException('Failed to recalculate availability after reservation cancellation.');
}

$db->commit();

try {
$reservationManager->flushDeferredNotifications();
} catch (\Throwable $e) {
SecureLogger::warning('Failed to flush reservation notifications after user cancellation', ['error' => $e->getMessage()]);
}

return $response->withHeader('Location', RouteTranslator::route('reservations') . '?canceled=1')->withStatus(302);

} catch (\Throwable $e) {
Expand Down Expand Up @@ -452,7 +470,9 @@ public function changeReservationDate(Request $request, Response $response, mysq

// Recalculate book availability
$integrity = new \App\Support\DataIntegrity($db);
$integrity->recalculateBookAvailability($libroId, insideTransaction: true);
if (!$integrity->recalculateBookAvailability($libroId, insideTransaction: true)) {
throw new \RuntimeException('Failed to recalculate availability after reservation date change.');
}

$db->commit();

Expand Down Expand Up @@ -568,19 +588,22 @@ public function loan(Request $request, Response $response, mysqli $db): Response
}
$dupReservationStmt->close();

// Revalidate eligibility while holding the user row. The fast check
// before the transaction improves feedback, but an administrator can
// suspend the patron between that check and this INSERT.
$userLockStmt = $db->prepare("SELECT id FROM utenti WHERE id = ? FOR UPDATE");
$userLockStmt->bind_param('i', $utenteId);
$userLockStmt->execute();
$userLockStmt->get_result();
$userLockStmt->close();
if (\App\Support\LoanEligibility::checkUser($db, $utenteId) !== null) {
$db->rollback();
return $this->back($response, ['loan_error' => 'not_eligible']);
}

// Enforce max active loans per user (admin setting; 0 = no limit)
$maxLoans = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'max_active_loans_per_user', '0') ?? 0);
if ($maxLoans > 0) {
// Serialize concurrent loan requests by the SAME user: the per-book
// libri lock taken earlier does not mutually exclude two requests
// for *different* books, so without this both could read the same
// activeCount below the limit and both insert, exceeding it.
// Locking the user row forces them to run one at a time.
$userLockStmt = $db->prepare("SELECT id FROM utenti WHERE id = ? FOR UPDATE");
$userLockStmt->bind_param('i', $utenteId);
$userLockStmt->execute();
$userLockStmt->close();

$cntStmt = $db->prepare("SELECT COUNT(*) FROM prestiti WHERE utente_id = ? AND attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')");
$cntStmt->bind_param('i', $utenteId);
$cntStmt->execute();
Expand Down Expand Up @@ -729,6 +752,18 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo
}
$dupLoanStmt->close();

// Revalidate under the user lock so a concurrent suspension/card
// expiry cannot race the pre-transaction eligibility check.
$userLockStmt = $db->prepare("SELECT id FROM utenti WHERE id = ? FOR UPDATE");
$userLockStmt->bind_param('i', $utenteId);
$userLockStmt->execute();
$userLockStmt->get_result();
$userLockStmt->close();
if (\App\Support\LoanEligibility::checkUser($db, $utenteId) !== null) {
$db->rollback();
return $this->back($response, ['reserve_error' => 'not_eligible']);
}

// Canonical peak-capacity decision (same service as admin create,
// approval, renew and audit), excluding this user defensively.
$capacity = new \App\Services\CapacityService($db);
Expand Down Expand Up @@ -759,7 +794,9 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo

// Recalculate book availability after reservation
$integrity = new \App\Support\DataIntegrity($db);
$integrity->recalculateBookAvailability($libroId, insideTransaction: true);
if (!$integrity->recalculateBookAvailability($libroId, insideTransaction: true)) {
throw new \RuntimeException('Failed to recalculate availability after reservation creation.');
}

$db->commit();
$params = ['reserve_success' => 1];
Expand Down
Loading