Skip to content

Commit 3326929

Browse files
feat: reservation system improvements and availability fixes
## Reservation System - Add ReservationReassignmentService for automatic copy reassignment - Add scripts/check-expired-reservations.php cron job - Fix availability calendar to correctly handle lost/damaged copies - Add 'annullato' and 'scaduto' states to prestiti enum (migrate_0.4.3.sql) ## Updater Improvements - Implement streaming database backup to prevent memory exhaustion - Add shutdown handler to cleanup maintenance mode on fatal errors - Add checkStaleMaintenanceMode() to auto-recover stuck updates - Add memory limit increase for large database updates - Add concurrent update prevention with file locking ## Bug Fixes - Fix JavaScript regex escaping in catalog.php (HEREDOC double-escape) - Fix getBookTotalCopies() fallback logic for lost copies - Fix SQL column references in ReservationReassignmentService ## Other Changes - Add English translations for new Updater log messages - Update MaintenanceService with admin login hook - Add copy status change logging in CopyController
1 parent c3dfc83 commit 3326929

File tree

18 files changed

+1235
-158
lines changed

18 files changed

+1235
-158
lines changed

app/Controllers/CopyController.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
use Psr\Http\Message\ResponseInterface as Response;
77
use Psr\Http\Message\ServerRequestInterface as Request;
8+
use App\Support\SecureLogger;
89
use mysqli;
910

1011
class CopyController
@@ -131,6 +132,26 @@ public function updateCopy(Request $request, Response $response, mysqli $db, int
131132
$stmt->execute();
132133
$stmt->close();
133134

135+
// Case 2 & 9: Handle Copy Status Changes
136+
try {
137+
$reassignmentService = new \App\Services\ReservationReassignmentService($db);
138+
139+
// Case 2: Copy became unavailable (lost/damaged/etc) -> Reassign any pending reservation
140+
if (in_array($stato, ['perso', 'danneggiato', 'manutenzione', 'in_restauro'])) {
141+
$reassignmentService->reassignOnCopyLost($copyId);
142+
}
143+
// Case 9: Copy became available -> Assign to waiting reservation
144+
elseif ($stato === 'disponibile') {
145+
$reassignmentService->reassignOnReturn($copyId); // reassignOnReturn handles picking a waiting reservation
146+
}
147+
} catch (\Exception $e) {
148+
SecureLogger::error(__('Errore gestione cambio stato copia'), [
149+
'copia_id' => $copyId,
150+
'stato' => $stato,
151+
'error' => $e->getMessage()
152+
]);
153+
}
154+
134155
// Ricalcola disponibilità del libro
135156
$integrity = new \App\Support\DataIntegrity($db);
136157
$integrity->recalculateBookAvailability($libroId);

app/Controllers/LibriController.php

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use mysqli;
77
use Psr\Http\Message\ResponseInterface as Response;
88
use Psr\Http\Message\ServerRequestInterface as Request;
9+
use App\Support\SecureLogger;
910

1011
class LibriController
1112
{
@@ -670,7 +671,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons
670671
$fields['autori_ids'] = array_map('intval', $data['autori_ids'] ?? []);
671672

672673
// Get scraped author bio if available
673-
$scrapedAuthorBio = trim((string)($data['scraped_author_bio'] ?? ''));
674+
$scrapedAuthorBio = trim((string) ($data['scraped_author_bio'] ?? ''));
674675

675676
// Gestione autori nuovi da creare (con controllo duplicati normalizzati)
676677
if (!empty($data['autori_new'])) {
@@ -1195,7 +1196,7 @@ public function update(Request $request, Response $response, mysqli $db, int $id
11951196
}
11961197

11971198
// Get scraped author bio if available (for update method)
1198-
$scrapedAuthorBioUpdate = trim((string)($data['scraped_author_bio'] ?? ''));
1199+
$scrapedAuthorBioUpdate = trim((string) ($data['scraped_author_bio'] ?? ''));
11991200

12001201
// Gestione autori nuovi da creare (con controllo duplicati normalizzati)
12011202
if (!empty($data['autori_new'])) {
@@ -1348,7 +1349,29 @@ public function update(Request $request, Response $response, mysqli $db, int $id
13481349
: $baseInventario;
13491350

13501351
$note = "Copia {$i} di {$newCopieCount}";
1351-
$copyRepo->create($id, $numeroInventario, 'disponibile', $note);
1352+
$newCopyId = $copyRepo->create($id, $numeroInventario, 'disponibile', $note);
1353+
1354+
// Case 1: Reassign pending reservations to this new copy
1355+
try {
1356+
$reassignmentService = new \App\Services\ReservationReassignmentService($db);
1357+
$reassignmentService->reassignOnNewCopy($id, $newCopyId);
1358+
} catch (\Exception $e) {
1359+
SecureLogger::error(__('Riassegnazione prenotazione nuova copia fallita'), [
1360+
'copia_id' => $newCopyId,
1361+
'error' => $e->getMessage()
1362+
]);
1363+
}
1364+
1365+
// Also process waitlist (prenotazioni -> prestiti) as we have more capacity now
1366+
try {
1367+
$reservationManager = new \App\Controllers\ReservationManager($db);
1368+
$reservationManager->processBookAvailability($id);
1369+
} catch (\Exception $e) {
1370+
SecureLogger::error(__('Elaborazione lista attesa fallita'), [
1371+
'libro_id' => $id,
1372+
'error' => $e->getMessage()
1373+
]);
1374+
}
13521375
}
13531376
} elseif ($newCopieCount < $currentCopieCount) {
13541377
// Rimuovi copie in eccesso (solo quelle disponibili, non in prestito)
@@ -1634,6 +1657,25 @@ private function handleCoverUpload(mysqli $db, int $bookId, array $file): void
16341657
public function delete(Request $request, Response $response, mysqli $db, int $id): Response
16351658
{
16361659
// CSRF validated by CsrfMiddleware
1660+
1661+
// Case 10: Prevent deletion if there are active loans/reservations
1662+
$stmt = $db->prepare("
1663+
SELECT COUNT(*) as count
1664+
FROM prestiti
1665+
WHERE libro_id = ?
1666+
AND attivo = 1
1667+
AND stato IN ('in_corso', 'prenotato', 'pendente', 'in_ritardo')
1668+
");
1669+
$stmt->bind_param('i', $id);
1670+
$stmt->execute();
1671+
$count = (int) $stmt->get_result()->fetch_assoc()['count'];
1672+
$stmt->close();
1673+
1674+
if ($count > 0) {
1675+
$_SESSION['error_message'] = __('Impossibile eliminare il libro: ci sono prestiti o prenotazioni attive. Termina prima i prestiti/prenotazioni.');
1676+
return $response->withHeader('Location', '/admin/libri/' . $id)->withStatus(302);
1677+
}
1678+
16371679
$repo = new \App\Models\BookRepository($db);
16381680
$repo->delete($id);
16391681
return $response->withHeader('Location', '/admin/libri')->withStatus(302);

app/Controllers/PrestitiController.php

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Psr\Http\Message\ServerRequestInterface as Request;
99
use App\Support\DataIntegrity;
1010
use App\Support\NotificationService;
11+
use App\Support\SecureLogger;
1112
use Exception;
1213

1314
/**
@@ -110,6 +111,20 @@ public function store(Request $request, Response $response, mysqli $db): Respons
110111
return $response->withHeader('Location', '/admin/prestiti/crea?error=missing_fields')->withStatus(302);
111112
}
112113

114+
// Case 8: Prevent multiple active reservations/loans for the same book by the same user
115+
$dupStmt = $db->prepare("
116+
SELECT id FROM prestiti
117+
WHERE libro_id = ? AND utente_id = ? AND attivo = 1
118+
AND stato IN ('in_corso', 'prenotato', 'pendente', 'in_ritardo')
119+
");
120+
$dupStmt->bind_param('ii', $libro_id, $utente_id);
121+
$dupStmt->execute();
122+
if ($dupStmt->get_result()->num_rows > 0) {
123+
$dupStmt->close();
124+
return $response->withHeader('Location', '/admin/prestiti/crea?error=duplicate_reservation')->withStatus(302);
125+
}
126+
$dupStmt->close();
127+
113128
// Verifica che la data di scadenza sia successiva alla data di prestito
114129
if (strtotime($data_scadenza) <= strtotime($data_prestito)) {
115130
return $response->withHeader('Location', '/admin/prestiti/crea?error=invalid_dates')->withStatus(302);
@@ -377,7 +392,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons
377392
$integrity->validateAndUpdateLoan($newLoanId);
378393
}
379394
} catch (\Throwable $e) {
380-
error_log('DataIntegrity warning (store loan): ' . $e->getMessage());
395+
SecureLogger::warning(__('DataIntegrity warning (store loan)'), ['error' => $e->getMessage()]);
381396
}
382397

383398
// Create in-app notification for new loan
@@ -408,7 +423,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons
408423
);
409424
}
410425
} catch (\Throwable $e) {
411-
error_log('Failed to create loan notification: ' . $e->getMessage());
426+
SecureLogger::warning(__('Notifica prestito fallita'), ['error' => $e->getMessage()]);
412427
}
413428
}
414429

@@ -592,7 +607,7 @@ public function processReturn(Request $request, Response $response, mysqli $db,
592607
// Valida e aggiorna lo stato del prestito
593608
$validationResult = $integrity->validateAndUpdateLoan($id);
594609
if (!$validationResult['success']) {
595-
error_log("Warning: Loan validation failed for loan {$id}: " . $validationResult['message']);
610+
SecureLogger::warning(__('Validazione prestito fallita'), ['loan_id' => $id, 'message' => $validationResult['message']]);
596611
}
597612

598613
// Se copia torna disponibile, gestisci notifiche
@@ -601,7 +616,15 @@ public function processReturn(Request $request, Response $response, mysqli $db,
601616
$notificationService = new NotificationService($db);
602617
$notificationService->notifyWishlistBookAvailability($libro_id);
603618

604-
// Processa prenotazioni attive per questo libro
619+
// Case 3: Reassign returned copy to next waiting reservation (NEW SYSTEM - prestiti table)
620+
try {
621+
$reassignmentService = new \App\Services\ReservationReassignmentService($db);
622+
$reassignmentService->reassignOnReturn($copia_id);
623+
} catch (\Exception $e) {
624+
SecureLogger::error(__('Riassegnazione copia fallita'), ['copia_id' => $copia_id, 'error' => $e->getMessage()]);
625+
}
626+
627+
// Processa prenotazioni attive per questo libro (Future/Scheduled reservations)
605628
$reservationManager = new \App\Controllers\ReservationManager($db);
606629
$reservationManager->processBookAvailability($libro_id);
607630
}
@@ -613,7 +636,7 @@ public function processReturn(Request $request, Response $response, mysqli $db,
613636

614637
} catch (Exception $e) {
615638
$db->rollback();
616-
error_log("Error processing loan return {$id}: " . $e->getMessage());
639+
SecureLogger::error(__('Errore elaborazione restituzione'), ['loan_id' => $id, 'error' => $e->getMessage()]);
617640
if ($redirectTo) {
618641
$separator = strpos($redirectTo, '?') === false ? '?' : '&';
619642
return $response->withHeader('Location', $redirectTo . $separator . 'error=update_failed')->withStatus(302);
@@ -734,7 +757,7 @@ public function renew(Request $request, Response $response, mysqli $db, int $id)
734757
$integrity = new DataIntegrity($db);
735758
$validationResult = $integrity->validateAndUpdateLoan($id);
736759
if (!$validationResult['success']) {
737-
error_log("Warning: Loan validation failed for loan {$id}: " . $validationResult['message']);
760+
SecureLogger::warning(__('Validazione prestito fallita'), ['loan_id' => $id, 'message' => $validationResult['message']]);
738761
}
739762

740763
$db->commit();
@@ -746,7 +769,7 @@ public function renew(Request $request, Response $response, mysqli $db, int $id)
746769

747770
} catch (Exception $e) {
748771
$db->rollback();
749-
error_log("Renewal failed for loan {$id}: " . $e->getMessage());
772+
SecureLogger::error(__('Rinnovo prestito fallito'), ['loan_id' => $id, 'error' => $e->getMessage()]);
750773

751774
$errorUrl = $redirectTo ?? '/admin/prestiti';
752775
$separator = strpos($errorUrl, '?') === false ? '?' : '&';
@@ -852,7 +875,7 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res
852875
} else {
853876
$result = $db->query($sql);
854877
if ($result === false) {
855-
error_log('exportCsv query error: ' . $db->error);
878+
SecureLogger::error(__('Errore export CSV'), ['error' => $db->error]);
856879
}
857880
}
858881
$loans = [];

app/Controllers/ReservationsController.php

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -376,33 +376,43 @@ private function getDateRange($startDate, $endDate)
376376

377377
private function getBookTotalCopies(int $bookId): int
378378
{
379-
// Count only loanable copies from copie table
380-
// Exclude permanently unavailable copies: 'perso' (lost), 'danneggiato' (damaged), 'manutenzione' (maintenance)
381-
// Include 'disponibile' and 'prestato' (currently on loan but will return)
382-
$stmt = $this->db->prepare("
383-
SELECT COUNT(*) as total FROM copie
384-
WHERE libro_id = ?
385-
AND stato NOT IN ('perso', 'danneggiato', 'manutenzione')
386-
");
379+
// First check if ANY copies exist in the copie table for this book
380+
$stmt = $this->db->prepare("SELECT COUNT(*) as total FROM copie WHERE libro_id = ?");
387381
$stmt->bind_param('i', $bookId);
388382
$stmt->execute();
389383
$result = $stmt->get_result();
390384
$row = $result ? $result->fetch_assoc() : null;
391385
$stmt->close();
386+
$totalCopiesExist = (int) ($row['total'] ?? 0);
392387

393-
$total = (int) ($row['total'] ?? 0);
394-
395-
// Fallback: if no copies exist in copie table, check libri.copie_totali as minimum
396-
if ($total === 0) {
397-
$stmt = $this->db->prepare("SELECT GREATEST(IFNULL(copie_totali, 1), 1) AS copie_totali FROM libri WHERE id = ?");
388+
// If copies exist in copie table, count only loanable ones
389+
// Exclude permanently unavailable copies: 'perso' (lost), 'danneggiato' (damaged), 'manutenzione' (maintenance)
390+
// Include 'disponibile' and 'prestato' (currently on loan but will return)
391+
if ($totalCopiesExist > 0) {
392+
$stmt = $this->db->prepare("
393+
SELECT COUNT(*) as total FROM copie
394+
WHERE libro_id = ?
395+
AND stato NOT IN ('perso', 'danneggiato', 'manutenzione')
396+
");
398397
$stmt->bind_param('i', $bookId);
399398
$stmt->execute();
400399
$result = $stmt->get_result();
401400
$row = $result ? $result->fetch_assoc() : null;
402401
$stmt->close();
403-
$total = (int) ($row['copie_totali'] ?? 1);
402+
403+
// Return actual loanable copies (can be 0 if all are lost/damaged)
404+
return (int) ($row['total'] ?? 0);
404405
}
405406

406-
return $total;
407+
// Fallback: if NO copies exist in copie table at all, use libri.copie_totali
408+
// This handles legacy data where copies weren't tracked individually
409+
$stmt = $this->db->prepare("SELECT GREATEST(IFNULL(copie_totali, 1), 1) AS copie_totali FROM libri WHERE id = ?");
410+
$stmt->bind_param('i', $bookId);
411+
$stmt->execute();
412+
$result = $stmt->get_result();
413+
$row = $result ? $result->fetch_assoc() : null;
414+
$stmt->close();
415+
416+
return (int) ($row['copie_totali'] ?? 1);
407417
}
408418
}

0 commit comments

Comments
 (0)