diff --git a/CHANGELOG.md b/CHANGELOG.md index facf34834..388603fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. +## [0.8.7] + +### Security +- **Administrators are told when the server's PHP lacks the fixes for CVE-2026-109618 and CVE-2026-109619** ([#491](https://github.com/fabiodalez-dev/Pinakes/issues/491)). PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, released on 2026-10-21, fix two unauthenticated remote code execution flaws in the PHP runtime. They do not depend on Pinakes and no Pinakes update can fix them: PHP has to be updated on the server. The dashboard and the updates page now show administrators a notice with the running version, the release to move to, the interim `session.upload_progress.enabled = 0`, and that some distributions backport the fixes without changing the version. `php.ini.recommended` turns session upload progress off; Pinakes does not use it. + +### Fixed +- **A loan request stops before a scheduled loan and says how long it can be** ([#490](https://github.com/fabiodalez-dev/Pinakes/pull/490)). When the only copy has a scheduled loan or a reservation inside the default loan window, the book page proposed a return date that fell on a blocked day and the request was refused with a bare "no copies available". Every surface that creates or edits a loan window (book page, user area, Mobile API, admin create, approval, date edit, admin reservations) now caps the window at the day before the conflict and says, in the reader's language, the longest possible loan and the day the book is booked from. +- **A renewal says until when it is still possible, and can renew until that day** ([#493](https://github.com/fabiodalez-dev/Pinakes/pull/493)). A renewal that crossed a scheduled loan on every copy was refused outright on the admin page, the bulk extension, the Mobile API and NCIP RenewItem. It now names the furthest possible due date and how many days that is, the loans list and the book page offer "Rinnova fino al D", and `POST /staff/loans/{id}/renew` accepts a `new_due_date`. +- **Copies received through Desiderata get the book's own inventory prefix** ([#489](https://github.com/fabiodalez-dev/Pinakes/issues/489), [#494](https://github.com/fabiodalez-dev/Pinakes/pull/494)). The plugin built them on `LIB-` and ignored "Prefisso inventario copie", and an inventory code cannot be changed afterwards. One helper, `CopyRepository::inventoryBase()`, now gives the rule to Desiderata, book creation, the copies form, the increase-copies endpoint and the Mobile API. Existing codes are not renamed. +- **`storage/digital` keeps its group write bits after an upgrade.** At the end of every upgrade the updater reset every folder under `storage/` to 0755, so an installation already on 0.8.6 lost the group write bits of `storage/digital` that the maintenance cron needs when it runs as another user than the web server. The updater now gives them back, and the first request after upgrading to 0.8.7 repairs a folder the previous updater already reset. +- **The "Whoever has the book on loan" visibility says that authorized users open the file too** ([#492](https://github.com/fabiodalez-dev/Pinakes/pull/492)). The access rule was already so; only the description in the Digital Library settings left them out. + +Plugin versions: Desiderata 1.2.1 and Mobile API 1.8.2, both requiring 0.8.7 (they call `CopyRepository::inventoryBase()`). No migration. + ## [0.8.6] ### Added diff --git a/README.md b/README.md index fecf27900..7a439e9ca 100644 --- a/README.md +++ b/README.md @@ -25,22 +25,17 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri [![Documentation](https://img.shields.io/badge/Documentazione-Docsify-4285f4?style=for-the-badge&logo=readthedocs&logoColor=white)](https://fabiodalez-dev.github.io/Pinakes/) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/fabiodalez) -## What's New in v0.8.6 +## What's New in v0.8.7 -**Who may open each digital file, files out of the web root, and custom roles.** +**A security notice for PHP, loan windows that explain themselves, and Desiderata codes that follow the book.** -- **Visibility per Digital Library file** ([#480](https://github.com/fabiodalez-dev/Pinakes/issues/480)): public, signed-in readers, authorised staff, or only the reader who has the book on loan. Every download passes through Pinakes, also from the Android app with signed links (Mobile API 1.8.x). -- **Files out of the web root**: new uploads live in `storage/digital/`; files of older versions move there on their own, at the same addresses. -- **Custom roles and a permission matrix** (Administration → Roles and permissions), with three editable presets: Catalogatore, Addetto prestiti, Archivista. -- **Upgrade heals every older `public/.htaccess`** ([#483](https://github.com/fabiodalez-dev/Pinakes/pull/483)): private uploads are checked on installations first set up before 0.7.71 too; edited rules are left alone and a backup is kept. -- **Older Digital Library links download again** ([#484](https://github.com/fabiodalez-dev/Pinakes/pull/484)), and cataloguing roles can use the FRBR lookups of the book editor. -- **A loan request defaults to the configured loan duration** ([#485](https://github.com/fabiodalez-dev/Pinakes/pull/485)) instead of one day; the Mobile API calendar reports `loan_duration_days`. -- **Lighter public pages** ([#486](https://github.com/fabiodalez-dev/Pinakes/pull/486)): the translation table is a cached script instead of being inlined in every page; sitemap and meta tags cleaned up. -- **The mail template editor says which mail an approved loan sends** ([#487](https://github.com/fabiodalez-dev/Pinakes/pull/487)). +- **PHP security notice** ([#491](https://github.com/fabiodalez-dev/Pinakes/issues/491)): CVE-2026-109618 and CVE-2026-109619 are in the PHP runtime, not in Pinakes: they are fixed upstream in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, and some distributions backport the fixes to older version numbers, so check your provider's packages too. The dashboard and the updates page tell administrators when their server's PHP is older, with the interim `session.upload_progress.enabled = 0` (in `php.ini` or the PHP-FPM pool, not `.user.ini`). +- **Loan requests stop before a scheduled loan** ([#490](https://github.com/fabiodalez-dev/Pinakes/pull/490)) and say the longest possible loan, on the book page, the user area, the Mobile API and every admin form. +- **Renewals say until when they are possible** ([#493](https://github.com/fabiodalez-dev/Pinakes/pull/493)) and can renew until that day, also from the Mobile API and NCIP. +- **Desiderata copies get the book's inventory prefix** ([#489](https://github.com/fabiodalez-dev/Pinakes/issues/489), [#494](https://github.com/fabiodalez-dev/Pinakes/pull/494)) instead of `LIB-`. +- **Digital Library**: the "Whoever has the book on loan" visibility now says that authorized users open the file too ([#492](https://github.com/fabiodalez-dev/Pinakes/pull/492)). -**Migration:** `migrate_0.8.6-rc.1.sql`, additive: creates `ruoli`, adds `utenti.ruolo_id` and `utenti.accesso_digitale`, seeds the three presets and leaves every existing account as it was. It runs when upgrading from 0.8.5 or earlier; an installation on 0.8.6-rc.1 already has it. - -**Plugins:** no new bundled plugin; Digital Library 1.5.0, Mobile API 1.8.1, OAI-PMH Server 1.1.2. **No manual steps:** the `.htaccess` heal and the move of digital files run on their own after the upgrade. Full details in **[CHANGELOG.md](CHANGELOG.md)**. +**No migration.** Desiderata 1.2.1 and Mobile API 1.8.2. Full details in **[CHANGELOG.md](CHANGELOG.md)**. --- @@ -66,6 +61,10 @@ The showcase contains 50 catalogue books, 8 desiderata, 3 periodicals with 10 is Highlights of the 0.8 releases are below. The full version-by-version history lives in **[CHANGELOG.md](CHANGELOG.md)**. +### v0.8.6 + +**Who may open each digital file, files out of the web root, and custom roles.** Every Digital Library attachment can be public, for signed-in readers, for authorised staff, or for the current borrower (authorised users open every file too), and every download passes through Pinakes, also from the app with signed links; administrators can create roles with exactly the permissions they need ([#480](https://github.com/fabiodalez-dev/Pinakes/issues/480)). The upgrade heals every older `public/.htaccess`, keeps files uploaded on older releases working, and public pages are much lighter. One additive migration (`migrate_0.8.6-rc.1.sql`); see **[CHANGELOG.md](CHANGELOG.md)**. + ### v0.8.5 **An admin area in the Android app, an offline catalogue and a private library calendar.** Admin and staff accounts can catalogue, lend, return and manage plugin content from the app through the same pipeline as the web forms (Mobile API 1.7.0); the app keeps an offline copy of the catalogue in sync and readers can subscribe to a calendar of their own loans. The anonymous library calendar no longer lists borrowers' names and emails, renaming a series by changing only its case no longer deletes it, and an installable update reads in green again on the updates page. No core migration; see **[CHANGELOG.md](CHANGELOG.md)**. diff --git a/app/Controllers/CopyController.php b/app/Controllers/CopyController.php index b9c53ffc3..7f5b655af 100644 --- a/app/Controllers/CopyController.php +++ b/app/Controllers/CopyController.php @@ -430,7 +430,7 @@ public function createCopy(Request $request, Response $response, mysqli $db, int // Re-evaluate after sanitisation: an explicit value made only of // control characters must fall back to automatic allocation. if ($numero === '') { - $base = !empty($book['numero_inventario']) ? (string) $book['numero_inventario'] : "LIB-{$bookId}"; + $base = CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); $newCopyIds = $repo->createManyForBookWithIdsAndNote( $bookId, $base, diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index d1c6c19eb..b96c18bbd 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -911,8 +911,8 @@ static function () use ($db, $book_id): array { // default to max_loan_duration_days, so expose the same effective value // to the view instead of hardcoding one calendar month in JavaScript. $loanSettings = new \App\Models\SettingsRepository($db); - $maxRequestDays = max(1, (int) ($loanSettings->get('loans', 'max_loan_duration_days', '90') ?? 90)); - $defaultRequestLoanDays = min($loanSettings->loanDurationDays(), $maxRequestDays); + $maxRequestDays = $loanSettings->maxLoanDurationDays(); + $defaultRequestLoanDays = $loanSettings->requestLoanDurationDays(); // Check whether the BIBFRAME Linked Data plugin is active. // Done before template include so the view can use $bibframePluginActive. diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index 1025d5cfc..dacbdf7c5 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -1335,9 +1335,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons // Genera copie fisiche del libro $copyRepo = new \App\Models\CopyRepository($db); $copieTotali = (int) $fields['copie_totali']; - $baseInventario = !empty($fields['numero_inventario']) - ? $fields['numero_inventario'] - : "LIB-{$id}"; + $baseInventario = \App\Models\CopyRepository::inventoryBase($id, $fields['numero_inventario'] ?? null); // Create the requested holding set with one atomic multi-row INSERT: // a request for three copies must never leave a partial 1/3 or 2/3 diff --git a/app/Controllers/LoanApprovalController.php b/app/Controllers/LoanApprovalController.php index ed1ea6a53..b1f672982 100644 --- a/app/Controllers/LoanApprovalController.php +++ b/app/Controllers/LoanApprovalController.php @@ -428,10 +428,24 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R $capacity = new \App\Services\CapacityService($db); if (!$capacity->hasFreeCapacity($libroId, $today, $dataScadenza, excludePrestitoId: $loanId)) { $db->rollback(); + // Name the longest loan still possible from today when a + // later commitment is what blocks the approval, so staff + // can shorten the request instead of waiting for a return. + // The capacity check starts at today (a fully occupied + // earlier day means the loan would depend on a preceding + // return), but the loan itself starts on data_prestito: + // the figures count from there, and a conflict before it + // cannot be fixed by shortening, so no figures then. + $blocked = array_filter([$capacity->firstUnavailableDateInRange($libroId, $today, $dataScadenza, null, $loanId)]); + $cap = ($blocked !== [] && min($blocked) > $dataPrestito) + ? \App\Support\LoanWindow::cap($dataPrestito, $blocked) + : null; $response->getBody()->write(json_encode([ 'success' => false, - 'message' => __('Nessuna copia disponibile per il periodo richiesto') - ])); + 'message' => $cap === null + ? __('Nessuna copia disponibile per il periodo richiesto') + : \App\Support\LoanWindow::explain($cap['max_duration_days'], $cap['max_end_date'], $cap['first_conflict_date']), + ] + ($cap ?? []))); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index a15e15b7b..28cdc5644 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -22,6 +22,20 @@ */ class PrestitiController { + /** + * The create-form redirect for a window with no free copy on every day. + * When the start day is free, the query also carries the longest loan + * still possible (LoanWindow::redirectParams()), so the form can say + * "maximum N days, booked from D" instead of asking staff to wait for a + * return that is not what blocks them. + */ + private function noCapacityRedirect($response, \App\Services\CapacityService $capacity, int $libroId, string $start, string $end) + { + $blocked = array_filter([$capacity->firstUnavailableDateInRange($libroId, $start, $end)]); + $query = http_build_query(['error' => 'no_copies_available'] + \App\Support\LoanWindow::redirectParams($start, $blocked)); + return $response->withHeader('Location', url('/admin/loans/create') . '?' . $query)->withStatus(302); + } + /** * Display the loans list page with filtering and pending loans widget * @@ -480,7 +494,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons $capacity = new \App\Services\CapacityService($db); if (!$capacity->hasFreeCapacity($libro_id, $data_prestito, $data_scadenza)) { $db->rollback(); - return $response->withHeader('Location', url('/admin/loans/create') . '?error=no_copies_available')->withStatus(302); + return $this->noCapacityRedirect($response, $capacity, $libro_id, $data_prestito, $data_scadenza); } $selectedCopy = ['id' => $forcedCopyId]; @@ -494,7 +508,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons $capacity = new \App\Services\CapacityService($db); if (!$capacity->hasFreeCapacity($libro_id, $data_prestito, $data_scadenza)) { $db->rollback(); - return $response->withHeader('Location', url('/admin/loans/create') . '?error=no_copies_available')->withStatus(302); + return $this->noCapacityRedirect($response, $capacity, $libro_id, $data_prestito, $data_scadenza); } // Pick a physical copy free for the whole interval. A copy may be @@ -1100,9 +1114,20 @@ public function update(Request $request, Response $response, mysqli $db, int $id } $capacity = new \App\Services\CapacityService($db); foreach ($claimedWindows as [$claimStart, $claimEnd]) { + // Only a later due date can be shortened meaningfully, and its + // figures count from the loan's own start. + $isExtension = $claimEnd === $newScadenza && $claimStart > $oldScadenza; if (!$capacity->hasFreeCapacity($libroId, $claimStart, $claimEnd, excludePrestitoId: $id)) { $db->rollback(); - return $response->withHeader('Location', url('/admin/loans') . '?error=no_copies_available')->withStatus(302); + // The loans list explains the refusal with the longest + // loan still possible from its start (LoanWindow). + $explained = []; + if ($isExtension) { + $blocked = array_filter([$capacity->firstUnavailableDateInRange($libroId, $claimStart, $claimEnd, null, $id)]); + $explained = \App\Support\LoanWindow::redirectParams($newPrestito, $blocked); + } + $query = http_build_query(['error' => 'no_copies_available'] + $explained); + return $response->withHeader('Location', url('/admin/loans') . '?' . $query)->withStatus(302); } } @@ -1968,6 +1993,7 @@ public function bulkExtend(Request $request, Response $response, mysqli $db): Re // Before-snapshot inside the lock, so the audit diff shows the // pre-extension due date (same event renew() records). $activityBefore = \App\Support\ActivityLog::loadLoanSnapshot($db, (int) $loan['id']); + $conflict = []; $applied = $this->applyBulkLoanExtension( $loan, $days, @@ -1975,13 +2001,16 @@ public function bulkExtend(Request $request, Response $response, mysqli $db): Re $capacity, $copyOverlap, $update, - $expectedBookByLoan + $expectedBookByLoan, + $conflict ); if ($applied === null) { $copyOverlap->close(); $update->close(); $db->rollback(); - return $response->withHeader('Location', $backUrl . '?error=bulk_extend_conflict')->withStatus(302); + // Which loan stopped the batch, and how far it could go (LoanWindow). + $query = http_build_query(['error' => 'bulk_extend_conflict', 'loan_id' => (int) $loan['id']] + $conflict); + return $response->withHeader('Location', $backUrl . '?' . $query)->withStatus(302); } if ($applied > 0) { \App\Support\ActivityLog::recordLoanEvent($db, (int) $loan['id'], 'loan.renewed', $activityBefore, source: 'manual'); @@ -2026,6 +2055,8 @@ public function bulkExtend(Request $request, Response $response, mysqli $db): Re * * @param array $loan locked prestiti row * @param array $expectedBookByLoan loan id -> book id snapshot taken before locking + * @param array $conflict Out: on a null return, the + * longest extension still possible (LoanWindow::renewalRedirectParams()). * @return int|null affected row count (0/1), or null on a capacity/copy conflict (caller rolls back) */ private function applyBulkLoanExtension( @@ -2035,7 +2066,8 @@ private function applyBulkLoanExtension( \App\Services\CapacityService $capacity, \mysqli_stmt $copyOverlap, \mysqli_stmt $update, - array $expectedBookByLoan + array $expectedBookByLoan, + array &$conflict = [] ): ?int { $loanId = (int) $loan['id']; $bookId = (int) $loan['libro_id']; @@ -2064,15 +2096,21 @@ private function applyBulkLoanExtension( // previously scanned the whole loan window while capacity scanned the // extension window: two different intervals for one decision (CodeRabbit). $extensionStart = $dueDate->modify('+1 day')->format('Y-m-d'); + $currentDue = $dueDate->format('Y-m-d'); + $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; if (!$capacity->hasFreeCapacity($bookId, $extensionStart, $newDueDate, excludePrestitoId: $loanId)) { + $blocked = $capacity->renewalConflictDates($bookId, $loanId, $copyId, $currentDue, $newDueDate); + $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked, $base->format('Y-m-d')); return null; } - $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; if ($copyId !== null) { $copyOverlap->bind_param('iisss', $copyId, $loanId, $newDueDate, $today, $extensionStart); $copyOverlap->execute(); if ((bool) $copyOverlap->get_result()->fetch_row()) { + // The loan's own copy is needed by a later loan: name that day. + $blocked = $capacity->renewalConflictDates($bookId, $loanId, $copyId, $currentDue, $newDueDate); + $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked, $base->format('Y-m-d')); return null; } } @@ -2184,6 +2222,22 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // Calculate proposed new due date for conflict checking $currentDueDate = $loan['data_scadenza']; $proposedNewDueDate = date('Y-m-d', strtotime($currentDueDate . " +{$renewDays} days")); + // Optional shorter renewal: the day before a scheduled loan or a + // reservation, as the refusal below proposes ("Rinnova fino al D"). + // A real day, after the current due date, never past the standard + // renewal. + $rawDue = $data['new_due_date'] ?? ''; + $requestedDue = is_string($rawDue) ? trim($rawDue) : ''; + if (!is_string($rawDue) || ($requestedDue !== '' && !\App\Support\DateHelper::isISODateFormat($requestedDue))) { + $errorUrl = $redirectTo ?? url('/admin/loans'); + $separator = strpos($errorUrl, '?') === false ? '?' : '&'; + return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_date_format'))->withStatus(302); + } + if ($requestedDue !== '' && ($requestedDue <= (string) $currentDueDate || $requestedDue > $proposedNewDueDate)) { + $errorUrl = $redirectTo ?? url('/admin/loans'); + $separator = strpos($errorUrl, '?') === false ? '?' : '&'; + return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_renewal_date'))->withStatus(302); + } $libroId = (int) $loan['libro_id']; // Start transaction BEFORE conflict check to prevent race conditions @@ -2255,6 +2309,16 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // Use the locked values for calculations (in case they changed) $currentDueDate = $lockedLoan['data_scadenza']; $proposedNewDueDate = date('Y-m-d', strtotime($currentDueDate . " +{$renewDays} days")); + if ($requestedDue !== '') { + // Re-bound under the lock: the due date may have moved meanwhile. + if ($requestedDue <= (string) $currentDueDate || $requestedDue > $proposedNewDueDate) { + $db->rollback(); + $errorUrl = $redirectTo ?? url('/admin/loans'); + $separator = strpos($errorUrl, '?') === false ? '?' : '&'; + return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_renewal_date'))->withStatus(302); + } + $proposedNewDueDate = $requestedDue; + } $currentRenewals = (int) $lockedLoan['renewals']; // Refresh libro_id from locked loan to ensure consistency (la riga `libri` è // già stata lockata sopra con lo stesso id all'inizio della transazione). @@ -2270,13 +2334,21 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // that day and bounced renewals over commitments that only touch it. $extensionStart = date('Y-m-d', strtotime($currentDueDate . ' +1 day')); $extensionEnd = $proposedNewDueDate; + $copiaId = $lockedLoan['copia_id'] !== null ? (int) $lockedLoan['copia_id'] : null; $capacity = new \App\Services\CapacityService($db); + // A refusal names the longest renewal still possible (LoanWindow), + // and which loan it is about, so the page can offer "renew until D". + $explainRefusal = static function (array $blocked) use ($id, $currentDueDate): string { + return http_build_query(['error' => 'extension_conflicts', 'loan_id' => $id] + + \App\Support\LoanWindow::renewalRedirectParams((string) $currentDueDate, $blocked)); + }; if (!$capacity->hasFreeCapacity($libroId, $extensionStart, $extensionEnd, excludePrestitoId: $id)) { + $blocked = $capacity->renewalConflictDates($libroId, $id, $copiaId, (string) $currentDueDate, $extensionEnd); $db->rollback(); $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; - return $response->withHeader('Location', url($errorUrl . $separator . 'error=extension_conflicts'))->withStatus(302); + return $response->withHeader('Location', url($errorUrl . $separator . $explainRefusal($blocked)))->withStatus(302); } // Overlap-check sulla COPIA del prestito rinnovato (M5c): il check di @@ -2286,7 +2358,6 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // escludendo il prestito corrente: senza questo check l'UPDATE farebbe // scattare il SIGNAL del trigger → catch-all → errore generico invece // del conflitto di estensione dedicato. - $copiaId = $lockedLoan['copia_id'] !== null ? (int) $lockedLoan['copia_id'] : null; if ($copiaId !== null) { $copyOvlStmt = $db->prepare(" SELECT 1 FROM prestiti @@ -2304,10 +2375,12 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $hasCopyOverlap = (bool) $copyOvlStmt->get_result()->fetch_row(); $copyOvlStmt->close(); if ($hasCopyOverlap) { + $blocked = $capacity->renewalConflictDates($libroId, $id, $copiaId, (string) $currentDueDate, $extensionEnd); $db->rollback(); + // The loan's own copy is needed by a later loan: name that day. $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; - return $response->withHeader('Location', url($errorUrl . $separator . 'error=extension_conflicts'))->withStatus(302); + return $response->withHeader('Location', url($errorUrl . $separator . $explainRefusal($blocked)))->withStatus(302); } } diff --git a/app/Controllers/ReservationsAdminController.php b/app/Controllers/ReservationsAdminController.php index c13b84a77..9c89c3aba 100644 --- a/app/Controllers/ReservationsAdminController.php +++ b/app/Controllers/ReservationsAdminController.php @@ -291,7 +291,9 @@ public function update(Request $request, Response $response, mysqli $db, int $id } if (!$capacity->hasFreeCapacity($libroId, $dataInizioRichiesta, $dataFineRichiesta, excludeReservationId: $id, excludeUserId: $utenteId)) { $db->rollback(); - return $response->withHeader('Location', url('/admin/reservations/edit/' . $id) . '?error=capacity_full')->withStatus(302); + $blocked = array_filter([$capacity->firstUnavailableDateInRange($libroId, $dataInizioRichiesta, $dataFineRichiesta, $utenteId, null, $id)]); + $query = http_build_query(['error' => 'capacity_full'] + \App\Support\LoanWindow::redirectParams($dataInizioRichiesta, $blocked)); + return $response->withHeader('Location', url('/admin/reservations/edit/' . $id) . '?' . $query)->withStatus(302); } } @@ -604,7 +606,11 @@ public function store(Request $request, Response $response, mysqli $db): Respons } if (!$capacity->hasFreeCapacity($libroId, $dataInizioRichiesta, $dataFineRichiesta, excludeUserId: $utenteId)) { $db->rollback(); - return $response->withHeader('Location', url('/admin/reservations/create') . '?error=capacity_full')->withStatus(302); + // The form explains the refusal with the longest window still + // possible from the requested start (LoanWindow). + $blocked = array_filter([$capacity->firstUnavailableDateInRange($libroId, $dataInizioRichiesta, $dataFineRichiesta, $utenteId)]); + $query = http_build_query(['error' => 'capacity_full'] + \App\Support\LoanWindow::redirectParams($dataInizioRichiesta, $blocked)); + return $response->withHeader('Location', url('/admin/reservations/create') . '?' . $query)->withStatus(302); } $stmt = $db->prepare("SELECT COALESCE(MAX(queue_position), 0) + 1 as position FROM prenotazioni WHERE libro_id = ? AND stato = 'attiva'"); diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php index 467fd97b1..eb30a05e3 100644 --- a/app/Controllers/ReservationsController.php +++ b/app/Controllers/ReservationsController.php @@ -297,18 +297,12 @@ public function createReservation($request, $response, $args) // Configurable duration cap (anti-DoS): applies both to an explicit // end_date and to the default end computed from loan_duration_days. - $maxDurationDays = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'max_loan_duration_days', '90') ?? 90); - if ($maxDurationDays < 1) { - $maxDurationDays = 90; - } + $settings = new \App\Models\SettingsRepository($this->db); + $maxDurationDays = $settings->maxLoanDurationDays(); // If no end date specified, set it using the configured loan duration (fallback: 30 days) if (!$endDate) { - $loanDays = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'loan_duration_days', '30') ?? 30); - if ($loanDays < 1) { - $loanDays = 30; - } - $loanDays = min($loanDays, $maxDurationDays); + $loanDays = $settings->requestLoanDurationDays(); $endDateTime = new DateTime($startDate); $endDateTime->modify("+{$loanDays} days"); $endDate = $endDateTime->format('Y-m-d'); @@ -330,11 +324,7 @@ public function createReservation($request, $response, $args) if (!$capacity->hasFreeCapacity($bookId, $startDate, $endDate, excludeUserId: $userId)) { $conflictDates = $this->capacityConflictDates($capacity, $bookId, $requestedDates, $userId); - $response->getBody()->write(json_encode([ - 'success' => false, - 'message' => __('Nessuna copia disponibile nelle date richieste'), - 'conflict_dates' => $conflictDates - ])); + $response->getBody()->write(json_encode($this->capacityConflictPayload($startDate, $conflictDates))); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } @@ -406,11 +396,7 @@ public function createReservation($request, $response, $args) if (!$capacity->hasFreeCapacity($bookId, $startDate, $endDate, excludeUserId: $userId)) { $postLockConflicts = $this->capacityConflictDates($capacity, $bookId, $requestedDates, $userId); $this->db->rollback(); - $response->getBody()->write(json_encode([ - 'success' => false, - 'message' => __('Nessuna copia disponibile nelle date richieste'), - 'conflict_dates' => $postLockConflicts - ])); + $response->getBody()->write(json_encode($this->capacityConflictPayload($startDate, $postLockConflicts))); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } @@ -707,7 +693,48 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int $reservationsResult = $stmt->get_result(); $existingReservations = $reservationsResult->fetch_all(MYSQLI_ASSOC); - return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId); + // The durations every calendar proposes with (public book page, admin + // loan and reservation forms, the app): the configured default, capped + // by the maximum the request endpoints enforce, from one source. + $settings = new \App\Models\SettingsRepository($this->db); + return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId) + [ + 'loan_duration_days' => $settings->requestLoanDurationDays(), + 'max_loan_duration_days' => $settings->maxLoanDurationDays(), + ]; + } + + /** + * The 400 body for a request whose window runs into a day without a free + * copy. When the start day itself is free, the first blocked day bounds the + * loan: the message then names the longest loan still possible from that + * start ("maximum N days, the book is already booked from D") instead of + * the bare "no copies on the requested dates", and `max_end_date` / + * `max_duration_days` let a client propose that return date. When the + * start day is the blocked one there is nothing to shorten, so the generic + * message stays. The figures and the wording come from LoanWindow, shared + * with the user-area forms, the Mobile API and the admin loan form. + * + * @param string[] $conflictDates + * @return array + */ + private function capacityConflictPayload(string $startDate, array $conflictDates): array + { + $payload = [ + 'success' => false, + 'error' => 'no_capacity', + 'message' => __('Nessuna copia disponibile nelle date richieste'), + 'conflict_dates' => $conflictDates, + ]; + $cap = \App\Support\LoanWindow::cap($startDate, $conflictDates); + if ($cap === null) { + return $payload; + } + $payload['message'] = \App\Support\LoanWindow::explain( + $cap['max_duration_days'], + $cap['max_end_date'], + $cap['first_conflict_date'] + ); + return $payload + $cap; } /** diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php index 2ca3b3510..ae6b51eae 100644 --- a/app/Controllers/UserActionsController.php +++ b/app/Controllers/UserActionsController.php @@ -561,8 +561,7 @@ public function changeReservationDate(Request $request, Response $response, mysq $uid = (int) $user['id']; $startDate = $date; - $loanDays = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'loan_duration_days', '30') ?? 30); - $loanDays = $loanDays > 0 ? $loanDays : 30; + $loanDays = (new \App\Models\SettingsRepository($db))->requestLoanDurationDays(); $endDate = (new \DateTimeImmutable($date))->modify("+{$loanDays} days")->format('Y-m-d'); $db->begin_transaction(); @@ -614,7 +613,12 @@ public function changeReservationDate(Request $request, Response $response, mysq $capacity = new \App\Services\CapacityService($db); if (!$capacity->hasFreeCapacity($libroId, $startDate, $endDate, excludeReservationId: $rid, excludeUserId: $uid)) { $db->rollback(); - return $response->withHeader('Location', RouteTranslator::route('reservations') . '?error=not_available')->withStatus(302); + // Say how long a loan from the new date could be (LoanWindow), + // as every other capacity refusal does. excludeUserId leaves + // out the moved row itself, which belongs to this user. + $blocked = $capacity->unavailableDatesInRange($libroId, $startDate, $endDate, $uid, null, $rid); + $query = http_build_query(['error' => 'not_available'] + \App\Support\LoanWindow::redirectParams($startDate, $blocked)); + return $response->withHeader('Location', RouteTranslator::route('reservations') . '?' . $query)->withStatus(302); } // Update reservation with new dates @@ -714,12 +718,22 @@ public function loan(Request $request, Response $response, mysqli $db): Response // library runs in a local zone, so a raw date('Y-m-d') near midnight // would date the request to the wrong day. $data_prestito = \App\Support\DateHelper::today(); - // Read configured loan duration; fallback to 30 days - $loanDays = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'loan_duration_days', '30') ?? 30); - if ($loanDays < 1) { - $loanDays = 30; - } + $loanDays = (new \App\Models\SettingsRepository($db))->requestLoanDurationDays(); $data_scadenza = date('Y-m-d', strtotime($data_prestito . " +{$loanDays} days")); + // Optional return date (Mobile API 1.8.2): the app proposes the day + // before a scheduled loan or reservation when the default would cross + // it, exactly as the website calendar does. Bounded like the public + // endpoint: a real day, not before the start, within the maximum. + $endOverride = $this->requestedEndDate($db, $data, $data_prestito); + if ($endOverride === false) { + return $this->back($response, ['loan_error' => 'invalid_date']); + } + if ($endOverride === 'max_duration_exceeded') { + return $this->back($response, ['loan_error' => 'max_duration_exceeded']); + } + if ($endOverride !== null) { + $data_scadenza = $endOverride; + } // Use transaction + lock to prevent race conditions $db->begin_transaction(); @@ -741,7 +755,14 @@ public function loan(Request $request, Response $response, mysqli $db): Response // Pass utenteId to exclude their own reservation from blocking the loan request if (!$reservationManager->isBookAvailableForImmediateLoan($libroId, $data_prestito, $data_scadenza, $utenteId)) { $db->rollback(); - return $this->back($response, ['loan_error' => 'not_available']); + // This form has no return-date field, so the reader cannot + // shorten the loan here: say how long it could be and from + // which day the book is already booked (LoanWindow), so the + // page and the Mobile API can explain instead of a bare refusal. + $blocked = (new \App\Services\CapacityService($db)) + ->unavailableDatesInRange($libroId, $data_prestito, $data_scadenza, $utenteId); + return $this->back($response, ['loan_error' => 'not_available'] + + \App\Support\LoanWindow::redirectParams($data_prestito, $blocked)); } // Check for existing active loan from this user for this book: @@ -953,9 +974,19 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo // Calculate date range for availability check $start = ($desired !== '') ? $desired : $today; - $loanDays = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'loan_duration_days', '30') ?? 30); - $loanDays = $loanDays > 0 ? $loanDays : 30; + $loanDays = (new \App\Models\SettingsRepository($db))->requestLoanDurationDays(); $end = (new \DateTimeImmutable($start))->modify("+{$loanDays} days")->format('Y-m-d'); + // Optional return date (Mobile API 1.8.2), bounded as in loan(). + $endOverride = $this->requestedEndDate($db, $data, $start); + if ($endOverride === false) { + return $this->back($response, ['reserve_error' => 'invalid_date']); + } + if ($endOverride === 'max_duration_exceeded') { + return $this->back($response, ['reserve_error' => 'max_duration_exceeded']); + } + if ($endOverride !== null) { + $end = $endOverride; + } // Start transaction for concurrency control $db->begin_transaction(); @@ -1016,7 +1047,9 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo } if (!$capacity->hasFreeCapacity($libroId, $start, $end, excludeUserId: $utenteId)) { $db->rollback(); - return $this->back($response, ['reserve_error' => 'not_available']); + $blocked = $capacity->unavailableDatesInRange($libroId, $start, $end, $utenteId); + return $this->back($response, ['reserve_error' => 'not_available'] + + \App\Support\LoanWindow::redirectParams($start, $blocked)); } // Calculate queue position @@ -1076,6 +1109,33 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo return $this->back($response, ['reserve_error' => 'db']); } + /** + * The optional `end_date` of a loan or reservation request: null when + * absent, false when it is not a real day or precedes the start, the + * string 'max_duration_exceeded' when it runs past max_loan_duration_days, + * else the Y-m-d date to use instead of the configured default. + * + * @param array $data + * @return string|false|null + */ + private function requestedEndDate(mysqli $db, array $data, string $startDate): string|false|null + { + $raw = $data['end_date'] ?? null; + if (!is_scalar($raw)) { + return $raw === null ? null : false; + } + $end = trim((string) $raw); + if ($end === '') { + return null; + } + if (!\App\Support\DateHelper::isISODateFormat($end) || $end < $startDate) { + return false; + } + $maxDays = (new \App\Models\SettingsRepository($db))->maxLoanDurationDays(); + $days = (int) (new \DateTimeImmutable($startDate))->diff(new \DateTimeImmutable($end))->days; + return $days > $maxDays ? 'max_duration_exceeded' : $end; + } + private function back(Response $response, array $params): Response { $qs = http_build_query($params); diff --git a/app/Models/CopyRepository.php b/app/Models/CopyRepository.php index 171d10365..8da299766 100644 --- a/app/Models/CopyRepository.php +++ b/app/Models/CopyRepository.php @@ -152,6 +152,22 @@ public function getById(int $id): ?array return $copia ?: null; } + /** + * Base of the "{base}-C{N}" codes given to new copies of a book: the book's + * own "Prefisso inventario copie" (libri.numero_inventario) when it has + * one, otherwise "LIB-{id}". Every path that allocates copy codes reads the + * rule from here, so a library with its own inventory scheme gets it + * whichever screen, plugin or API created the copy (#489). + * + * $bookPrefix is the raw column/form value. A blank or whitespace-only + * value means "no prefix"; surrounding whitespace is never part of a code. + */ + public static function inventoryBase(int $bookId, mixed $bookPrefix): string + { + $prefix = is_scalar($bookPrefix) ? trim((string) $bookPrefix) : ''; + return $prefix !== '' ? $prefix : "LIB-{$bookId}"; + } + /** * Crea una nuova copia */ diff --git a/app/Models/SettingsRepository.php b/app/Models/SettingsRepository.php index ad3e65471..20764a0a1 100644 --- a/app/Models/SettingsRepository.php +++ b/app/Models/SettingsRepository.php @@ -117,6 +117,19 @@ public function loanDurationDays(): int return $days >= 1 ? $days : 30; } + /** Maximum duration of a reader's request, including an explicit end date. */ + public function maxLoanDurationDays(): int + { + $days = (int) ($this->get('loans', 'max_loan_duration_days', '90') ?? 90); + return $days >= 1 ? $days : 90; + } + + /** Default for reader requests and their calendars; staff loans retain their own duration. */ + public function requestLoanDurationDays(): int + { + return min($this->loanDurationDays(), $this->maxLoanDurationDays()); + } + /** * @return array */ diff --git a/app/Routes/web.php b/app/Routes/web.php index 5b75cdbab..3d5650d08 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -2658,9 +2658,7 @@ // manually-inflated copie_totali and a partial copy set behind. No manual // UPDATE, one transaction, rolled back on any failure. $copyRepo = new \App\Models\CopyRepository($db); - $baseInventario = !empty($book['numero_inventario']) - ? $book['numero_inventario'] - : "LIB-{$bookId}"; + $baseInventario = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); $reassignmentService = null; $reservationManager = null; @@ -2866,7 +2864,12 @@ // reservation on this book — the picker would otherwise show // an all-green calendar that the date-less duplicate guard in // createReservation rejects for every date. - 'has_active_reservation' => $availability['has_active_reservation'] ?? false + 'has_active_reservation' => $availability['has_active_reservation'] ?? false, + // The durations every calendar proposes with (public book + // page, admin loan and reservation forms): the configured + // default, capped by the maximum the request endpoints enforce. + 'loan_duration_days' => $availability['loan_duration_days'] ?? null, + 'max_loan_duration_days' => $availability['max_loan_duration_days'] ?? null ] ]; $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE)); diff --git a/app/Services/CapacityService.php b/app/Services/CapacityService.php index a561c99dd..ae769a7e4 100644 --- a/app/Services/CapacityService.php +++ b/app/Services/CapacityService.php @@ -224,8 +224,14 @@ public function firstAvailableDate(int $libroId, string $from): ?string * * @return list Y-m-d dates lacking capacity, ascending */ - public function unavailableDatesInRange(int $libroId, string $start, string $end, ?int $excludeUserId = null): array - { + public function unavailableDatesInRange( + int $libroId, + string $start, + string $end, + ?int $excludeUserId = null, + ?int $excludePrestitoId = null, + ?int $excludeReservationId = null + ): array { $days = $this->enumerateDays($start, $end); if ($days === []) { return []; @@ -235,9 +241,12 @@ public function unavailableDatesInRange(int $libroId, string $start, string $end return $days; // nothing lendable → every requested day conflicts } + // The same exclusions hasFreeCapacity() takes, so a refusal explained + // from this list (LoanWindow) never counts the row being approved, + // edited or moved as its own conflict. $intervals = array_merge( - $this->holdingLoanIntervals($libroId, $start, $end, null, $excludeUserId), - $this->activeReservationIntervals($libroId, $start, $end, null, $excludeUserId) + $this->holdingLoanIntervals($libroId, $start, $end, $excludePrestitoId, $excludeUserId), + $this->activeReservationIntervals($libroId, $start, $end, $excludeReservationId, $excludeUserId) ); $conflicts = []; @@ -255,6 +264,98 @@ public function unavailableDatesInRange(int $libroId, string $start, string $end return $conflicts; } + /** + * The start of the first commitment on one physical copy that begins after + * $afterDate, excluding one loan: the same rows the per-copy overlap checks + * of renew(), bulkExtend() and NCIP RenewItem count (holding states, and a + * reservation-conversion pending that already holds the copy). With two + * or more copies the book-level gate can pass while the loan's own copy is + * needed by a later loan; this names the day it is needed from. + */ + public function firstCopyCommitmentAfter(int $copiaId, int $excludePrestitoId, string $afterDate): ?string + { + $stmt = $this->db->prepare( + "SELECT MIN(data_prestito) AS first_start FROM prestiti + WHERE copia_id = ? AND id <> ? AND data_prestito > ? + AND ((attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) + OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL))" + ); + $stmt->bind_param('iis', $copiaId, $excludePrestitoId, $afterDate); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + $first = $row['first_start'] ?? null; + + return is_string($first) && $first !== '' ? substr($first, 0, 10) : null; + } + + /** + * Both limits on a renewal: book capacity and commitments on its assigned + * copy. A capacity refusal can occur later than the copy's own conflict; + * the proposed shorter renewal must respect the earlier of the two. + * + * @return list First capacity/copy conflicts inside (currentDue, proposedDue]. + */ + public function renewalConflictDates(int $libroId, int $loanId, ?int $copiaId, string $currentDue, string $proposedDue): array + { + $extensionStart = (new \DateTimeImmutable($currentDue))->modify('+1 day')->format('Y-m-d'); + $blocked = array_filter([$this->firstUnavailableDateInRange($libroId, $extensionStart, $proposedDue, null, $loanId)]); + if ($copiaId !== null) { + $copyFrom = $this->firstCopyCommitmentAfter($copiaId, $loanId, $currentDue); + if ($copyFrom !== null && $copyFrom <= $proposedDue) { + $blocked[] = $copyFrom; + } + } + $blocked = array_values(array_unique($blocked)); + sort($blocked, SORT_STRING); + return $blocked; + } + + /** + * First fully occupied day, without enumerating the requested calendar. + * Staff can request very long ranges: refusal details only need the first + * conflict, so their cost must depend on commitments rather than days. + */ + public function firstUnavailableDateInRange( + int $libroId, + string $start, + string $end, + ?int $excludeUserId = null, + ?int $excludePrestitoId = null, + ?int $excludeReservationId = null + ): ?string { + if ($end < $start) { + return null; + } + $total = $this->totalCopies($libroId); + if ($total <= 0) { + return $start; + } + $intervals = array_merge( + $this->holdingLoanIntervals($libroId, $start, $end, $excludePrestitoId, $excludeUserId), + $this->activeReservationIntervals($libroId, $start, $end, $excludeReservationId, $excludeUserId) + ); + $events = []; + foreach ($intervals as [$from, $to]) { + $events[$from] = ($events[$from] ?? 0) + 1; + // No release event is needed beyond the requested horizon. This + // also avoids computing a five-digit year after 9999-12-31. + if ($to < $end) { + $after = $this->nextDay($to); + $events[$after] = ($events[$after] ?? 0) - 1; + } + } + ksort($events, SORT_STRING); + $occupied = 0; + foreach ($events as $day => $delta) { + $occupied += $delta; + if ($occupied >= $total) { + return $day; + } + } + return null; + } + /** * @return list Y-m-d days from $start to $end inclusive. */ diff --git a/app/Support/LoanWindow.php b/app/Support/LoanWindow.php new file mode 100644 index 000000000..33b55a942 --- /dev/null +++ b/app/Support/LoanWindow.php @@ -0,0 +1,229 @@ + $d > $startDate); + if ($future === []) { + return null; + } + $firstConflict = min($future); + if (in_array($startDate, $conflictDates, true)) { + return null; + } + $maxEnd = (new DateTime($firstConflict))->modify('-1 day')->format('Y-m-d'); + $maxDays = (int) (new DateTime($startDate))->diff(new DateTime($maxEnd))->days; + // The very next day is blocked: no loan of at least one day exists + // from this start, so there is nothing to propose and the caller + // keeps its generic refusal (the calendars say so before sending). + if ($maxDays < 1) { + return null; + } + + return [ + 'first_conflict_date' => $firstConflict, + 'max_end_date' => $maxEnd, + 'max_duration_days' => $maxDays, + ]; + } + + /** + * The explanation for a capped window, in the current locale, with the + * dates laid out as the public calendar shows them. + */ + public static function explain(int $maxDays, string $maxEndDate, string $firstConflictDate): string + { + if ($maxDays === 1) { + return __( + 'Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.', + self::displayDate($maxEndDate), + self::displayDate($firstConflictDate) + ); + } + return __( + 'Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.', + $maxDays, + self::displayDate($maxEndDate), + self::displayDate($firstConflictDate) + ); + } + + /** + * Redirect parameters that carry a cap through a 302 (user-area forms, + * Mobile API delegation). Empty when nothing bounds the window. + * + * @param string[] $conflictDates + * @return array{max_loan_days?: int, max_end_date?: string, blocked_from?: string} + */ + public static function redirectParams(string $startDate, array $conflictDates): array + { + $cap = self::cap($startDate, $conflictDates); + if ($cap === null) { + return []; + } + return [ + 'max_loan_days' => $cap['max_duration_days'], + 'max_end_date' => $cap['max_end_date'], + 'blocked_from' => $cap['first_conflict_date'], + ]; + } + + /** + * The explanation rebuilt from redirect parameters, or null when they are + * absent or not of the shape redirectParams() emits (query strings are + * user-editable: anything else is ignored, never echoed). + * + * @param array $params + */ + public static function explainFromParams(array $params): ?string + { + $days = $params['max_loan_days'] ?? null; + $end = $params['max_end_date'] ?? null; + $from = $params['blocked_from'] ?? null; + if (!is_scalar($days) || !is_string($end) || !is_string($from)) { + return null; + } + if (preg_match('/^\d{1,4}$/', (string) $days) !== 1 + || !DateHelper::isISODateFormat($end) + || !DateHelper::isISODateFormat($from)) { + return null; + } + return self::explain((int) $days, $end, $from); + } + + /** + * The longest renewal still possible when a scheduled loan or a reservation + * on every copy begins inside the extension window. The extension adds the + * days after $currentDue; the first blocked day among them bounds it: the + * new due date can be at most the day before, max_extension_days counts + * from the current due date (0 when the very next day is blocked: no + * renewal is possible, and the sentence says so). + * + * @param string[] $conflictDates Y-m-d days inside (currentDue, proposedDue] + * without a free copy, or the first day of a + * later commitment on the loan's own copy. + * @param string|null $extensionBase Bulk extensions of overdue loans count + * their days from today, not the past due date. + * @return array{first_conflict_date: string, max_due_date: string, max_extension_days: int}|null + * null when nothing bounds the extension. + */ + public static function renewalCap(string $currentDue, array $conflictDates, ?string $extensionBase = null): ?array + { + $future = array_filter($conflictDates, static fn (string $d): bool => $d > $currentDue); + if ($future === []) { + return null; + } + $firstConflict = min($future); + $maxDue = (new DateTime($firstConflict))->modify('-1 day')->format('Y-m-d'); + $base = max($currentDue, $extensionBase ?? $currentDue); + // An existing conflict before today's bulk-extension base leaves no + // extension possible; never propose a due date in the past. + $maxDue = max($base, $maxDue); + $maxDays = (int) (new DateTime($base))->diff(new DateTime($maxDue))->days; + + return [ + 'first_conflict_date' => $firstConflict, + 'max_due_date' => $maxDue, + 'max_extension_days' => $maxDays, + ]; + } + + /** The explanation for a bounded renewal, in the current locale. */ + public static function explainRenewal(int $maxDays, string $maxDueDate, string $firstConflictDate): string + { + if ($maxDays < 1) { + return __('Rinnovo non possibile: dal %s il libro è già prenotato.', self::displayDate($firstConflictDate)); + } + if ($maxDays === 1) { + return __( + 'Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.', + self::displayDate($maxDueDate), + self::displayDate($firstConflictDate) + ); + } + return __( + 'Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.', + $maxDays, + self::displayDate($maxDueDate), + self::displayDate($firstConflictDate) + ); + } + + /** + * Redirect parameters that carry a renewal cap through a 302. Empty when + * nothing bounds the extension. + * + * @param string[] $conflictDates + * @return array{max_extension_days?: int, max_due_date?: string, blocked_from?: string} + */ + public static function renewalRedirectParams(string $currentDue, array $conflictDates, ?string $extensionBase = null): array + { + $cap = self::renewalCap($currentDue, $conflictDates, $extensionBase); + if ($cap === null) { + return []; + } + return [ + 'max_extension_days' => $cap['max_extension_days'], + 'max_due_date' => $cap['max_due_date'], + 'blocked_from' => $cap['first_conflict_date'], + ]; + } + + /** + * The renewal explanation rebuilt from redirect parameters, or null when + * they are absent or malformed (query strings are user-editable). + * + * @param array $params + */ + public static function explainRenewalFromParams(array $params): ?string + { + $days = $params['max_extension_days'] ?? null; + $due = $params['max_due_date'] ?? null; + $from = $params['blocked_from'] ?? null; + if (!is_scalar($days) || !is_string($due) || !is_string($from)) { + return null; + } + if (preg_match('/^\d{1,4}$/', (string) $days) !== 1 + || !DateHelper::isISODateFormat($due) + || !DateHelper::isISODateFormat($from)) { + return null; + } + return self::explainRenewal((int) $days, $due, $from); + } + + /** Format dates consistently in the explanation and its renewal action. */ + public static function displayDate(string $ymd): string + { + $format = str_starts_with(I18n::getLocale(), 'en') ? 'm-d-Y' : 'd-m-Y'; + return (new DateTime($ymd))->format($format); + } +} diff --git a/app/Support/PhpSecurityAdvisory.php b/app/Support/PhpSecurityAdvisory.php new file mode 100644 index 000000000..0f45e5ca8 --- /dev/null +++ b/app/Support/PhpSecurityAdvisory.php @@ -0,0 +1,77 @@ + branch => first release with the fixes */ + public const FIXED = [ + '8.2' => '8.2.35', + '8.3' => '8.3.36', + '8.4' => '8.4.27', + '8.5' => '8.5.12', + ]; + + /** @var list */ + public const CVES = ['CVE-2026-109618', 'CVE-2026-109619']; + + /** + * The PHP 8 changelog. Unlike the php.net home page it does not move as news come + * out, and each release has its own anchor, where the fix is listed by its GitHub + * advisory ids (GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp). + */ + public const ADVISORY_URL = 'https://www.php.net/ChangeLog-8.php'; + + /** The changelog entry of the release that fixes it, e.g. ChangeLog-8.php#8.4.27. */ + public static function changelogUrl(string $fixed): string + { + return self::ADVISORY_URL . '#' . rawurlencode($fixed); + } + + /** + * The release the server should move to, or null when $version has the fixes. + * + * @return array{current: string, fixed: string}|null + */ + public static function check(string $version = PHP_VERSION): ?array + { + // A pre-release (8.4.27RC1, 8.4.27-dev) comes before its final and may lack the + // fix, so its suffix takes part in the comparison; a distribution's own suffix + // (8.4.10-1+0~deb12) does not, and the release number decides. + if (preg_match('/^(\d+)\.(\d+)\.(\d+)((?:-?(?:dev|alpha|beta|RC)\d*)?)/i', $version, $m) !== 1) { + return null; + } + $branch = $m[1] . '.' . $m[2]; + $release = $branch . '.' . $m[3] . $m[4]; + $fixed = self::FIXED[$branch] ?? null; + if ($fixed === null) { + // Not a patched branch: either newer than all of them (fine) or older, + // where the PHP project publishes no fix at all. + $oldest = array_values(self::FIXED)[0]; + return version_compare($release, $oldest, '<') ? ['current' => $version, 'fixed' => $oldest] : null; + } + return version_compare($release, $fixed, '<') ? ['current' => $version, 'fixed' => $fixed] : null; + } +} diff --git a/app/Support/PluginManager.php b/app/Support/PluginManager.php index 402d974ed..59dc3faed 100644 --- a/app/Support/PluginManager.php +++ b/app/Support/PluginManager.php @@ -2335,6 +2335,20 @@ public function loadActivePlugins(): void SecureLogger::error('[PluginManager] public/.htaccess still lets private uploads be served from disk (#480): put "RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [NC,QSA,L]" before the public /uploads/ rule, or restore the shipped file'); } + // Same window, for storage/digital: up to 0.8.6 the updater's fixPermissions() + // set every folder under storage/ to 0755 at the end of an upgrade, so an + // install already on 0.8.6 lost the group write bits of storage/digital, which + // the cron needs when it runs as another user. The OLD updater in memory does + // this upgrade too; ensureDir() gives the bits back (never the "other" ones). + try { + $digitalDir = DigitalStorage::targetDir(); + if (is_dir($digitalDir)) { + DigitalStorage::ensureDir($digitalDir); + } + } catch (\Throwable $e) { + SecureLogger::error('[PluginManager] storage/digital permission heal failed', ['error' => $e->getMessage()]); + } + QueryCache::set($maintenanceKey, 1, 900); } diff --git a/app/Support/Updater.php b/app/Support/Updater.php index b0370011e..0ac058a17 100644 --- a/app/Support/Updater.php +++ b/app/Support/Updater.php @@ -4788,6 +4788,18 @@ private function fixPermissions(): void } } + // The loop above gives every folder under storage/ 0755, storage/digital + // included: give it back the group write bits it is created with (0775), which + // the cron needs when it runs as another user than the web server (#480). + $digitalDir = DigitalStorage::targetDir($this->rootPath); + if (is_dir($digitalDir)) { + try { + DigitalStorage::ensureDir($digitalDir); + } catch (\Throwable $e) { + $this->debugLog('WARNING', 'storage/digital permissions not restored: ' . $e->getMessage()); + } + } + $envFile = $this->rootPath . '/.env'; if (file_exists($envFile)) { chmod($envFile, 0600); diff --git a/app/Views/admin/updates.php b/app/Views/admin/updates.php index a22b6b5b6..9892fcf5c 100644 --- a/app/Views/admin/updates.php +++ b/app/Views/admin/updates.php @@ -49,6 +49,8 @@ class="inline-flex items-center px-6 py-3 bg-gray-100 text-gray-700 rounded-xl h unset($workspaceHeader); ?> + +
diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php index 61c51f8b1..7f5a246ec 100644 --- a/app/Views/dashboard/index.php +++ b/app/Views/dashboard/index.php @@ -33,6 +33,8 @@ unset($workspaceHeader); ?> + +
diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index b70108f4e..f78f079e2 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -21,6 +21,7 @@ * @var array $seriesBooks Other books in the same series (collana) * @var string $collana Series/collection name * @var int $defaultRequestLoanDays Effective configured default duration for loan requests + * @var int $maxRequestDays Longest loan the reservation endpoint accepts (max_loan_duration_days) */ // Check if catalogue-only mode is enabled (hides loans, reservations, wishlist) @@ -685,7 +686,10 @@ class="book-cover-large img-fluid pk-book__img - + + + + @@ -707,7 +711,8 @@ class="book-cover-large img-fluid pk-book__img __('Hai già una prenotazione attiva per questo libro.'), 'invalid_date' => __('Data non valida.'), 'past_date' => __('La data non può essere nel passato.'), - 'not_available' => __('Nessuna copia disponibile per il periodo richiesto.') + 'not_available' => \App\Support\LoanWindow::explainFromParams($_GET) ?? __('Nessuna copia disponibile per il periodo richiesto.'), + 'max_duration_exceeded' => __('Il periodo richiesto supera la durata massima consentita.') ]; // Defense-in-depth: the values are __() translations from // the i18n catalog (app-controlled), and unknown keys fall @@ -1575,6 +1580,7 @@ function setFavUI(isFav) { const alreadyReservedMsg = ; const appToday = ; const defaultRequestLoanDays = ; + const maxRequestLoanDays = ; function addDaysToIso(isoDate, days) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate || ''); @@ -1584,6 +1590,55 @@ function addDaysToIso(isoDate, days) { return date.toISOString().slice(0, 10); } + function daysBetweenIso(fromIso, toIso) { + const parse = (iso) => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || ''); + return m ? Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : NaN; + }; + const a = parse(fromIso); + const b = parse(toIso); + if (Number.isNaN(a) || Number.isNaN(b)) return NaN; + return Math.round((b - a) / 86400000); + } + + // The loan the visitor can still ask for from a given start: the default + // duration, or less when a scheduled loan or a reservation on every copy + // begins before that default return date. The server applies the same + // default when end_date is omitted and rejects a window that crosses a + // blocked day, so without this cap a request sent without touching the + // calendars came back "no copies available" even though a shorter loan + // was possible, and the visitor was never told how much shorter. + // + // Two limits bound the return date: the configured maximum duration and + // the first blocked day, searched through that whole maximum so a manual + // pick can never jump past a conflict to a day that is free again. The + // proposed return date is the default duration, or the day before the + // conflict when that comes first (`capped`, which is what the note under + // the field explains). + function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { + const lookAhead = Math.max(defaultDays, maxDays || defaultDays); + let firstBlocked = null; + for (let i = 1; i <= lookAhead; i++) { + const day = addDaysToIso(startIso, i); + const info = availabilityByDate[day]; + if (info && info.state !== 'free') { + firstBlocked = day; + break; + } + } + const maxEndDate = firstBlocked === null ? addDaysToIso(startIso, lookAhead) : addDaysToIso(firstBlocked, -1); + const defaultEnd = addDaysToIso(startIso, defaultDays); + const capped = firstBlocked !== null && maxEndDate < defaultEnd; + const endDate = capped ? maxEndDate : defaultEnd; + return { + endDate, + maxEndDate, + maxDays: daysBetweenIso(startIso, maxEndDate), + firstBlocked, + capped + }; + } + async function updateReservationsBadge() { const badge = document.getElementById('nav-res-count'); if (!badge) return; @@ -1683,6 +1738,61 @@ function addDaysToIso(isoDate, days) { }; const infoText = __('Le date rosse o arancioni non sono disponibili. La richiesta verrà valutata da un amministratore.'); + // This page's __() is a key-only dictionary (see the override above + // the script): the templates are translated server-side and filled + // here, as successRangeTpl is. + const loanLimitTpl = ; + const loanLimitOneTpl = ; + const noWindowTpl = ; + const blockedDayTpl = ; + const maxDurationText = ; + const endBeforeStartText = ; + const loanLimitText = (loanWindow) => loanWindow.maxDays === 1 + ? loanLimitOneTpl + .replace('%1$s', formatDateIT(loanWindow.maxEndDate)) + .replace('%2$s', formatDateIT(loanWindow.firstBlocked)) + : loanLimitTpl + .replace('%1$d', String(loanWindow.maxDays)) + .replace('%2$s', formatDateIT(loanWindow.maxEndDate)) + .replace('%3$s', formatDateIT(loanWindow.firstBlocked)); + // The next day is blocked: no loan of at least one day from this start. + const noWindowText = (loanWindow) => noWindowTpl.replace('%s', formatDateIT(loanWindow.firstBlocked)); + const windowText = (loanWindow) => loanWindow.maxDays < 1 ? noWindowText(loanWindow) : loanLimitText(loanWindow); + const blockedDayText = (isoDate, state) => blockedDayTpl + .replace('%1$s', formatDateIT(isoDate)) + .replace('%2$s', tooltipTexts[state] || tooltipTexts.borrowed); + const setLimitNote = (text) => { + const note = document.getElementById('swal-loan-limit'); + if (!note) return; + note.textContent = text || ''; + note.hidden = !text; + }; + // Cap the return picker at the day before the first blocked day (or at + // the maximum duration) and say so under the field; from a start with + // no conflict ahead the note disappears. + const applyLoanWindow = (startIso) => { + const loanWindow = loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); + if (endPicker) { + endPicker.set('minDate', startIso); + endPicker.set('maxDate', loanWindow.maxEndDate); + endPicker.setDate(loanWindow.endDate, true); + } + setLimitNote(loanWindow.capped ? windowText(loanWindow) : ''); + return loanWindow; + }; + // A red or orange day cannot be picked; clicking it must still say + // why, and how long a loan from the chosen start can last. + const explainBlockedDay = (isoDate, state) => { + const startIso = (document.getElementById('swal-date-start') || {}).value || ''; + let text = blockedDayText(isoDate, state); + if (/^\d{4}-\d{2}-\d{2}$/.test(startIso)) { + const loanWindow = loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); + if (loanWindow.firstBlocked !== null) { + text += ' ' + windowText(loanWindow); + } + } + setLimitNote(text); + }; let startPicker = null; let endPicker = null; @@ -1698,6 +1808,7 @@ function addDaysToIso(isoDate, days) { `
`+ ``+ ``+ + ``+ `
`+ `

`+ ``+ @@ -1755,6 +1866,7 @@ function addDaysToIso(isoDate, days) { dayElem.classList.add('flatpickr-disabled'); dayElem.setAttribute('aria-disabled', 'true'); dayElem.tabIndex = -1; + dayElem.addEventListener('click', () => explainBlockedDay(isoDate, info.state)); } if (tooltipTexts[info.state]) { dayElem.setAttribute('title', tooltipTexts[info.state]); @@ -1787,11 +1899,7 @@ function addDaysToIso(isoDate, days) { ...baseOpts, onChange: function(selectedDates, dateStr, instance) { if (selectedDates.length > 0 && dateStr) { - const endDate = addDaysToIso(dateStr, defaultRequestLoanDays); - if (endPicker) { - endPicker.setDate(endDate, true); - endPicker.set('minDate', dateStr); - } + applyLoanWindow(dateStr); } } }); @@ -1802,10 +1910,11 @@ function addDaysToIso(isoDate, days) { // baseOpts.defaultDate is the start date: inherited here, it made a // request sent without touching the calendars a one-day loan. The // start picker's onChange only fires on a click, so the default - // return date has to be set here too. - defaultDate: addDaysToIso(suggestedDate, defaultRequestLoanDays), - maxDate: undefined // End date can extend beyond availability range + // return date is set by applyLoanWindow() right below. + defaultDate: undefined, + maxDate: undefined }); + applyLoanWindow(suggestedDate); if (startPicker.altInput) { startPicker.altInput.id = 'swal-date-start-display'; @@ -1831,6 +1940,23 @@ function addDaysToIso(isoDate, days) { Swal.showValidationMessage(__('Seleziona una data di inizio')); return false; } + // A return date past the first blocked day (or no return date, which + // the server fills with the full default) would be refused anyway: + // say how long the loan can be instead of sending it. + if (endDate && endDate < startDate) { + Swal.showValidationMessage(endBeforeStartText); + return false; + } + const loanWindow = loanWindowFrom(startDate, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); + if (loanWindow.firstBlocked !== null && loanWindow.maxDays < 1) { + Swal.showValidationMessage(noWindowText(loanWindow)); + return false; + } + const effectiveEnd = endDate || addDaysToIso(startDate, defaultRequestLoanDays); + if (effectiveEnd > loanWindow.maxEndDate) { + Swal.showValidationMessage(loanWindow.firstBlocked !== null ? loanLimitText(loanWindow) : maxDurationText); + return false; + } return { startDate, endDate }; } }); diff --git a/app/Views/libri/scheda_libro.php b/app/Views/libri/scheda_libro.php index dd0b51d59..4ec0d721c 100644 --- a/app/Views/libri/scheda_libro.php +++ b/app/Views/libri/scheda_libro.php @@ -68,11 +68,38 @@ case 'max_renewals': echo __('Numero massimo di rinnovi raggiunto per questo prestito.'); break; + case 'invalid_date_format': + echo __('Errore: formato data non valido. Inserisci le date nel formato YYYY-MM-DD.'); + break; + case 'invalid_renewal_date': + echo __('La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.'); + break; case 'renewal_conflict': case 'extension_conflicts': // #336: renew() emette 'extension_conflicts' — il vecchio switch conosceva // solo 'renewal_conflict' (mai emesso) e mostrava il messaggio generico. - echo __('Impossibile rinnovare: un altro prestito o prenotazione occupa il periodo richiesto.'); + // Quando un impegno successivo limita il rinnovo, il redirect porta + // fino a quando è ancora possibile (LoanWindow): lo diciamo e lo + // proponiamo con un clic. + $renewalExplained = \App\Support\LoanWindow::explainRenewalFromParams($_GET); + echo htmlspecialchars( + __('Impossibile rinnovare: un altro prestito o prenotazione occupa il periodo richiesto.') + . ($renewalExplained !== null ? ' ' . $renewalExplained : ''), + ENT_QUOTES, + 'UTF-8' + ); + $renewalLoanId = is_scalar($_GET['loan_id'] ?? null) ? (int) $_GET['loan_id'] : 0; + if ($renewalExplained !== null && $renewalLoanId > 0 && (int) ($_GET['max_extension_days'] ?? 0) >= 1) { + $renewalMaxDue = (string) $_GET['max_due_date']; + ?> +

+ + + + +
+ +
diff --git a/app/Views/prenotazioni/crea_prenotazione.php b/app/Views/prenotazioni/crea_prenotazione.php index 60bebef10..61a2e9d53 100644 --- a/app/Views/prenotazioni/crea_prenotazione.php +++ b/app/Views/prenotazioni/crea_prenotazione.php @@ -102,7 +102,7 @@

-

+

@@ -197,7 +197,8 @@ class="w-full px-6 py-5 text-xl border-2 border-gray-300 rounded-2xl focus:ring-

@@ -206,9 +207,11 @@ class="w-full px-6 py-5 text-xl border-2 border-gray-300 rounded-2xl focus:ring-

+
@@ -260,30 +263,120 @@ class="inline-flex items-center px-12 py-4 text-lg bg-gray-900 text-white rounde diff --git a/app/Views/prenotazioni/modifica_prenotazione.php b/app/Views/prenotazioni/modifica_prenotazione.php index 91645c15a..11ec0680b 100644 --- a/app/Views/prenotazioni/modifica_prenotazione.php +++ b/app/Views/prenotazioni/modifica_prenotazione.php @@ -34,7 +34,7 @@ $errorKey = is_scalar($_GET['error']) ? (string) $_GET['error'] : ''; echo match ($errorKey) { 'duplicate' => __('Questo utente ha già un prestito o una prenotazione attiva per questo libro.'), - 'capacity_full' => __('Tutte le copie sono già impegnate (prestiti o prenotazioni) per il periodo richiesto. Scegli un altro periodo.'), + 'capacity_full' => \App\Support\LoanWindow::explainFromParams($_GET) ?? __('Tutte le copie sono già impegnate (prestiti o prenotazioni) per il periodo richiesto. Scegli un altro periodo.'), 'invalid_status' => __('Stato prenotazione non valido: modifica non salvata.'), 'promoted_loan_active' => __('Questa prenotazione è già stata convertita in un prestito ancora aperto: gestisci il prestito dalla pagina Prestiti invece di modificare la prenotazione.'), 'user_not_found', 'user_suspended', 'card_expired' => \App\Support\LoanEligibility::errorMessage($errorKey), diff --git a/app/Views/prestiti/crea_prestito.php b/app/Views/prestiti/crea_prestito.php index c60e15bea..acc43c7ce 100644 --- a/app/Views/prestiti/crea_prestito.php +++ b/app/Views/prestiti/crea_prestito.php @@ -88,7 +88,14 @@ echo __('La finestra richiesta è già trascorsa: aggiorna le date del prestito prima di approvarlo'); break; case 'no_copies_available': - echo __('Tutte le copie di questo libro hanno già un prestito attivo o prenotato. Attendi che una copia venga restituita.'); + // A window that runs into a scheduled loan or a reservation: name + // the longest loan still possible instead of "wait for a return". + echo htmlspecialchars( + \App\Support\LoanWindow::explainFromParams($_GET) + ?? __('Tutte le copie di questo libro hanno già un prestito attivo o prenotato. Attendi che una copia venga restituita.'), + ENT_QUOTES, + 'UTF-8' + ); break; case 'duplicate_reservation': echo __('Questo utente ha già un prestito o una prenotazione attiva per questo libro.'); @@ -213,6 +220,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te
+
@@ -399,7 +407,10 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-gray-800 focus:ring-gray-500" copyResolved: , copyNotFound: , copyNotAvailable: , - borrowerAlreadyReserved: + borrowerAlreadyReserved: , + loanLimit: , + loanLimitOne: , + noWindow: }; // F043: base della route di disponibilità risolta per il locale attivo, @@ -408,6 +419,9 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-gray-800 focus:ring-gray-500" const API_BOOK_BASE = ; const appToday = ; const defaultLoanDays = ; + // Staff use the configured duration; availability.loan_duration_days is + // the reader default, which may be shortened by the reader's maximum. + const effectiveLoanDays = defaultLoanDays; function addDaysToIso(isoDate, days) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate || ''); @@ -417,6 +431,85 @@ function addDaysToIso(isoDate, days) { return date.toISOString().slice(0, 10); } + function daysBetweenIso(fromIso, toIso) { + const parse = (iso) => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || ''); + return m ? Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : NaN; + }; + const a = parse(fromIso); + const b = parse(toIso); + if (Number.isNaN(a) || Number.isNaN(b)) return NaN; + return Math.round((b - a) / 86400000); + } + + // Same rule as the public calendar (book-detail.php) and the server's + // LoanWindow: when a scheduled loan or a reservation on every copy begins + // before the default return date, the proposed deadline stops the day + // before it. Staff keep the picker free to type a later date (they may + // be about to cancel the other commitment), but the hint says why the + // store would refuse the full default. + function loanWindowFrom(startIso, defaultDays) { + let firstBlocked = null; + for (let i = 1; i <= defaultDays; i++) { + const day = addDaysToIso(startIso, i); + const info = availabilityByDate[day]; + if (info && info.state !== 'free') { + firstBlocked = day; + break; + } + } + if (firstBlocked === null) { + return { endDate: addDaysToIso(startIso, defaultDays), maxDays: defaultDays, firstBlocked: null, capped: false }; + } + const endDate = addDaysToIso(firstBlocked, -1); + return { endDate, maxDays: daysBetweenIso(startIso, endDate), firstBlocked, capped: true }; + } + + function formatDisplayDate(iso) { + const parts = (iso || '').split('-'); + if (parts.length !== 3) return iso; + return isItalian ? parts.reverse().join('/') : `${parts[1]}/${parts[2]}/${parts[0]}`; + } + + // The deadline this code last proposed on its own. A value the operator + // typed is never replaced by a later availability fetch; one this code + // proposed follows the new subject (another book or borrower may have no + // conflict, or a different one). + let autoProposedEnd = null; + function setLoanLimitHint(text) { + const hint = document.getElementById('data_scadenza_hint'); + if (!hint) return; + hint.textContent = text || ''; + hint.classList.toggle('hidden', !text); + } + function applyLoanWindow(startIso, onlyShorten) { + const loanWindow = loanWindowFrom(startIso, effectiveLoanDays); + if (fpScadenza) { + fpScadenza.set('minDate', startIso); + const current = dataScadenzaEl.value || ''; + const typedByOperator = current !== '' && current !== autoProposedEnd; + if (!onlyShorten || !typedByOperator || (loanWindow.capped && current > loanWindow.endDate)) { + fpScadenza.setDate(loanWindow.endDate); + autoProposedEnd = loanWindow.endDate; + } + } + let text = ''; + if (loanWindow.capped && loanWindow.maxDays < 1) { + // The next day is blocked: no loan of at least one day from this start. + text = i18n.noWindow.replace('%s', formatDisplayDate(loanWindow.firstBlocked)); + } else if (loanWindow.capped && loanWindow.maxDays === 1) { + text = i18n.loanLimitOne + .replace('%1$s', formatDisplayDate(loanWindow.endDate)) + .replace('%2$s', formatDisplayDate(loanWindow.firstBlocked)); + } else if (loanWindow.capped) { + text = i18n.loanLimit + .replace('%1$d', String(loanWindow.maxDays)) + .replace('%2$s', formatDisplayDate(loanWindow.endDate)) + .replace('%3$s', formatDisplayDate(loanWindow.firstBlocked)); + } + setLoanLimitHint(text); + } + // Book availability data let bookAvailability = { occupiedRanges: [], @@ -557,12 +650,9 @@ function updateConsegnaImmediataVisibility(dateStr) { onDayCreate: colorCalendarDates, onChange: function(selectedDates, dateStr) { if (dateStr) { - // Auto-update end date - const endDate = addDaysToIso(dateStr, defaultLoanDays); - if (fpScadenza) { - fpScadenza.set('minDate', dateStr); - fpScadenza.setDate(endDate); - } + // Auto-update end date, stopping before a scheduled loan or + // reservation that begins inside the default duration. + applyLoanWindow(dateStr); // Update hint about availability updateDateHint(dateStr); @@ -639,6 +729,7 @@ function fetchBookAvailability(bookId) { bookAvailability = { occupiedRanges: [], firstAvailable: null, isAvailableNow: true, blockedByReservation: false }; if (calendarLegend) calendarLegend.classList.add('hidden'); if (dataPrestitoHint) dataPrestitoHint.classList.add('hidden'); + setLoanLimitHint(''); if (fpPrestito) fpPrestito.redraw(); if (fpScadenza) fpScadenza.redraw(); return; @@ -651,6 +742,8 @@ function fetchBookAvailability(bookId) { bookAvailability = { occupiedRanges: [], firstAvailable: null, isAvailableNow: true, blockedByReservation: false }; if (calendarLegend) calendarLegend.classList.add('hidden'); if (dataPrestitoHint) dataPrestitoHint.classList.add('hidden'); + // The previous subject's figures must not outlive it. + setLoanLimitHint(''); if (fpPrestito) fpPrestito.redraw(); if (fpScadenza) fpScadenza.redraw(); @@ -702,6 +795,14 @@ function fetchBookAvailability(bookId) { if (fpPrestito) fpPrestito.redraw(); if (fpScadenza) fpScadenza.redraw(); + // The default deadline was proposed before the availability was + // known: shorten it now if a commitment begins inside the window. + if (dataPrestitoEl.value && !bookAvailability.blockedByReservation) { + applyLoanWindow(dataPrestitoEl.value, true); + } else { + setLoanLimitHint(''); + } + // Update hint if date is already selected if (bookAvailability.blockedByReservation && dataPrestitoHint) { dataPrestitoHint.textContent = i18n.borrowerAlreadyReserved; diff --git a/app/Views/prestiti/index.php b/app/Views/prestiti/index.php index bec70a634..74a0e893d 100644 --- a/app/Views/prestiti/index.php +++ b/app/Views/prestiti/index.php @@ -62,6 +62,9 @@ case 'invalid_date_format': echo __('Errore: formato data non valido. Inserisci le date nel formato YYYY-MM-DD.'); break; + case 'invalid_renewal_date': + echo __('La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.'); + break; case 'loan_closed': echo __('Il prestito è già chiuso: la modifica non è stata salvata.'); break; @@ -82,13 +85,39 @@ case 'no_copies_available': // #336: dire solo "nessuna copia" era fuorviante — il vero motivo // è un conflitto con un altro impegno nel periodo richiesto. - echo __('Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.'); + // Quando il conflitto inizia dopo la data di inizio, il + // redirect porta la finestra massima possibile (LoanWindow). + echo htmlspecialchars( + __('Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.') + . (($loanWindowExplained = \App\Support\LoanWindow::explainFromParams($_GET)) !== null ? ' ' . $loanWindowExplained : ''), + ENT_QUOTES, + 'UTF-8' + ); break; case 'loan_copy_conflict': echo __('Modifica non salvata: la copia assegnata è già impegnata da un altro prestito nel nuovo periodo.'); break; case 'extension_conflicts': - echo __('Impossibile rinnovare: un altro prestito o prenotazione occupa il periodo richiesto.'); + // A later commitment bounds the renewal: say until when it is + // still possible (LoanWindow) and offer that renewal in one click. + $renewalExplained = \App\Support\LoanWindow::explainRenewalFromParams($_GET); + echo htmlspecialchars( + __('Impossibile rinnovare: un altro prestito o prenotazione occupa il periodo richiesto.') + . ($renewalExplained !== null ? ' ' . $renewalExplained : ''), + ENT_QUOTES, + 'UTF-8' + ); + $renewalLoanId = is_scalar($_GET['loan_id'] ?? null) ? (int) $_GET['loan_id'] : 0; + if ($renewalExplained !== null && $renewalLoanId > 0 && (int) ($_GET['max_extension_days'] ?? 0) >= 1) { + $renewalMaxDue = (string) $_GET['max_due_date']; + ?> +
+ + + +
+ ; + if (detail) message += ' ' + detail; } Swal.fire({ icon: 'error', title: t('Errore'), diff --git a/app/Views/user_dashboard/prenotazioni.php b/app/Views/user_dashboard/prenotazioni.php index 3884ec303..12a3e1c3b 100644 --- a/app/Views/user_dashboard/prenotazioni.php +++ b/app/Views/user_dashboard/prenotazioni.php @@ -499,7 +499,7 @@ function resolveCoverUrl(array $item, string $key = 'copertina_url'): string { $outcomeErrorMessages = [ 'not_found' => __('Elemento non trovato o non più annullabile: lo stato potrebbe essere cambiato nel frattempo.'), 'past_date' => __('La data richiesta è nel passato.'), - 'not_available' => __('Il libro non è disponibile per il periodo richiesto.'), + 'not_available' => \App\Support\LoanWindow::explainFromParams($_GET) ?? __('Il libro non è disponibile per il periodo richiesto.'), 'book_not_found' => __('Libro non trovato.'), ]; // Normalizza prima del lookup: ?error[]=x arriverebbe come array (warning PHP 8). diff --git a/locale/da_DK.json b/locale/da_DK.json index c9ff8b2dd..4c622f2b2 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8298,7 +8298,7 @@ "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.": "%1$d filer flyttet, %2$d ikke flyttet: kontrollér mappernes rettigheder.", "%d file spostati nella cartella protetta.": "%d filer flyttet til den beskyttede mappe.", "Accedi per aprirlo": "Log ind for at åbne den", - "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.": "Administratorer, personale og den låner, der har bogen udlånt lige nu.", + "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.": "Administratorer, personale, godkendte brugere og den låner, der har bogen udlånt lige nu.", "Archiviazione dei file": "Opbevaring af filer", "Chi ha il libro in prestito": "Den, der har bogen udlånt", "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.": "Hvem der må åbne e-bøger, lydbøger og dokumenter knyttet til posterne, og hvor filerne opbevares.", @@ -8373,5 +8373,20 @@ "Revoca tutti i link dell’app": "Tilbagekald alle appens links", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Hvis den samme fil også er vedhæftet andre poster, gælder den mest åbne synlighed blandt disse poster.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Administration af digitale vedhæftninger omfatter også åbning af begrænsede vedhæftninger: den, der kan ændre en fils synlighed, kan alligevel åbne den, så de to tilladelser gemmes sammen.", - "Diritti esistenti": "Eksisterende rettigheder" + "Diritti esistenti": "Eksisterende rettigheder", + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maksimal lånetid: %1$d dage (aflevering senest %2$s). Fra %3$s er bogen allerede reserveret.", + "Giorno non disponibile (%1$s): %2$s": "Dag ikke tilgængelig (%1$s): %2$s", + "Il periodo richiesto supera la durata massima consentita.": "Den ønskede periode overstiger den maksimalt tilladte lånetid.", + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Intet lån er muligt fra denne startdato: fra %s er bogen allerede reserveret. Vælg en anden dato.", + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maksimal lånetid: 1 dag (aflevering senest %1$s). Fra %2$s er bogen allerede reserveret.", + "Rinnovo non possibile: dal %s il libro è già prenotato.": "Ingen forlængelse er mulig: fra %s er bogen allerede reserveret.", + "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.": "Forlængelse mulig med højst 1 dag (indtil %1$s): fra %2$s er bogen allerede reserveret.", + "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.": "Forlængelse mulig med højst %1$d dage (indtil %2$s): fra %3$s er bogen allerede reserveret.", + "Rinnova fino al %s": "Forlæng indtil %s", + "Prestito n. %1$d: %2$s": "Lån nr. %1$d: %2$s", + "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.": "Den valgte dato ligger uden for den tilladte fornyelsesperiode. Opdater siden, og prøv igen.", + "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s har kritiske sårbarheder: opdater til %s eller nyere", + "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "To fejl i PHP (%s) gør det muligt at køre kode på serveren uden at logge ind. De afhænger ikke af Pinakes, men af miljøet: de rettes ved at opdatere PHP fra hostingpanelet eller med systemets pakker, og ingen Pinakes-opdatering kan gøre det for dig.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Indtil du kan opdatere, mindsker session.upload_progress.enabled = 0 i php.ini eller PHP-FPM-poolen (ikke i .user.ini, som virker for sent) og en genstart af PHP risikoen uden at fjerne den. Nogle distributioner anvender rettelserne uden at ændre versionsnummeret: spørg i så fald din udbyder.", + "Avviso di sicurezza di PHP": "PHP-sikkerhedsmeddelelse" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 60433b038..1b6f63f66 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8298,7 +8298,7 @@ "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.": "%1$d Dateien verschoben, %2$d nicht verschoben: Ordnerberechtigungen prüfen.", "%d file spostati nella cartella protetta.": "%d Dateien in den geschützten Ordner verschoben.", "Accedi per aprirlo": "Zum Öffnen anmelden", - "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.": "Administratoren, Mitarbeitende und wer dieses Buch gerade ausgeliehen hat.", + "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.": "Administratoren, Mitarbeitende, berechtigte Nutzer und wer dieses Buch gerade ausgeliehen hat.", "Archiviazione dei file": "Dateiablage", "Chi ha il libro in prestito": "Wer das Buch ausgeliehen hat", "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.": "Wer E-Books, Hörbücher und an Titeln angehängte Dokumente öffnen darf und wo die Dateien liegen.", @@ -8373,5 +8373,20 @@ "Revoca tutti i link dell’app": "Alle App-Links widerrufen", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Ist dieselbe Datei auch an andere Datensätze angehängt, gilt die offenste Sichtbarkeit unter diesen Datensätzen.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Die Verwaltung digitaler Anhänge umfasst auch das Öffnen geschützter Anhänge: Wer die Sichtbarkeit einer Datei ändern kann, kann sie ohnehin öffnen, daher werden beide Berechtigungen gemeinsam gespeichert.", - "Diritti esistenti": "Bisherige Rechte" + "Diritti esistenti": "Bisherige Rechte", + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximale Leihdauer: %1$d Tage (Rückgabe bis %2$s). Ab dem %3$s ist das Buch bereits vorgemerkt.", + "Giorno non disponibile (%1$s): %2$s": "Tag nicht verfügbar (%1$s): %2$s", + "Il periodo richiesto supera la durata massima consentita.": "Der gewünschte Zeitraum überschreitet die maximal zulässige Leihdauer.", + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Ab diesem Startdatum ist keine Ausleihe möglich: ab dem %s ist das Buch bereits vorgemerkt. Wählen Sie ein anderes Datum.", + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maximale Leihdauer: 1 Tag (Rückgabe bis %1$s). Ab dem %2$s ist das Buch bereits vorgemerkt.", + "Rinnovo non possibile: dal %s il libro è già prenotato.": "Keine Verlängerung möglich: ab dem %s ist das Buch bereits vorgemerkt.", + "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.": "Verlängerung um höchstens 1 Tag möglich (bis %1$s): ab dem %2$s ist das Buch bereits vorgemerkt.", + "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.": "Verlängerung um höchstens %1$d Tage möglich (bis %2$s): ab dem %3$s ist das Buch bereits vorgemerkt.", + "Rinnova fino al %s": "Bis %s verlängern", + "Prestito n. %1$d: %2$s": "Ausleihe Nr. %1$d: %2$s", + "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.": "Das gewählte Datum liegt außerhalb des zulässigen Verlängerungszeitraums. Laden Sie die Seite neu und versuchen Sie es erneut.", + "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s hat kritische Sicherheitslücken: aktualisieren Sie auf %s oder neuer", + "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Zwei Lücken in PHP (%s) erlauben es, ohne Anmeldung Code auf dem Server auszuführen. Sie hängen nicht von Pinakes ab, sondern von der Umgebung: Behoben werden sie durch ein PHP-Update im Hosting-Panel oder über die Systempakete, und kein Pinakes-Update kann das ersetzen.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Bis Sie aktualisieren können, verringert session.upload_progress.enabled = 0 in der php.ini oder im PHP-FPM-Pool (nicht in .user.ini, die zu spät greift) mit anschließendem Neustart von PHP das Risiko, beseitigt es aber nicht. Manche Distributionen spielen die Korrekturen ein, ohne die Versionsnummer zu ändern: Fragen Sie in diesem Fall Ihren Anbieter.", + "Avviso di sicurezza di PHP": "PHP-Sicherheitshinweis" } diff --git a/locale/en_US.json b/locale/en_US.json index 114f36767..716687124 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8298,7 +8298,7 @@ "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.": "%1$d files moved, %2$d not moved: check the folder permissions.", "%d file spostati nella cartella protetta.": "%d files moved to the protected folder.", "Accedi per aprirlo": "Sign in to open it", - "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.": "Administrators, staff and the reader who currently has that book on loan.", + "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.": "Administrators, staff, authorized users and whoever currently has that book on loan.", "Archiviazione dei file": "File storage", "Chi ha il libro in prestito": "Whoever has the book on loan", "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.": "Who may open the eBooks, audiobooks and documents attached to records, and where the files are kept.", @@ -8373,5 +8373,20 @@ "Revoca tutti i link dell’app": "Revoke all app links", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "If the same file is also attached to other records, the most open visibility among those records applies.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Managing digital attachments includes opening restricted attachments: whoever can change a file's visibility can open it anyway, so the two permissions are saved together.", - "Diritti esistenti": "Existing rights" + "Diritti esistenti": "Existing rights", + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximum loan length: %1$d days (return by %2$s). The book is already booked from %3$s.", + "Giorno non disponibile (%1$s): %2$s": "Day not available (%1$s): %2$s", + "Il periodo richiesto supera la durata massima consentita.": "The requested period exceeds the maximum allowed loan length.", + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "No loan is possible from this start date: the book is already booked from %s. Choose another date.", + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maximum loan length: 1 day (return by %1$s). The book is already booked from %2$s.", + "Rinnovo non possibile: dal %s il libro è già prenotato.": "No renewal is possible: the book is already booked from %s.", + "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.": "Renewal possible by at most 1 day (until %1$s): the book is already booked from %2$s.", + "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.": "Renewal possible by at most %1$d days (until %2$s): the book is already booked from %3$s.", + "Rinnova fino al %s": "Renew until %s", + "Prestito n. %1$d: %2$s": "Loan no. %1$d: %2$s", + "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.": "The selected date is outside the allowed renewal period. Refresh the page and try again.", + "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s has critical vulnerabilities: update it to %s or later", + "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Two PHP flaws (%s) let anyone run code on the server without signing in. They do not depend on Pinakes but on the environment: they are fixed by updating PHP from the hosting panel or the system packages, and no Pinakes update can do it for you.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Until you can update, setting session.upload_progress.enabled = 0 in php.ini or the PHP-FPM pool (not in .user.ini, which comes too late) and restarting PHP lowers the risk without removing it. Some distributions apply the fixes without changing the version number: in that case check with your provider.", + "Avviso di sicurezza di PHP": "PHP security advisory" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index de529e362..b8fbd16ec 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8298,7 +8298,7 @@ "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.": "%1$d fichiers déplacés, %2$d non déplacés : vérifiez les permissions des dossiers.", "%d file spostati nella cartella protetta.": "%d fichiers déplacés dans le dossier protégé.", "Accedi per aprirlo": "Connectez-vous pour l’ouvrir", - "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.": "Administrateurs, personnel et le lecteur qui a ce livre en prêt.", + "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.": "Administrateurs, personnel, utilisateurs autorisés et le lecteur qui a ce livre en prêt.", "Archiviazione dei file": "Stockage des fichiers", "Chi ha il libro in prestito": "Qui a le livre en prêt", "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.": "Qui peut ouvrir les livres numériques, livres audio et documents joints aux notices, et où les fichiers sont conservés.", @@ -8373,5 +8373,20 @@ "Revoca tutti i link dell’app": "Révoquer tous les liens de l’application", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Si le même fichier est aussi joint à d’autres notices, la visibilité la plus ouverte parmi ces notices s’applique.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "La gestion des pièces jointes numériques comprend aussi l’ouverture des pièces jointes réservées : qui peut modifier la visibilité d’un fichier peut de toute façon l’ouvrir, les deux autorisations sont donc enregistrées ensemble.", - "Diritti esistenti": "Droits existants" + "Diritti esistenti": "Droits existants", + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durée maximale du prêt : %1$d jours (retour avant le %2$s). À partir du %3$s, le livre est déjà réservé.", + "Giorno non disponibile (%1$s): %2$s": "Jour non disponible (%1$s) : %2$s", + "Il periodo richiesto supera la durata massima consentita.": "La période demandée dépasse la durée maximale de prêt autorisée.", + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Aucun prêt n'est possible à partir de cette date : à partir du %s, le livre est déjà réservé. Choisissez une autre date.", + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Durée maximale du prêt : 1 jour (retour avant le %1$s). À partir du %2$s, le livre est déjà réservé.", + "Rinnovo non possibile: dal %s il libro è già prenotato.": "Aucune prolongation possible : à partir du %s, le livre est déjà réservé.", + "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.": "Prolongation possible d'au plus 1 jour (jusqu'au %1$s) : à partir du %2$s, le livre est déjà réservé.", + "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.": "Prolongation possible d'au plus %1$d jours (jusqu'au %2$s) : à partir du %3$s, le livre est déjà réservé.", + "Rinnova fino al %s": "Prolonger jusqu'au %s", + "Prestito n. %1$d: %2$s": "Prêt n° %1$d : %2$s", + "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.": "La date choisie est hors de la période de renouvellement autorisée. Actualisez la page et réessayez.", + "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s présente des vulnérabilités critiques : mettez-le à jour vers %s ou une version ultérieure", + "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Deux failles de PHP (%s) permettent d’exécuter du code sur le serveur sans se connecter. Elles ne dépendent pas de Pinakes mais de l’environnement : on les corrige en mettant à jour PHP depuis le panneau de l’hébergeur ou avec les paquets du système, et aucune mise à jour de Pinakes ne peut le faire à votre place.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "En attendant de pouvoir mettre à jour, définir session.upload_progress.enabled = 0 dans php.ini ou dans le pool PHP-FPM (pas dans .user.ini, appliqué trop tard) puis redémarrer PHP réduit le risque sans l’éliminer. Certaines distributions appliquent les correctifs sans changer le numéro de version : dans ce cas, vérifiez auprès de votre fournisseur.", + "Avviso di sicurezza di PHP": "Avis de sécurité de PHP" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 1c73a5189..23267629c 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8298,7 +8298,7 @@ "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.": "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.", "%d file spostati nella cartella protetta.": "%d file spostati nella cartella protetta.", "Accedi per aprirlo": "Accedi per aprirlo", - "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.": "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.", + "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.": "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.", "Archiviazione dei file": "Archiviazione dei file", "Chi ha il libro in prestito": "Chi ha il libro in prestito", "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.": "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.", @@ -8373,5 +8373,20 @@ "Revoca tutti i link dell’app": "Revoca tutti i link dell’app", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.", - "Diritti esistenti": "Diritti esistenti" + "Diritti esistenti": "Diritti esistenti", + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.", + "Giorno non disponibile (%1$s): %2$s": "Giorno non disponibile (%1$s): %2$s", + "Il periodo richiesto supera la durata massima consentita.": "Il periodo richiesto supera la durata massima consentita.", + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.", + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.", + "Rinnovo non possibile: dal %s il libro è già prenotato.": "Rinnovo non possibile: dal %s il libro è già prenotato.", + "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.": "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.", + "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.": "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.", + "Rinnova fino al %s": "Rinnova fino al %s", + "Prestito n. %1$d: %2$s": "Prestito n. %1$d: %2$s", + "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.": "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.", + "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo", + "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.", + "Avviso di sicurezza di PHP": "Avviso di sicurezza di PHP" } diff --git a/php.ini.recommended b/php.ini.recommended index 1ff9cf8e2..773447353 100644 --- a/php.ini.recommended +++ b/php.ini.recommended @@ -38,6 +38,19 @@ opcache.validate_timestamps=1 opcache.jit=disable opcache.jit_buffer_size=0 +; =================================== +; SECURITY +; =================================== +; Pinakes does not use PHP's session upload progress. Turning it off lowers the +; exposure to CVE-2026-109618 / CVE-2026-109619 on an unpatched PHP; it does not +; replace updating PHP to 8.2.35, 8.3.36, 8.4.27, 8.5.12 or later (or a +; distribution package with the fixes backported). +; Unlike the rest of this file, this one must be set administratively, in php.ini, +; the PHP-FPM pool (php_admin_value) or the server configuration, and every PHP +; worker restarted: .user.ini is applied too late for this case (PHP advisory +; GHSA-6wmf-4rv5-p3rp). +session.upload_progress.enabled=0 + ; =================================== ; MEMORY & PERFORMANCE ; =================================== diff --git a/public/assets/css/swal-theme.css b/public/assets/css/swal-theme.css index f0ff6a14a..52badf2f4 100644 --- a/public/assets/css/swal-theme.css +++ b/public/assets/css/swal-theme.css @@ -341,6 +341,21 @@ body.swal2-shown { color: var(--primary-text, var(--primary-color, #d70161)); } +/* Under the return-date field: the longest loan still possible when a + scheduled loan or reservation begins before the default return date. */ +.loan-request-limit { + margin: .45rem 0 0; + color: #b45309; + font-size: .8rem; + font-weight: 600; + line-height: 1.45; + text-align: left; +} + +.loan-request-limit[hidden] { + display: none; +} + .loan-request-actions { justify-content: flex-start !important; gap: .65rem !important; diff --git a/storage/plugins/desiderata/DesiderataPlugin.php b/storage/plugins/desiderata/DesiderataPlugin.php index c7b33867e..a4b8f5fe0 100644 --- a/storage/plugins/desiderata/DesiderataPlugin.php +++ b/storage/plugins/desiderata/DesiderataPlugin.php @@ -556,7 +556,7 @@ private function registerCopies(int $bookId, int $howMany): void { $this->db->begin_transaction(); try { - $stmt = $this->db->prepare('SELECT titolo FROM libri l WHERE l.id = ? AND l.deleted_at IS NULL AND l.is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); + $stmt = $this->db->prepare('SELECT titolo, numero_inventario FROM libri l WHERE l.id = ? AND l.deleted_at IS NULL AND l.is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); if ($stmt === false) { throw new RuntimeException($this->db->error); } $stmt->bind_param('i', $bookId); $stmt->execute(); @@ -565,8 +565,10 @@ private function registerCopies(int $bookId, int $howMany): void if (!$book) { $this->db->rollback(); return; } $title = (string) ($book['titolo'] ?? ''); $this->assertNoOpenOffers($bookId); + // The book's own inventory prefix, as every core path uses it (#489). + $base = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); $ids = (new \App\Models\CopyRepository($this->db)) - ->createManyForBookWithIdsAndNote($bookId, 'LIB-' . $bookId, $howMany, 'disponibile', 'Donazione diretta'); + ->createManyForBookWithIdsAndNote($bookId, $base, $howMany, 'disponibile', 'Donazione diretta'); if (count($ids) !== $howMany) { throw new RuntimeException('Copy creation failed'); } $copyId = $ids[0]; $stmt = $this->db->prepare('UPDATE libri SET is_desiderata = 0' . \App\Support\BookVisibility::catalogueStamp($this->db) . ' WHERE id = ? AND deleted_at IS NULL'); @@ -1550,7 +1552,7 @@ public function receiveDirect(Request $q, Response $r, int $bookId): Response // inconsistency DataIntegrity exists to repair, and without this // clause a receipt recorded in that window would answer it by // creating a SECOND inventory copy rather than refusing. - $stmt = $this->db->prepare('SELECT titolo FROM libri l WHERE id = ? AND deleted_at IS NULL AND is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); + $stmt = $this->db->prepare('SELECT titolo, numero_inventario FROM libri l WHERE id = ? AND deleted_at IS NULL AND is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); if ($stmt === false) { throw new RuntimeException($this->db->error); } $stmt->bind_param('i', $bookId); $stmt->execute(); @@ -1559,7 +1561,8 @@ public function receiveDirect(Request $q, Response $r, int $bookId): Response if (!$book) { throw new InvalidArgumentException(__('Il libro non è più una richiesta aperta.')); } $title = (string) ($book['titolo'] ?? ''); $this->assertNoOpenOffers($bookId); - $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, 'LIB-' . $bookId, 'disponibile', 'Donazione diretta'); + $base = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); + $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, $base, 'disponibile', 'Donazione diretta'); if ($copyId <= 0) { throw new RuntimeException('Copy creation failed'); } // Self-guarding, per ABSOLUTE RULE 2, and kept even though // DataIntegrity clears the flag by itself once a copy exists: the @@ -1933,13 +1936,14 @@ public function manage(Request $q, Response $r, int $id): Response throw new InvalidArgumentException(__('Seleziona la scheda del libro ricevuto.')); } $bookId = (int)$rawBookId; - $stmt = $this->db->prepare('SELECT id FROM libri WHERE id=? AND deleted_at IS NULL FOR UPDATE'); + $stmt = $this->db->prepare('SELECT id, numero_inventario FROM libri WHERE id=? AND deleted_at IS NULL FOR UPDATE'); if ($stmt === false) { throw new RuntimeException('Book lock prepare failed: ' . $this->db->error); } $stmt->bind_param('i', $bookId); $stmt->execute(); $bookRow = $stmt->get_result()->fetch_assoc(); $stmt->close(); if (!$bookRow) { throw new InvalidArgumentException(__('Per la ricezione scegli una scheda libro esistente. Puoi prima crearla con zero copie.')); } - $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, 'LIB-' . $bookId, 'disponibile', 'Donazione #' . $id); + $base = \App\Models\CopyRepository::inventoryBase($bookId, $bookRow['numero_inventario'] ?? null); + $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, $base, 'disponibile', 'Donazione #' . $id); if ($copyId <= 0) { throw new RuntimeException('Copy creation failed'); } // Self-guarding, per ABSOLUTE RULE 2. Provably a no-op here — // the row was locked FOR UPDATE and checked for deleted_at two diff --git a/storage/plugins/desiderata/plugin.json b/storage/plugins/desiderata/plugin.json index db9740bde..f8b1fa51d 100644 --- a/storage/plugins/desiderata/plugin.json +++ b/storage/plugins/desiderata/plugin.json @@ -2,11 +2,11 @@ "name": "desiderata", "display_name": "Desiderata e donazioni", "description": "Libri cercati dalla biblioteca, ricerca pubblica e proposte di donazione con ricezione controllata.", - "version": "1.2.0", + "version": "1.2.1", "author": "Pinakes", "main_file": "wrapper.php", "requires_php": "8.2", - "requires_app": "0.7.85", + "requires_app": "0.8.7", "metadata": { "category": "community", "optional": true diff --git a/storage/plugins/digital-library/README.md b/storage/plugins/digital-library/README.md index 3da54cc35..d3d4049c2 100644 --- a/storage/plugins/digital-library/README.md +++ b/storage/plugins/digital-library/README.md @@ -24,7 +24,7 @@ Ogni allegato ha una visibilità, scelta nella scheda del libro accanto all'URL: - **Chiunque**: anche senza accedere; - **Utenti registrati**: solo chi ha effettuato l'accesso; - **Amministratori, staff e utenti autorizzati** (chi ha il permesso di aprire gli allegati riservati, per ruolo o individualmente): il file non compare nella scheda pubblica per gli altri; -- **Chi ha il libro in prestito**: amministratori, staff e l'utente con un prestito in corso di quel libro. +- **Chi ha il libro in prestito**: amministratori, staff, utenti autorizzati e l'utente con un prestito in corso di quel libro, solo finché il prestito è attivo. È il livello da scegliere per dare il file sia agli utenti autorizzati sia a chi prende in prestito il libro. Nella scheda pubblica un file riservato agli utenti registrati o a chi ha il prestito compare chiuso, con il motivo (si può nascondere dalle impostazioni). L'API pubblica e OAI-PMH, che non conoscono l'utente, ricevono solo i file aperti a chiunque. L'app riceve per ogni file `visibility` e `access`, e un link firmato valido 24-25 ore per i file che l'utente può aprire; il link smette di funzionare subito se l'utente cambia password, e il pulsante «Revoca tutti i link dell’app» nelle impostazioni del plugin li revoca per tutti. Un file caricato dall'app viene allegato direttamente alla scheda, con la visibilità predefinita. diff --git a/storage/plugins/digital-library/views/settings.php b/storage/plugins/digital-library/views/settings.php index b9734a6ea..6fc286be0 100644 --- a/storage/plugins/digital-library/views/settings.php +++ b/storage/plugins/digital-library/views/settings.php @@ -68,7 +68,7 @@ 'public' => [__('Chiunque'), __('Chiunque visiti la scheda, anche senza accedere.')], 'users' => [__('Utenti registrati'), __('Solo chi ha effettuato l’accesso.')], 'staff' => [__('Amministratori, staff e utenti autorizzati'), __('Il file non compare nella scheda pubblica.')], - 'loan' => [__('Chi ha il libro in prestito'), __('Amministratori, staff e l’utente che ha in corso il prestito di quel libro.')], + 'loan' => [__('Chi ha il libro in prestito'), __('Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.')], ]; ?> diff --git a/storage/plugins/mobile-api/plugin.json b/storage/plugins/mobile-api/plugin.json index d70fbe56e..f4599dc02 100644 --- a/storage/plugins/mobile-api/plugin.json +++ b/storage/plugins/mobile-api/plugin.json @@ -2,12 +2,12 @@ "name": "mobile-api", "display_name": "Mobile API", "description": "API REST/JSON versionata (/api/v1) per l'app companion mobile di Pinakes: discovery, autenticazione a token per dispositivo, ricerca catalogo, prestiti/prenotazioni, wishlist, profilo, messaggi e notifiche push. Disattivata per default finché non viene abilitata.", - "version": "1.8.1", + "version": "1.8.2", "author": "Fabiodalez", "author_url": "", "plugin_url": "", "requires_php": "8.1", - "requires_app": "0.8.6-rc.1", + "requires_app": "0.8.7", "path": "mobile-api", "main_file": "wrapper.php", "metadata": { diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index d08af1fb7..2a3c7b443 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -206,6 +206,14 @@ public function requestReservation(Request $request, ResponseInterface $response $body = JsonBody::parse($request); $bookId = Input::int($body['book_id'] ?? $body['libro_id'] ?? 0); $desired = isset($body['desired_date']) ? trim(Input::str($body['desired_date'])) : ''; + // Optional return date (Mobile API 1.8.2): the app sends the day before + // a scheduled loan or reservation when the configured duration would + // cross it, as the website calendar does. The core form validates the + // bounds (not before the start, within max_loan_duration_days). + if (isset($body['end_date']) && !is_scalar($body['end_date'])) { + return ResponseEnvelope::error($response, 'invalid_date', __('Data richiesta non valida.'), 422); + } + $endDate = isset($body['end_date']) ? trim(Input::str($body['end_date'])) : ''; if ($bookId <= 0) { return ResponseEnvelope::error($response, 'invalid_book', __('Identificativo libro non valido.'), 422); @@ -213,6 +221,9 @@ public function requestReservation(Request $request, ResponseInterface $response if ($desired !== '' && !DateHelper::isISODateFormat($desired)) { return ResponseEnvelope::error($response, 'invalid_date', __('Data richiesta non valida.'), 422); } + if ($endDate !== '' && !DateHelper::isISODateFormat($endDate)) { + return ResponseEnvelope::error($response, 'invalid_date', __('Data richiesta non valida.'), 422); + } // Domain "today" MUST come from DateHelper::today() (app timezone), not // date() (process tz, often UTC): near midnight they disagree by a day. $today = DateHelper::today(); @@ -237,7 +248,11 @@ public function requestReservation(Request $request, ResponseInterface $response } if ($immediate) { - $delegated = $request->withParsedBody(['libro_id' => $bookId]); + $delegatedLoan = ['libro_id' => $bookId]; + if ($endDate !== '') { + $delegatedLoan['end_date'] = $endDate; + } + $delegated = $request->withParsedBody($delegatedLoan); $result = $controller->loan($delegated, new SlimResponse(), $this->db); $outcome = $this->redirectOutcome($result); @@ -270,13 +285,16 @@ public function requestReservation(Request $request, ResponseInterface $response return $this->reservationCreatedResponse($response, $userId, $bookId); } - return $this->mapActionError($response, (string) ($outcome['loan_error'] ?? 'db')); + return $this->mapActionError($response, (string) ($outcome['loan_error'] ?? 'db'), $outcome); } $delegatedBody = ['libro_id' => $bookId]; if ($desired !== '') { $delegatedBody['desired_date'] = $desired; } + if ($endDate !== '') { + $delegatedBody['end_date'] = $endDate; + } $delegated = $request->withParsedBody($delegatedBody); $result = $controller->reserve($delegated, new SlimResponse(), $this->db); $outcome = $this->redirectOutcome($result); @@ -285,7 +303,7 @@ public function requestReservation(Request $request, ResponseInterface $response return $this->reservationCreatedResponse($response, $userId, $bookId); } - return $this->mapActionError($response, (string) ($outcome['reserve_error'] ?? 'db')); + return $this->mapActionError($response, (string) ($outcome['reserve_error'] ?? 'db'), $outcome); } catch (\Throwable $e) { SecureLogger::error('[MobileApi] request reservation failed: ' . $e->getMessage()); return ResponseEnvelope::error($response, 'internal_error', __('Richiesta non disponibile.'), 500); @@ -1190,12 +1208,26 @@ private function redirectOutcome(ResponseInterface $result): array return $flat; } - private function mapActionError(ResponseInterface $response, string $code): ResponseInterface + /** + * @param array $outcome The redirect's query parameters: a + * capacity refusal may carry the longest loan still possible + * (LoanWindow::redirectParams()), surfaced as the message and as + * details so the app can propose that return date. + */ + private function mapActionError(ResponseInterface $response, string $code, array $outcome = []): ResponseInterface { switch ($code) { case 'duplicate': return ResponseEnvelope::error($response, 'duplicate', __('Hai già una richiesta attiva per questo libro.'), 409); case 'not_available': + $explained = \App\Support\LoanWindow::explainFromParams($outcome); + if ($explained !== null) { + return ResponseEnvelope::error($response, 'not_available', $explained, 409, [ + 'max_duration_days' => (int) $outcome['max_loan_days'], + 'max_end_date' => $outcome['max_end_date'], + 'first_conflict_date' => $outcome['blocked_from'], + ]); + } return ResponseEnvelope::error($response, 'not_available', __('Il libro non è disponibile per il periodo richiesto.'), 409); case 'max_loans_reached': return ResponseEnvelope::error($response, 'max_loans_reached', __('Hai raggiunto il numero massimo di prestiti attivi.'), 409); @@ -1209,6 +1241,8 @@ private function mapActionError(ResponseInterface $response, string $code): Resp return ResponseEnvelope::error($response, 'past_date', __('La data richiesta è nel passato.'), 422); case 'invalid_date': return ResponseEnvelope::error($response, 'invalid_date', __('Data richiesta non valida.'), 422); + case 'max_duration_exceeded': + return ResponseEnvelope::error($response, 'max_duration_exceeded', __('Il periodo richiesto supera la durata massima consentita.'), 422); case 'invalid': return ResponseEnvelope::error($response, 'invalid_book', __('Identificativo libro non valido.'), 422); default: diff --git a/storage/plugins/mobile-api/src/Controllers/CatalogController.php b/storage/plugins/mobile-api/src/Controllers/CatalogController.php index 11e178952..bf4fab312 100644 --- a/storage/plugins/mobile-api/src/Controllers/CatalogController.php +++ b/storage/plugins/mobile-api/src/Controllers/CatalogController.php @@ -833,7 +833,11 @@ public function bookAvailability( // configured loan duration (UserActionsController::loan()/reserve()), so // the app can show it before the reader sends the request. $data = self::mapAvailability($avail) - + ['loan_duration_days' => (new \App\Models\SettingsRepository($this->db))->loanDurationDays()]; + + [ + 'loan_duration_days' => (int) $avail['loan_duration_days'], + // Upper bound POST /reservations accepts for end_date (Mobile API 1.8.2). + 'max_loan_duration_days' => (int) ($avail['max_loan_duration_days'] ?? 90), + ]; return ResponseEnvelope::success($response, $data, []); } catch (\Throwable $e) { diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index 0707c63fc..80aed872b 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -611,6 +611,7 @@ private function paths(): array ], ]], 'loan_duration_days' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Days POST /reservations adds to the chosen start to set the return date.'], + 'max_loan_duration_days' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Longest loan POST /reservations accepts through end_date (Mobile API 1.8.2).'], ], ]], ]]], @@ -734,7 +735,8 @@ private function paths(): array ], '400' => ['$ref' => '#/components/responses/UnprocessableEntity'], '401' => ['$ref' => '#/components/responses/Unauthorized'], - '409' => ['description' => 'Overlap or book unavailable.', 'content' => $json], + '409' => ['description' => 'Overlap or book unavailable. For not_available, when the start day is free but a scheduled loan or reservation begins inside the requested window, error.message names the longest loan still possible and meta carries max_duration_days, max_end_date and first_conflict_date (Mobile API 1.8.2) so the client can propose that return date.', 'content' => $json], + '422' => ['description' => 'Invalid book id or date, or end_date beyond max_loan_duration_days (max_duration_exceeded).', 'content' => $json], '500' => ['$ref' => '#/components/responses/InternalError'], ], ], @@ -1433,7 +1435,7 @@ private function reservationRequestSchema(): array 'book_id' => ['type' => 'integer'], 'desired_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'description' => 'Requested start date. Today or omitted means immediate loan when a copy is free; future dates create reservations.'], 'start_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'deprecated' => true], - 'end_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'deprecated' => true], + 'end_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'description' => 'Optional return date (Mobile API 1.8.2). Omitted, the library\'s loan_duration_days is added to the start. Send the day before the first fully-booked day of GET /catalog/books/{id}/availability when the default would cross it; must not precede the start nor exceed max_loan_duration_days (422 max_duration_exceeded).'], ], ]; } diff --git a/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php b/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php index 6d06d960b..6eae0d944 100644 --- a/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php +++ b/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php @@ -528,9 +528,7 @@ public function addCopies(ServerRequestInterface $request, ResponseInterface $re return ResponseEnvelope::error($response, 'not_found', __('Libro non trovato.'), 404); } $before = \App\Support\ActivityLog::loadBookSnapshot($this->db, $bookId); - $base = trim((string) ($book['numero_inventario'] ?? '')) !== '' - ? (string) $book['numero_inventario'] - : 'LIB-' . $bookId; + $base = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); // Same steps as POST /api/libri/{id}/increase-copies: one // transaction, book row locked first, every new copy first repairs diff --git a/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php b/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php index cd42ab3a2..64e916b5b 100644 --- a/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php +++ b/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php @@ -408,16 +408,30 @@ public function renew(ServerRequestInterface $request, ResponseInterface $respon if ($this->reader->loanRow($id) === null) { return $this->notFound($response); } + // Optional shorter renewal (Mobile API 1.8.2): the day before a later + // commitment, as a not_available refusal proposes in its meta. The + // core bounds it (after the current due date, within the standard + // renewal). + $json = JsonBody::parse($request); + $rawDue = $json['new_due_date'] ?? ''; + if (!is_string($rawDue)) { + return $this->invalid($response, 'new_due_date', __('Data richiesta non valida.')); + } + $newDue = trim($rawDue); + if ($newDue !== '' && !\App\Support\DateHelper::isISODateFormat($newDue)) { + return $this->invalid($response, 'new_due_date', __('Data richiesta non valida.')); + } + $body = $newDue !== '' ? ['new_due_date' => $newDue] : []; $db = $this->db; $outcome = Delegation::run(static fn (): ResponseInterface => (new PrestitiController())->renew( - Delegation::request('POST', '/admin/loans/renew/' . $id, []), + Delegation::request('POST', '/admin/loans/renew/' . $id, $body), new SlimResponse(), $db, $id )); $error = $outcome->errorCode(); if ($error !== null) { - return CirculationOutcome::error($response, CirculationOutcome::RENEW, $error, 'staff loan renew'); + return CirculationOutcome::error($response, CirculationOutcome::RENEW, $error, 'staff loan renew', self::windowDetails($outcome)); } if ($outcome->queryValue('renewed') !== '1') { SecureLogger::error('[MobileApi] staff loan renew: unexpected outcome HTTP ' . $outcome->status); @@ -453,7 +467,7 @@ public function bulkExtend(ServerRequestInterface $request, ResponseInterface $r )); $error = $outcome->errorCode(); if ($error !== null) { - return CirculationOutcome::error($response, CirculationOutcome::BULK, $error, 'staff bulk extend'); + return CirculationOutcome::error($response, CirculationOutcome::BULK, $error, 'staff bulk extend', self::windowDetails($outcome)); } return ResponseEnvelope::success($response, [ @@ -695,6 +709,24 @@ private function notFound(ResponseInterface $response, ?string $message = null): return ResponseEnvelope::error($response, 'not_found', $message ?? __('Prestito non trovato.'), 404); } + /** + * The renewal-window figures a core redirect may carry beside its error + * code (LoanWindow::renewalRedirectParams(), plus the loan they are about). + * + * @return array + */ + private static function windowDetails(\App\Plugins\MobileApi\Support\DelegationOutcome $outcome): array + { + $details = []; + foreach (['max_extension_days', 'max_due_date', 'blocked_from', 'loan_id'] as $key) { + $value = $outcome->queryValue($key); + if ($value !== null) { + $details[$key] = $value; + } + } + return $details; + } + private function invalid(ResponseInterface $response, string $field, string $message): ResponseInterface { return ResponseEnvelope::error($response, 'validation_error', $message, 422, ['field' => $field]); diff --git a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php index 7f1432a35..f95b16e12 100644 --- a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php +++ b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php @@ -61,6 +61,8 @@ final class CirculationOutcome /** ⑤ renew (PrestitiController::renew). */ public const RENEW = [ + 'invalid_date_format' => [422, 'validation_error', 'new_due_date', false], + 'invalid_renewal_date' => [422, 'validation_error', 'new_due_date', false], 'loan_not_found' => [404, 'not_found', null, false], 'loan_not_active' => [409, 'loan_not_active', null, true], 'loan_not_picked_up' => [409, 'loan_not_active', null, true], @@ -110,8 +112,12 @@ private function __construct() * Error envelope for a core `?error=` code, using one of the tables above. * * @param array $table + * @param array $details Redirect parameters beside the + * error code: a renewal or extension refused for a later commitment + * carries the longest renewal still possible (LoanWindow), surfaced + * as the message and as meta so the app can offer "renew until D". */ - public static function error(ResponseInterface $response, array $table, string $coreCode, string $context): ResponseInterface + public static function error(ResponseInterface $response, array $table, string $coreCode, string $context, array $details = []): ResponseInterface { $entry = $table[$coreCode] ?? null; if ($entry === null) { @@ -129,8 +135,21 @@ public static function error(ResponseInterface $response, array $table, string $ if ($withReason) { $meta['reason'] = $coreCode; } + $message = self::message($coreCode); + // The loan that stopped a bulk extension is named even when no window can be + // explained (OpenAPI documents meta.loan_id for bulk_extend_conflict). + if (isset($details['loan_id']) && is_numeric($details['loan_id'])) { + $meta['loan_id'] = (int) $details['loan_id']; + } + $explained = \App\Support\LoanWindow::explainRenewalFromParams($details); + if ($explained !== null) { + $message = $explained; + $meta['max_extension_days'] = (int) $details['max_extension_days']; + $meta['max_due_date'] = $details['max_due_date']; + $meta['first_conflict_date'] = $details['blocked_from']; + } - return ResponseEnvelope::error($response, $code, self::message($coreCode), $status, $meta); + return ResponseEnvelope::error($response, $code, $message, $status, $meta); } /** @@ -216,6 +235,7 @@ public static function message(string $coreCode): string return match ($coreCode) { 'invalid_date_format', 'invalid_date' => __('Formato data non valido. Usa AAAA-MM-GG.'), + 'invalid_renewal_date' => __('La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.'), 'invalid_dates' => __('La data di scadenza deve essere successiva alla data di inizio.'), 'missing_fields', 'missing_data' => __('Utente e libro sono obbligatori.'), 'expired_window' => __('Il periodo indicato è già terminato.'), diff --git a/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php b/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php index 74bc66742..af759e992 100644 --- a/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php +++ b/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php @@ -111,7 +111,7 @@ private static function paths(): array ])], '/staff/loans/bulk-extend' => ['post' => O::op($t, 'staffBulkExtend', 'Push the due date of several out-loans by N days', [ '200' => O::ok('Extended.', O::object(['extended' => ['type' => 'integer'], 'days' => ['type' => 'integer']])), - ] + O::invalid() + O::conflict('`not_available`: a capacity conflict rolled back the whole batch.'), [ + ] + O::invalid() + O::conflict('`not_available`: a capacity conflict rolled back the whole batch. meta names the loan that stopped it (`loan_id`) and, when a later commitment bounds it, the longest extension still possible (`max_extension_days`, `max_due_date`, `first_conflict_date`, Mobile API 1.8.2).'), [ 'requestBody' => O::inlineBody(['ids' => ['type' => 'array', 'maxItems' => 500, 'items' => ['type' => 'integer']], 'days' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 365]], ['ids', 'days']), ])], '/staff/loans/bulk-recall' => ['post' => O::op($t, 'staffBulkRecall', 'Send the overdue reminder to several loans (5/min)', [ @@ -134,9 +134,11 @@ private static function paths(): array '/staff/loans/{id}/return' => ['post' => O::op($t, 'staffReturnLoan', 'Record the return with the copy outcome', ['200' => $loan] + O::notFound() + $returnErrors, $loanId + [ 'requestBody' => O::inlineBody($returnBody), ])], - '/staff/loans/{id}/renew' => ['post' => O::op($t, 'staffRenewLoan', 'Renew by the configured loan duration', [ + '/staff/loans/{id}/renew' => ['post' => O::op($t, 'staffRenewLoan', 'Renew by the configured loan duration, or until an earlier date', [ '200' => $loan, - ] + O::notFound() + O::conflict('`loan_not_active`, `loan_overdue`, `max_renewals`, `not_available`, `not_eligible`.'), $loanId)], + ] + O::notFound() + O::invalid() + O::conflict('`loan_not_active`, `loan_overdue`, `max_renewals`, `not_available`, `not_eligible`. For `not_available`, when a later commitment bounds the renewal, the message names the longest renewal still possible and meta carries `max_extension_days`, `max_due_date` and `first_conflict_date` (Mobile API 1.8.2): send that date as `new_due_date` to renew until it.'), $loanId + [ + 'requestBody' => O::inlineBody(['new_due_date' => ['type' => 'string', 'format' => 'date', 'description' => 'Optional. Renew until this day instead of the configured duration: after the current due date, not past the standard renewal.']], []), + ])], '/staff/loans/{id}/recall' => ['post' => O::op($t, 'staffRecallLoan', 'Send the overdue reminder now (10/min)', ['200' => $sent] + O::notFound(), $loanId)], '/staff/loans/{id}/receipt.pdf' => ['get' => O::op($t, 'staffLoanReceipt', 'Loan receipt PDF (30/min)', [ '200' => O::file('application/pdf', 'Content-Disposition: attachment; filename="prestito_{id}_{Ymd}.pdf".'), diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index 34027c1fb..8caf1842d 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -95,6 +95,15 @@ class NcipServerPlugin private \mysqli $db; private ?int $pluginId = null; + /** + * Set by extendLoan() when a later commitment bounds the renewal: the + * longest renewal still possible (LoanWindow::renewalCap()), so the + * RenewItem Problem can say until when instead of a bare "not renewable". + * + * @var array{first_conflict_date: string, max_due_date: string, max_extension_days: int}|null + */ + private ?array $renewalCap = null; + /** Partner attivo risolto dal FromAgencyId del messaggio corrente (per il log transazioni). */ private ?int $currentPartnerId = null; @@ -1091,9 +1100,22 @@ private function handleRenewItem( default => 'temporary-processing-failure', }; SecureLogger::error('[NcipServer] extendLoan failed: ' . $failureReason); + // ProblemDetail is free text for the partner's operator: when a later + // commitment bounds the renewal, say until when it is still possible. + $detail = 'Failed to extend loan'; + $cap = $this->renewalCap; + if ($cap !== null) { + // The limiting commitment may concern only the assigned copy, + // even when the book also reaches full capacity on a later day. + $conflictDetail = 'a conflicting loan or reservation blocks the extension from ' . $cap['first_conflict_date']; + $detail .= $cap['max_extension_days'] < 1 + ? ': no renewal is possible; ' . $conflictDetail + : ': renewal possible at most until ' . $cap['max_due_date'] . ' (' . $cap['max_extension_days'] + . ' day' . ($cap['max_extension_days'] === 1 ? '' : 's') . '); ' . $conflictDetail; + } return $this->xmlResponse( $response, - $this->buildProblem('Failed to extend loan', $problemType) + $this->buildProblem($detail, $problemType) ); } @@ -2679,6 +2701,7 @@ private function extendLoan( ): ?string { $failureReason = 'db_error'; + $this->renewalCap = null; if ($expectedBookId <= 0 || $expectedUserId <= 0) { $failureReason = 'identity_changed'; return null; @@ -2762,15 +2785,18 @@ private function extendLoan( // [currentDue, newDue] un RenewItem falliva dove il rinnovo web // identico riusciva (giorno di confine contato due volte). $extensionStart = (new \DateTimeImmutable($currentDue))->modify('+1 day')->format('Y-m-d'); + $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; $capacity = new \App\Services\CapacityService($this->db); if (!$capacity->hasFreeCapacity($expectedBookId, $extensionStart, $newDueDate, excludePrestitoId: $loanId)) { + $blocked = $capacity->renewalConflictDates($expectedBookId, $loanId, $copyId, $currentDue, $newDueDate); $this->db->rollback(); $failureReason = 'no_capacity'; + // Same figures the web renew() redirect carries (LoanWindow). + $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked); return null; } - $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; if ($copyId !== null) { $applicationToday = \App\Support\DateHelper::today(); $overlap = $this->db->prepare( @@ -2789,8 +2815,11 @@ private function extendLoan( $hasOverlap = (bool) $overlap->get_result()->fetch_row(); $overlap->close(); if ($hasOverlap) { + $blocked = $capacity->renewalConflictDates($expectedBookId, $loanId, $copyId, $currentDue, $newDueDate); $this->db->rollback(); $failureReason = 'overlap'; + // The loan's own copy is needed by a later loan: name that day. + $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked); return null; } } diff --git a/tests/desiderata-inventory-prefix-489.spec.js b/tests/desiderata-inventory-prefix-489.spec.js new file mode 100644 index 000000000..177f98454 --- /dev/null +++ b/tests/desiderata-inventory-prefix-489.spec.js @@ -0,0 +1,171 @@ +// @ts-check +/** + * #489 — a copy received through Desiderata carries the book's own + * "Prefisso inventario copie", through the real admin screens. + * + * Each wanted book is created from the admin book form with a prefix, then + * received through one of the three buttons an operator actually has: + * + * A. edit the record, untick "Desiderata", save with two copies + * B. "Libro donato: registra la copia" on /admin/desiderata + * C. a donor's proposal: "Accetta proposta", then + * "Libro arrivato: registra una copia" + * + * The code is read where the operator reads it: the Copie Fisiche table of the + * book page. A fourth book without a prefix keeps the LIB--C1 default. + * + * Run: /tmp/run-e2e.sh tests/desiderata-inventory-prefix-489.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('node:child_process'); +const crypto = require('node:crypto'); +const { ensurePluginActive } = require('./helpers/plugin-activation'); + +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; + +test.skip(!ADMIN_EMAIL || !ADMIN_PASS || !DB_NAME, 'Requires an installed app, E2E_ADMIN_* and E2E_DB_* credentials'); +test.describe.configure({ mode: 'serial' }); + +// Unique per run: copy codes are globally unique, so a fixed prefix would +// continue a previous run's -C family instead of starting at -C1. +const TAG = 'E2E489' + crypto.randomBytes(4).toString('hex'); +const bookIds = []; + +function dbQuery(sql) { + const args = ['-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql]; + if (DB_SOCKET) args.splice(2, 0, '-S', DB_SOCKET); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); +} + +async function login(page) { + await page.goto('/accedi'); + await page.getByRole('textbox', { name: 'Email', exact: true }).fill(ADMIN_EMAIL); + await page.getByRole('textbox', { name: 'Password', exact: true }).fill(ADMIN_PASS); + await page.getByRole('button', { name: 'Accedi', exact: true }).click(); + await page.waitForURL(/\/admin/); +} + +/** Submit the core book form, confirming its SweetAlert when it asks. */ +async function submitBookForm(page) { + await page.locator('#bookForm button[type="submit"]').click(); + const confirm = page.locator('.swal2-confirm'); + if (await confirm.isVisible({ timeout: 5000 }).catch(() => false)) { + await confirm.click(); + } + await page.waitForURL(/\/admin\/books\/(?!create|edit)\d+/, { timeout: 15000 }); +} + +/** A wanted book created from the admin form, as an operator does it. */ +async function createWanted(page, label, prefix) { + const title = `${TAG} ${label}`; + await page.goto('/admin/books/create'); + await page.fill('#titolo', title); + await page.getByRole('checkbox', { name: 'Desiderata: cerchiamo questo libro', exact: true }).check(); + if (prefix !== null) { + await page.fill('#numero_inventario', prefix); + } + await submitBookForm(page); + const id = Number(dbQuery(`SELECT id FROM libri WHERE titolo = '${title}' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1`)); + expect(id).toBeGreaterThan(0); + bookIds.push(id); + expect(dbQuery(`SELECT is_desiderata FROM libri WHERE id = ${id}`)).toBe('1'); + return { id, title }; +} + +/** The codes the operator sees in the book page's Copie Fisiche table. */ +async function expectCopyCodes(page, bookId, codes) { + await page.goto(`/admin/books/${bookId}#physical-copies`); + const section = page.locator('#physical-copies'); + for (const code of codes) { + await expect(section).toContainText(code); + } + const stored = dbQuery(`SELECT GROUP_CONCAT(numero_inventario ORDER BY id SEPARATOR ',') FROM copie WHERE libro_id = ${bookId}`); + expect(stored).toBe(codes.join(',')); +} + +test.beforeAll(async ({ browser }) => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + await login(page); + await ensurePluginActive(page, 'desiderata', { smokePath: '/desiderata' }); + } finally { + await context.close(); + } +}); + +test.afterAll(() => { + if (bookIds.length === 0) return; + const list = bookIds.join(','); + dbQuery(`DELETE FROM desiderata_offers WHERE book_id IN (${list}) OR received_book_id IN (${list})`); + dbQuery(`DELETE FROM admin_notifications WHERE title LIKE '%${TAG}%' OR message LIKE '%${TAG}%'`); + dbQuery(`DELETE FROM copie WHERE libro_id IN (${list})`); + dbQuery(`DELETE FROM log_modifiche WHERE tabella = 'libri' AND record_id IN (${list})`); + dbQuery(`DELETE FROM libri WHERE id IN (${list})`); +}); + +test('A — closing the request from the book form uses the prefix', async ({ page }) => { + await login(page); + const prefix = `BIB-${TAG}A`; + const book = await createWanted(page, 'form', prefix); + await page.goto(`/admin/books/edit/${book.id}`); + await page.getByRole('checkbox', { name: 'Desiderata: cerchiamo questo libro', exact: true }).uncheck(); + await page.fill('#desiderata_copies', '2'); + await submitBookForm(page); + await expectCopyCodes(page, book.id, [`${prefix}-C1`, `${prefix}-C2`]); +}); + +test('B — "Libro donato" on the requests list uses the prefix', async ({ page }) => { + await login(page); + const prefix = `BIB-${TAG}B`; + const book = await createWanted(page, 'direct', prefix); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('/admin/desiderata'); + const row = page.locator('li').filter({ hasText: book.title }); + // The click starts on /admin/desiderata, so waiting for that URL returns at once: + // wait for the receipt POST itself before reading the copies. + await Promise.all([ + page.waitForResponse((res) => res.request().method() === 'POST' && /\/admin\/desiderata\/books\/\d+\/received/.test(res.url())), + row.getByRole('button', { name: 'Libro donato: registra la copia', exact: true }).click(), + ]); + await expectCopyCodes(page, book.id, [`${prefix}-C1`]); +}); + +test('C — a donor proposal received through the admin buttons uses the prefix', async ({ page }) => { + await login(page); + const prefix = `BIB-${TAG}C`; + const book = await createWanted(page, 'offer', prefix); + // The donor's side is not under test here: the proposal row is what the + // public form writes, inserted directly so this spec does not depend on the + // anonymous form's rate limiter or captcha settings. + dbQuery(`INSERT INTO desiderata_offers (book_id, donor_name, donor_email, title, notes, status) VALUES (${book.id}, 'E2E donor', 'donor-${TAG}@example.invalid', '${book.title}', '', 'pending')`); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('/admin/desiderata'); + let offer = page.locator('article').filter({ hasText: book.title }); + await offer.getByRole('button', { name: 'Accetta proposta', exact: true }).click(); + offer = page.locator('article').filter({ hasText: book.title }); + await expect(offer).toContainText('In attesa della consegna'); + await offer.getByRole('button', { name: 'Libro arrivato: registra una copia', exact: true }).click(); + await expect(page.locator('article').filter({ hasText: book.title })).toContainText('Ricevuta'); + await expectCopyCodes(page, book.id, [`${prefix}-C1`]); +}); + +test('without a prefix the copy keeps the LIB- default', async ({ page }) => { + await login(page); + const book = await createWanted(page, 'no prefix', null); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('/admin/desiderata'); + const row = page.locator('li').filter({ hasText: book.title }); + // The click starts on /admin/desiderata, so waiting for that URL returns at once: + // wait for the receipt POST itself before reading the copies. + await Promise.all([ + page.waitForResponse((res) => res.request().method() === 'POST' && /\/admin\/desiderata\/books\/\d+\/received/.test(res.url())), + row.getByRole('button', { name: 'Libro donato: registra la copia', exact: true }).click(), + ]); + await expectCopyCodes(page, book.id, [`LIB-${book.id}-C1`]); +}); diff --git a/tests/desiderata-inventory-prefix-489.unit.php b/tests/desiderata-inventory-prefix-489.unit.php new file mode 100644 index 000000000..00fb86736 --- /dev/null +++ b/tests/desiderata-inventory-prefix-489.unit.php @@ -0,0 +1,214 @@ +-C1 codes it + * cannot correct (a copy's code is written once and is editable nowhere, by + * design). This suite receives a book through each of those paths and reads + * back the code that was actually written: + * + * A. the book form — request closed by saving the record with copies + * (prepareBook() + bookSaved() → registerCopies()) + * B. direct receipt — "received at the desk", no proposal (receiveDirect()) + * C. donation offer — a proposal marked received (manage()) + * + * Each path runs twice: once on a book WITH a prefix (codes must be + * "-C") and once on a book WITHOUT one (codes must stay + * "LIB--C", the historical default). The Mobile API receive endpoints + * delegate to B and C, so they are covered by the same assertions. + * + * Real database, real plugin methods, no stubs except the mailer. FAILS HARD + * when the database is unreachable: a skip would be indistinguishable from a + * pass. Fixtures are removed by id at the end. + * + * Run: php tests/desiderata-inventory-prefix-489.unit.php + */ + +$root = dirname(__DIR__); +require $root . '/vendor/autoload.php'; +require_once $root . '/storage/plugins/desiderata/wrapper.php'; + +use App\Models\CopyRepository; + +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + +$pass = 0; +$fail = 0; +$check = static function (bool $ok, string $label) use (&$pass, &$fail): void { + echo ($ok ? 'PASS ' : 'FAIL ') . $label . "\n"; + $ok ? $pass++ : $fail++; +}; + +/** .env is parsed by hand: this file must run as a bare `php tests/...` process. */ +$env = []; +foreach (preg_split('/\r?\n/', (string) @file_get_contents($root . '/.env')) ?: [] as $line) { + if (!str_contains($line, '=') || str_starts_with(trim($line), '#')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $env[trim($key)] = trim(trim($value), "\"'"); +} + +$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? '/opt/homebrew/var/mysql/mysql.sock'); +$dbUser = getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''); +$dbPass = getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')); +$dbName = getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''); +$db = $socket !== '' && file_exists($socket) + ? new mysqli(null, $dbUser, $dbPass, $dbName, 0, $socket) + : new mysqli($env['DB_HOST'] ?? '127.0.0.1', $dbUser, $dbPass, $dbName, (int) ($env['DB_PORT'] ?? 3306)); +$db->set_charset('utf8mb4'); + +$plugin = new DesiderataPlugin($db, new App\Support\HookManager($db)); +// Receipts notify the operators by e-mail: a test must never mail anyone. +$plugin->setEmailService(new class($db) extends App\Support\EmailService { + public function sendEmail(string $to, string $subject, string $body, string $toName = '', ?string $locale = null, array $attachments = []): bool + { + return true; + } +}); + +// A per-run tag keeps the prefixes globally unique: numero_inventario has a +// GLOBAL unique index, so a fixed "BIB-" would collide with a previous run or +// with a real library's codes and the allocator would (correctly) skip to -C2. +$tag = 'INV489' . bin2hex(random_bytes(4)); +$bookIds = []; +$offerIds = []; +$fatalError = null; + +$repo = new App\Models\BookRepository($db); +$factory = new Slim\Psr7\Factory\ServerRequestFactory(); +$post = static fn (string $path, array $body) => $factory->createServerRequest('POST', $path)->withParsedBody($body); + +/** A wanted book (desiderata), created through the plugin's own form filter. */ +$createWanted = static function (string $suffix, ?string $prefix) use ($plugin, $repo, $tag, &$bookIds): int { + $fields = ['titolo' => $tag . ' ' . $suffix, 'copie_totali' => 1, 'numero_inventario' => $prefix]; + $fields = $plugin->prepareBook($fields, ['desiderata_form' => '1', 'is_desiderata' => '1'], null); + $id = $repo->createBasic($fields); + $bookIds[] = $id; + return $id; +}; + +/** @return list the codes of the book's copies, oldest first */ +$codesOf = static function (int $bookId) use ($db): array { + $stmt = $db->prepare('SELECT numero_inventario FROM copie WHERE libro_id = ? ORDER BY id'); + $stmt->bind_param('i', $bookId); + $stmt->execute(); + $codes = array_map(static fn (array $row): string => (string) $row[0], $stmt->get_result()->fetch_all(MYSQLI_NUM)); + $stmt->close(); + return $codes; +}; + +$show = static fn (array $codes): string => '[' . implode(', ', $codes) . ']'; + +try { + // Built, never skipped: the receipt paths need is_desiderata and the + // offers table, and a missing one would make every path below a no-op. + $plugin->ensureSchema(); + if (!App\Support\BookVisibility::hasDesiderata($db)) { + throw new RuntimeException('libri.is_desiderata is absent after ensureSchema()'); + } + + // One prefix per book: codes are globally unique, so books sharing a + // prefix continue one "-C" family instead of each starting at -C1. + foreach (['prefixed' => true, 'unprefixed' => false] as $case => $withPrefix) { + $prefixFor = static fn (string $path): ?string => $withPrefix ? "BIB-{$tag}{$path}" : null; + $expected = static fn (int $id, ?string $prefix, int $n): string => ($prefix ?? "LIB-{$id}") . "-C{$n}"; + + echo "\nA. Book form receipt ({$case})\n"; + $_SESSION = []; + $prefix = $prefixFor('A'); + $bookA = $createWanted("form {$case}", $prefix); + // The edit that closes the request: flag unticked, two copies asked for. + // prepareBook() is the book.form.save filter, bookSaved() the + // book.save.after action — the same pair core fires on a real save. + $plugin->prepareBook(['titolo' => $tag . " form {$case}"], ['desiderata_form' => '1', 'is_desiderata' => '0', 'desiderata_copies' => '2'], $bookA); + $plugin->bookSaved($bookA); + $codes = $codesOf($bookA); + $want = [$expected($bookA, $prefix, 1), $expected($bookA, $prefix, 2)]; + $check($codes === $want, "two copies coded {$show($want)} (got {$show($codes)})"); + + echo "\nB. Direct receipt at the desk ({$case})\n"; + $_SESSION = []; + $prefix = $prefixFor('B'); + $bookB = $createWanted("direct {$case}", $prefix); + $response = $plugin->receiveDirect($post('/admin/desiderata/books/' . $bookB . '/receive', []), new Slim\Psr7\Response(), $bookB); + $codes = $codesOf($bookB); + $check($response->getStatusCode() === 303, 'the receipt is accepted (got HTTP ' . $response->getStatusCode() . ')'); + $want = [$expected($bookB, $prefix, 1)]; + $check($codes === $want, "one copy coded {$show($want)} (got {$show($codes)})"); + + echo "\nC. Donation offer marked received ({$case})\n"; + $_SESSION = []; + $prefix = $prefixFor('C'); + $bookC = $createWanted("offer {$case}", $prefix); + $title = $tag . " offer {$case}"; + $stmt = $db->prepare("INSERT INTO desiderata_offers (book_id, donor_name, donor_email, title, notes, status) VALUES (?, 'Test donor', 'donor@example.invalid', ?, '', 'accepted')"); + $stmt->bind_param('is', $bookC, $title); + $stmt->execute(); + $offerId = (int) $db->insert_id; + $stmt->close(); + $offerIds[] = $offerId; + $response = $plugin->manage($post('/admin/desiderata/offers/' . $offerId, ['action' => 'received']), new Slim\Psr7\Response(), $offerId); + $codes = $codesOf($bookC); + $check($response->getStatusCode() === 303, 'the receipt is accepted (got HTTP ' . $response->getStatusCode() . ')'); + $want = [$expected($bookC, $prefix, 1)]; + $check($codes === $want, "one copy coded {$show($want)} (got {$show($codes)})"); + } + + echo "\nD. The rule itself\n"; + $check(CopyRepository::inventoryBase(7, 'BIB-') === 'BIB-', 'a prefix is used as the base'); + $check(CopyRepository::inventoryBase(7, ' BIB- ') === 'BIB-', 'surrounding whitespace is not part of the base'); + $check(CopyRepository::inventoryBase(7, '0') === '0', 'a prefix of "0" is a prefix, not an empty value'); + foreach ([null, '', ' '] as $blank) { + $check(CopyRepository::inventoryBase(7, $blank) === 'LIB-7', 'no prefix (' . var_export($blank, true) . ') falls back to LIB-{id}'); + } + $check(CopyRepository::inventoryBase(7, ['BIB-']) === 'LIB-7', 'a non-scalar prefix falls back to LIB-{id}'); + $check(CopyRepository::inventoryBase(7, new stdClass()) === 'LIB-7', 'an object prefix falls back to LIB-{id}'); + $long = str_repeat('P', 120); + $check(CopyRepository::inventoryBase(7, $long) === $long, 'a long prefix is returned whole; the allocator shortens it, not the rule'); +} catch (Throwable $thrown) { + $fatalError = $thrown; +} finally { + $cleanupErrors = []; + $run = static function (string $sql) use ($db, &$cleanupErrors): void { + try { + $db->query($sql); + } catch (Throwable $e) { + $cleanupErrors[] = $sql . ': ' . $e->getMessage(); + } + }; + if ($offerIds !== []) { + $run('DELETE FROM desiderata_offers WHERE id IN (' . implode(',', $offerIds) . ')'); + } + if ($bookIds !== []) { + $list = implode(',', $bookIds); + // Paths A and B write their own audit row (status 'received') per book. + $run('DELETE FROM desiderata_offers WHERE book_id IN (' . $list . ') OR received_book_id IN (' . $list . ')'); + $run('DELETE FROM copie WHERE libro_id IN (' . $list . ')'); + $run("DELETE FROM log_modifiche WHERE tabella = 'libri' AND record_id IN (" . $list . ')'); + $run('DELETE FROM libri WHERE id IN (' . $list . ')'); + } + // notifyReceipt() rings the operators' bell with the book title. + $run("DELETE FROM admin_notifications WHERE title LIKE '%" . $db->real_escape_string($tag) . "%' OR message LIKE '%" . $db->real_escape_string($tag) . "%'"); + try { + App\Support\ContentCache::booksChanged(); + } catch (Throwable $e) { + $cleanupErrors[] = 'ContentCache::booksChanged(): ' . $e->getMessage(); + } +} + +foreach ($cleanupErrors as $message) { + fwrite(STDERR, "CLEANUP FAILED: {$message}\n"); +} +if ($fatalError !== null) { + fwrite(STDERR, "\nFAIL: " . get_class($fatalError) . ': ' . $fatalError->getMessage() . "\n"); + exit(1); +} + +echo "\n" . ($fail === 0 ? "ALL {$pass} PASS\n" : "{$pass} PASS, {$fail} FAIL\n"); +exit($fail === 0 && $cleanupErrors === [] ? 0 : 1); diff --git a/tests/digital-storage-permissions-upgrade.unit.php b/tests/digital-storage-permissions-upgrade.unit.php new file mode 100644 index 000000000..3d9178c9b --- /dev/null +++ b/tests/digital-storage-permissions-upgrade.unit.php @@ -0,0 +1,79 @@ +newInstanceWithoutConstructor(); + $property = new ReflectionProperty(Updater::class, 'rootPath'); + $property->setValue($updater, $root); + (new ReflectionMethod(Updater::class, 'fixPermissions'))->invoke($updater); + + $check($mode("{$root}/storage/digital") === '0775', 'fixPermissions() leaves storage/digital with its group write bits', $mode("{$root}/storage/digital")); + $check($mode("{$root}/storage/digital/20261021_book.pdf") === '0644', 'the files inside still get 0644 from fixPermissions()', $mode("{$root}/storage/digital/20261021_book.pdf")); + $check($mode("{$root}/storage/uploads") === '0755', 'the other storage folders keep what fixPermissions() always gave them', $mode("{$root}/storage/uploads")); + $check(is_file("{$root}/storage/digital/.htaccess") && str_contains((string) file_get_contents("{$root}/storage/digital/.htaccess"), 'Require all denied'), + 'the repair keeps storage/digital shut to direct requests'); + + chmod("{$root}/storage/digital", 0755); + DigitalStorage::ensureDir("{$root}/storage/digital"); + $check($mode("{$root}/storage/digital") === '0775', 'an existing 0755 storage/digital (an install upgraded from 0.8.6) gets 0775 back', $mode("{$root}/storage/digital")); + + chmod("{$root}/storage/digital", 0750); + DigitalStorage::ensureDir("{$root}/storage/digital"); + $check($mode("{$root}/storage/digital") === '0770', 'a stricter 0750 chosen by the administrator gets group write, never "other" bits', $mode("{$root}/storage/digital")); + + $rmTree("{$root}/storage/digital"); + $updaterNoDigital = (new ReflectionClass(Updater::class))->newInstanceWithoutConstructor(); + $property->setValue($updaterNoDigital, $root); + (new ReflectionMethod(Updater::class, 'fixPermissions'))->invoke($updaterNoDigital); + $check(!is_dir("{$root}/storage/digital"), 'fixPermissions() does not create storage/digital where an install has none'); +} finally { + umask($previousUmask); + if (is_dir($root)) { + $rmTree($root); + } +} + +echo "\nALL {$n} PASS\n"; diff --git a/tests/digital-upgrade-480.spec.js b/tests/digital-upgrade-480.spec.js index 235426d07..ec41cdc77 100644 --- a/tests/digital-upgrade-480.spec.js +++ b/tests/digital-upgrade-480.spec.js @@ -2,9 +2,10 @@ // Issue #480 — Digital Library files uploaded on an older release, then the upgrade. // // Run twice by scripts/reinstall-test.sh (Test B), against the same sandbox install: -// E2E_UPGRADE_PHASE=before on the old release (files go to public/uploads/digital and -// anyone can download them), the admin uploads through the -// book form like a person would; +// E2E_UPGRADE_PHASE=before on the old release (files go to public/uploads/digital up to +// 0.8.5, to storage/digital from 0.8.6-rc.1, and anyone can +// download them), the admin uploads through the book form +// like a person would; // E2E_UPGRADE_PHASE=after on the upgraded install: the same files, byte for byte, now // in storage/digital, still served at the same addresses, and // the per-file visibility of 0.8.6 enforced on them. @@ -26,6 +27,27 @@ const PHASE = process.env.E2E_UPGRADE_PHASE || ''; const STATE = process.env.E2E_UPGRADE_STATE || path.join(ROOT, 'storage/tmp/digital-upgrade-480.json'); const STORAGE = path.join(ROOT, 'storage/digital'); const LEGACY = path.join(ROOT, 'public/uploads/digital'); + +/** version_compare() for X.Y.Z[-rc.N]: a prerelease sorts before its final. */ +function compareVersions(a, b) { + const split = v => { const [core, pre = ''] = String(v).split('-', 2); return [core.split('.').map(Number), pre]; }; + const [ca, pa] = split(a), [cb, pb] = split(b); + for (let i = 0; i < Math.max(ca.length, cb.length); i++) { + const d = (ca[i] || 0) - (cb[i] || 0); + if (d !== 0) return d < 0 ? -1 : 1; + } + if (pa === pb) return 0; + if (pa === '') return 1; + if (pb === '') return -1; + return pa < pb ? -1 : 1; +} +// Where the installed release writes a new upload: out of the web root since #480. +function uploadFolder() { + const version = JSON.parse(fs.readFileSync(path.join(ROOT, 'version.json'), 'utf8')).version; + return compareVersions(version, '0.8.6-rc.1') >= 0 + ? { dir: STORAGE, label: 'storage/digital', version } + : { dir: LEGACY, label: 'public/uploads/digital', version }; +} // Without a phase this is the regular browser run, where the spec has nothing to do; a // reason with "required" there would read as broken infrastructure to the skip audit. test.skip(!PHASE, 'Runs only around a real upgrade (phase given by E2E_UPGRADE_PHASE)'); @@ -182,12 +204,17 @@ test.describe.serial(`Digital Library across the upgrade (#480) — ${PHASE}`, ( } await saveBook(page); - // The old release keeps them in the public uploads folder, as uploaded, and lists them on the book. + // The old release keeps them where it writes uploads (the public folder up to 0.8.5, + // storage/digital since 0.8.6-rc.1), as uploaded, and lists them on the book. + const folder = uploadFolder(); for (const { url, sha: digest } of Object.values(state.files)) { expect(url).toMatch(/^\/uploads\/digital\/[A-Za-z0-9._-]+\.(pdf|mp3)$/); - const file = path.join(LEGACY, path.basename(url)); - expect(fs.existsSync(file), `${url} in public/uploads/digital`).toBe(true); + const file = path.join(folder.dir, path.basename(url)); + expect(fs.existsSync(file), `${url} in ${folder.label} on ${folder.version}`).toBe(true); expect(sha(fs.readFileSync(file))).toBe(digest); + if (folder.dir === STORAGE) { + expect(fs.existsSync(path.join(LEGACY, path.basename(url))), `${url} not also in public/uploads/digital on ${folder.version}`).toBe(false); + } } const listed = db(`SELECT CONCAT_WS('|', file_url, audio_url) FROM libri WHERE id=${state.bookId}`) + (state.mode === 'attachments' ? db(`SELECT digital_attachments FROM libri WHERE id=${state.bookId}`) : ''); diff --git a/tests/loan-coherence-audit.unit.php b/tests/loan-coherence-audit.unit.php index a02102c6d..18a899e57 100644 --- a/tests/loan-coherence-audit.unit.php +++ b/tests/loan-coherence-audit.unit.php @@ -290,12 +290,17 @@ && str_contains($loanCreateView, 'blockedByReservation: blockedByReservation') && str_contains($loanCreateView, 'borrowerAlreadyReserved'); +// The configured duration reaches both calendars through loanWindowFrom(), +// which also stops the proposed return date before a scheduled loan or a +// reservation; the day arithmetic stays UTC-based (addDaysToIso). $checks['loan calendars use the configured duration without local-time date arithmetic'] = str_contains($loanCreateView, 'const defaultLoanDays =') - && str_contains($loanCreateView, 'addDaysToIso(dateStr, defaultLoanDays)') + && str_contains($loanCreateView, 'loanWindowFrom(startIso, effectiveLoanDays)') + && str_contains($loanCreateView, 'addDaysToIso(startIso, defaultDays)') && !str_contains($loanCreateView, 'setMonth(endDate.getMonth() + 1)') - && str_contains($frontend, '$defaultRequestLoanDays = min(') - && str_contains($bookDetail, 'addDaysToIso(dateStr, defaultRequestLoanDays)') + && str_contains($frontend, '$defaultRequestLoanDays = $loanSettings->requestLoanDurationDays()') + && str_contains($bookDetail, 'loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays)') + && str_contains($bookDetail, 'addDaysToIso(startIso, defaultDays)') && !str_contains($bookDetail, 'setMonth(endDate.getMonth() + 1)'); // 21. Server-rendered edit fallback follows the same application clock. diff --git a/tests/loan-extension-281.unit.php b/tests/loan-extension-281.unit.php index a9ed724dd..e37d59c8c 100644 --- a/tests/loan-extension-281.unit.php +++ b/tests/loan-extension-281.unit.php @@ -217,7 +217,8 @@ $check($bookLockPos !== false && $loanLockPos !== false && $bookLockPos < $loanLockPos, 'bulk path locks books before loans in deterministic order'); $check(str_contains($bulkSource, 'hasFreeCapacity(') && str_contains($bulkSource, 'excludePrestitoId: $loanId'), 'every proposed window passes through canonical CapacityService'); $check(str_contains($bulkSource, 'copia_id = ? AND id <> ?'), 'bulk path rejects overlap on the assigned physical copy'); -$check(str_contains($bulkSource, '?error=bulk_extend_conflict') && str_contains($bulkSource, '$db->rollback();'), 'capacity/copy conflict has a dedicated all-or-nothing response'); +$check(str_contains($bulkSource, "http_build_query(['error' => 'bulk_extend_conflict', 'loan_id' =>") + && str_contains($bulkSource, '$db->rollback();'), 'capacity/copy conflict has a dedicated all-or-nothing response with the affected loan'); $check(!str_contains($bulkSource, 'DATE_ADD(data_scadenza'), 'unsafe set-based extension is absent'); $cleanup(); diff --git a/tests/loan-renewal-window.unit.php b/tests/loan-renewal-window.unit.php new file mode 100644 index 000000000..4b6850d57 --- /dev/null +++ b/tests/loan-renewal-window.unit.php @@ -0,0 +1,395 @@ +set_charset('utf8mb4'); + DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — renewal window suite is mandatory: {$e->getMessage()}\n"); + exit(1); +} + +$run = bin2hex(random_bytes(5)); +$prefix = "ZZ_LWRNW_{$run}"; +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label, string $detail = '') use (&$passed, &$failed): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . ($ok || $detail === '' ? '' : " [{$detail}]") . PHP_EOL; + $ok ? $passed++ : $failed++; +}; + +/** @var int[] $bookIds */ +$bookIds = []; +/** @var int[] $userIds */ +$userIds = []; +$cleanup = static function () use ($db, &$bookIds, &$userIds): void { + foreach ($bookIds as $id) { + $db->query("DELETE FROM prestiti WHERE libro_id = {$id}"); + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$id}"); + $db->query("DELETE FROM copie WHERE libro_id = {$id}"); + $db->query("DELETE FROM libri WHERE id = {$id}"); + } + foreach ($userIds as $id) { + $db->query("DELETE FROM utenti WHERE id = {$id}"); + } +}; +set_exception_handler(static function (Throwable $e) use ($cleanup, $db): void { + try { $cleanup(); } catch (Throwable) {} + fwrite(STDERR, 'FAIL: uncaught ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . PHP_EOL); + $db->close(); + exit(1); +}); + +// ── Sandbox ────────────────────────────────────────────────────────────────── +$mkUser = static function (int $n, string $tipo = 'standard') use ($db, $prefix, $run, &$userIds): int { + // codice_tessera is a short column: a run tag plus the sequence fits it. + $card = 'LR' . substr($run, 0, 8) . $n; + $email = strtolower("{$prefix}-u{$n}@example.invalid"); + $cognome = "{$prefix} {$n}"; + $stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) + VALUES (?, 'LoanWindow', ?, ?, 'x', 'attivo', ?, 1)"); + $stmt->bind_param('ssss', $card, $cognome, $email, $tipo); + $stmt->execute(); + $stmt->close(); + $userIds[] = (int) $db->insert_id; + return (int) $db->insert_id; +}; +$mkBook = static function (int $copies, int $n) use ($db, $prefix, &$bookIds): array { + $title = "{$prefix} book {$n}"; + $stmt = $db->prepare("INSERT INTO libri (titolo, copie_totali, copie_disponibili, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())"); + $stmt->bind_param('sii', $title, $copies, $copies); + $stmt->execute(); + $stmt->close(); + $bookId = (int) $db->insert_id; + $bookIds[] = $bookId; + $copyIds = []; + for ($i = 1; $i <= $copies; $i++) { + $code = "{$prefix}-{$bookId}-C{$i}"; + $stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'disponibile')"); + $stmt->bind_param('is', $bookId, $code); + $stmt->execute(); + $stmt->close(); + $copyIds[] = (int) $db->insert_id; + } + return [$bookId, $copyIds]; +}; +$mkLoan = static function (int $bookId, ?int $copyId, int $userId, string $stato, string $from, string $to) use ($db): int { + $stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo, renewals, warning_sent, overdue_notification_sent) + VALUES (?, ?, ?, ?, ?, ?, 'diretto', 1, 0, 0, 0)"); + $stmt->bind_param('iiisss', $bookId, $copyId, $userId, $from, $to, $stato); + $stmt->execute(); + $stmt->close(); + return (int) $db->insert_id; +}; +$addDays = static fn (string $ymd, int $days): string => (new DateTimeImmutable($ymd))->modify(($days >= 0 ? '+' : '') . "{$days} days")->format('Y-m-d'); +$redirectParams = static function ($response): array { + $query = parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY); + $params = []; + parse_str(is_string($query) ? $query : '', $params); + return $params; +}; +$loanRow = static fn (int $id): array => $db->query("SELECT data_scadenza, renewals FROM prestiti WHERE id = {$id}")->fetch_assoc() ?: []; + +$settings = new SettingsRepository($db); +$renewDays = $settings->loanDurationDays(); +$maxRenewals = (int) ($settings->get('loans', 'max_renewals', '3') ?? 3); +if ($maxRenewals === 0) { + echo "SKIP: loans.max_renewals is 0 on this installation: renew() refuses every renewal before any window is computed." . PHP_EOL; + exit(0); +} +$today = DateHelper::today(); +// The borrower's loan is due in two days; the other patron's scheduled loan +// starts a few days later, inside the standard renewal. +$dueDate = $addDays($today, 2); +$offset = max(2, min(5, $renewDays - 1)); +$conflictStart = $addDays($dueDate, $offset); +$expectedMaxDue = $addDays($conflictStart, -1); +$expectedMaxDays = $offset - 1; + +$borrower = $mkUser(1); +$otherUser = $mkUser(2); +$admin = $mkUser(3, 'admin'); +[$book, $copies] = $mkBook(1, 1); +$ownLoan = $mkLoan($book, $copies[0], $borrower, 'in_corso', $addDays($today, -10), $dueDate); +$db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$copies[0]}"); +$mkLoan($book, $copies[0], $otherUser, 'prenotato', $conflictStart, $addDays($conflictStart, 14)); +echo "Sandbox: today {$today}, own loan due {$dueDate}, scheduled loan from {$conflictStart}, renewal {$renewDays} days" . PHP_EOL; + +$asAdmin = static function () use ($admin): void { + $_SESSION['user'] = ['tipo_utente' => 'admin', 'id' => $admin]; +}; +$renew = static function (int $loanId, array $body = []) use ($db, $asAdmin, $redirectParams): array { + $asAdmin(); + $request = (new ServerRequestFactory())->createServerRequest('POST', "/admin/loans/renew/{$loanId}")->withParsedBody($body); + $response = (new PrestitiController())->renew($request, new SlimResponse(), $db, $loanId); + return $redirectParams($response); +}; + +// ═════════ 1-3: the helper ═══════════════════════════════════════════════════ +$cap = LoanWindow::renewalCap($dueDate, [$conflictStart, $addDays($conflictStart, 1)]); +$check($cap !== null + && $cap['first_conflict_date'] === $conflictStart + && $cap['max_due_date'] === $expectedMaxDue + && $cap['max_extension_days'] === $expectedMaxDays, + "1 LoanWindow::renewalCap bounds the renewal to {$expectedMaxDays} days ending {$expectedMaxDue}", + json_encode($cap)); +$next = LoanWindow::renewalCap($dueDate, [$addDays($dueDate, 1)]); +$check($next !== null && $next['max_extension_days'] === 0 && $next['max_due_date'] === $dueDate + && LoanWindow::renewalCap($dueDate, []) === null + && LoanWindow::renewalCap($dueDate, [$addDays($dueDate, -3)]) === null, + '2 a conflict on the very next day leaves 0 days; nothing bounds an empty or earlier list'); +$sentence = LoanWindow::explainRenewal($expectedMaxDays, $expectedMaxDue, $conflictStart); +$none = LoanWindow::explainRenewal(0, $dueDate, $addDays($dueDate, 1)); +$check(str_contains($sentence, (string) $expectedMaxDays) + && $none !== $sentence && str_contains($none, substr($addDays($dueDate, 1), 0, 4)) && !str_contains($none, ' 0 ') + && str_contains(LoanWindow::explainRenewal(1, $dueDate, $addDays($dueDate, 2)), '1 ') + && LoanWindow::explainRenewalFromParams(['max_extension_days' => (string) $expectedMaxDays, 'max_due_date' => $expectedMaxDue, 'blocked_from' => $conflictStart]) === $sentence + && LoanWindow::explainRenewalFromParams(['max_extension_days' => 'x', 'max_due_date' => $expectedMaxDue, 'blocked_from' => $conflictStart]) === null, + '3 the sentence names the days (plural, singular, none) and is rebuilt from validated redirect parameters only', + $sentence); + +// ═════════ 4-7: the admin renewal ════════════════════════════════════════════ +$params = $renew($ownLoan); +$check(($params['error'] ?? null) === 'extension_conflicts' + && (int) ($params['loan_id'] ?? 0) === $ownLoan + && (int) ($params['max_extension_days'] ?? -1) === $expectedMaxDays + && ($params['max_due_date'] ?? null) === $expectedMaxDue + && ($params['blocked_from'] ?? null) === $conflictStart, + '4 renew() across the scheduled loan: extension_conflicts with loan_id, max_extension_days, max_due_date, blocked_from', + json_encode($params)); +$before = $loanRow($ownLoan); +$check(($before['data_scadenza'] ?? null) === $dueDate && (int) ($before['renewals'] ?? -1) === 0, + '5 the refused renewal changed nothing'); + +$params = $renew($ownLoan, ['new_due_date' => $addDays($dueDate, -1)]); +$params2 = $renew($ownLoan, ['new_due_date' => $addDays($dueDate, $renewDays + 1)]); +$params3 = $renew($ownLoan, ['new_due_date' => 'not-a-date']); +$check(($params['error'] ?? null) === 'invalid_renewal_date' + && ($params2['error'] ?? null) === 'invalid_renewal_date' + && ($params3['error'] ?? null) === 'invalid_date_format', + '6 new_due_date on or before the current due date, past the standard renewal, or malformed is refused', + json_encode([$params, $params2, $params3])); + +$params = $renew($ownLoan, ['new_due_date' => $expectedMaxDue]); +$after = $loanRow($ownLoan); +$check(($params['renewed'] ?? null) === '1' + && ($after['data_scadenza'] ?? null) === $expectedMaxDue + && (int) ($after['renewals'] ?? 0) === 1, + "7 renew() with new_due_date {$expectedMaxDue} (the day before the scheduled loan) renews until that day", + json_encode([$params, $after])); +// Back to the original due date for the checks below. +$db->query("UPDATE prestiti SET data_scadenza = '{$dueDate}', renewals = 0 WHERE id = {$ownLoan}"); + +// ═════════ 8: a later loan on the loan's own copy (second copy free) ═════════ +[$twoBook, $twoCopies] = $mkBook(2, 2); +$twoLoan = $mkLoan($twoBook, $twoCopies[0], $borrower, 'in_corso', $addDays($today, -10), $dueDate); +$db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$twoCopies[0]}"); +$mkLoan($twoBook, $twoCopies[0], $otherUser, 'prenotato', $conflictStart, $addDays($conflictStart, 14)); +$copyFrom = (new CapacityService($db))->firstCopyCommitmentAfter($twoCopies[0], $twoLoan, $dueDate); +$params = $renew($twoLoan); +$check($copyFrom === $conflictStart + && ($params['error'] ?? null) === 'extension_conflicts' + && ($params['blocked_from'] ?? null) === $conflictStart + && ($params['max_due_date'] ?? null) === $expectedMaxDue, + '8 two copies, the loan\'s own copy needed by a later loan: the refusal names the day the copy is needed from', + json_encode(['copyFrom' => $copyFrom, 'params' => $params])); + +// ═════════ 9: the bulk extension ═════════════════════════════════════════════ +$asAdmin(); +$request = (new ServerRequestFactory())->createServerRequest('POST', '/admin/loans/bulk-extend') + ->withParsedBody(['ids' => [(string) $ownLoan], 'days' => (string) ($offset + 2)]); +$response = (new PrestitiController())->bulkExtend($request, new SlimResponse(), $db); +$params = $redirectParams($response); +$check(($params['error'] ?? null) === 'bulk_extend_conflict' + && (int) ($params['loan_id'] ?? 0) === $ownLoan + && (int) ($params['max_extension_days'] ?? -1) === $expectedMaxDays + && ($params['max_due_date'] ?? null) === $expectedMaxDue + && ($params['blocked_from'] ?? null) === $conflictStart, + '9 bulkExtend across the scheduled loan: bulk_extend_conflict names the loan and the longest extension still possible', + $response->getHeaderLine('Location')); +$check(($loanRow($ownLoan)['data_scadenza'] ?? null) === $dueDate, '10 the refused batch changed nothing'); + +// ═════════ 11-13: the other surfaces read the same figures (source contracts) +$indexSrc = (string) file_get_contents($root . '/app/Views/prestiti/index.php'); +$bookSrc = (string) file_get_contents($root . '/app/Views/libri/scheda_libro.php'); +$check(str_contains($indexSrc, 'LoanWindow::explainRenewalFromParams($_GET)') + && str_contains($indexSrc, 'name="new_due_date"') + && str_contains($indexSrc, 'LoanWindow::displayDate($renewalMaxDue)') + && str_contains($indexSrc, "case 'invalid_renewal_date':") + && str_contains($bookSrc, 'LoanWindow::explainRenewalFromParams($_GET)') + && str_contains($bookSrc, 'name="new_due_date"') + && str_contains($bookSrc, 'LoanWindow::displayDate($renewalMaxDue)') + && str_contains($bookSrc, "case 'invalid_renewal_date':"), + '11 the loans list and the book page explain the refusal and offer "renew until D" with new_due_date'); +$outcomeSrc = (string) file_get_contents($root . '/storage/plugins/mobile-api/src/Support/CirculationOutcome.php'); +$staffSrc = (string) file_get_contents($root . '/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php'); +$check(str_contains($outcomeSrc, 'LoanWindow::explainRenewalFromParams($details)') + && str_contains($outcomeSrc, "\$meta['max_due_date']") + && str_contains($staffSrc, "'new_due_date'") + && substr_count($staffSrc, 'self::windowDetails($outcome)') === 2, + '12 the Mobile API carries the figures in meta on renew and bulk extend, and accepts new_due_date'); +$ncipSrc = (string) file_get_contents($root . '/storage/plugins/ncip-server/NcipServerPlugin.php'); +$check(str_contains($ncipSrc, 'LoanWindow::renewalCap($currentDue, $blocked)') + && str_contains($ncipSrc, 'renewal possible at most until'), + '13 NCIP RenewItem names until when the renewal is still possible in its ProblemDetail'); + +// Exercise the API boundary, not only the presence of source strings: a +// syntactically valid but out-of-range date used to become an unmapped 500. +$staff = new StaffCirculationController($db); +$staffRenew = static function (int $id, array $body = []) use ($staff, $asAdmin): array { + $asAdmin(); + $request = (new ServerRequestFactory())->createServerRequest('POST', "/api/v1/staff/loans/{$id}/renew") + ->withParsedBody($body); + $response = $staff->renew($request, new SlimResponse(), $id); + return [$response->getStatusCode(), json_decode((string) $response->getBody(), true)]; +}; +foreach ([$dueDate, $addDays($dueDate, -1), $addDays($dueDate, $renewDays + 1), 'bad-date', ['date' => $dueDate]] as $invalidDue) { + [$status, $body] = $staffRenew($ownLoan, ['new_due_date' => $invalidDue]); + $expectedMessage = is_string($invalidDue) && DateHelper::isISODateFormat($invalidDue) + ? __('La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.') + : null; + $check($status === 422 && ($body['error']['code'] ?? '') === 'validation_error' + && ($body['meta']['field'] ?? '') === 'new_due_date' + && ($expectedMessage === null || ($body['error']['message'] ?? '') === $expectedMessage) + && $loanRow($ownLoan) === $before, + '14 invalid renewal dates return 422 for new_due_date without changing the loan', json_encode([$status, $body])); +} + +// The assigned copy becomes busy BEFORE the whole book is full. A suggested +// renewal that only considered the later book-wide conflict would fail again +// when submitted. All surfaces must propose the earlier, actually usable day. +$laterConflict = $addDays($conflictStart, 3); +$mkLoan($twoBook, $twoCopies[1], $admin, 'prenotato', $laterConflict, $addDays($laterConflict, 14)); +$capacity = new CapacityService($db); +$blocked = $capacity->renewalConflictDates($twoBook, $twoLoan, $twoCopies[0], $dueDate, $addDays($dueDate, $renewDays)); +$check(min($blocked) === $conflictStart && in_array($laterConflict, $blocked, true) + && $capacity->renewalConflictDates($twoBook, $twoLoan, $twoCopies[0], $dueDate, $addDays($dueDate, 1)) === [], + '15 renewal conflict dates combine the copy and book limits inside the proposed extension'); +$params = $renew($twoLoan); +$check(($params['blocked_from'] ?? '') === $conflictStart && ($params['max_due_date'] ?? '') === $expectedMaxDue, + '16 web renewal proposes the earlier copy limit when both gates conflict', json_encode($params)); +[$status, $body] = $staffRenew($twoLoan); +$check($status === 409 && ($body['meta']['max_due_date'] ?? '') === $expectedMaxDue + && ($body['meta']['first_conflict_date'] ?? '') === $conflictStart, + '17 Mobile renewal names the same earlier copy limit', json_encode([$status, $body])); +$asAdmin(); +$request = (new ServerRequestFactory())->createServerRequest('POST', '/api/v1/staff/loans/bulk-extend') + ->withParsedBody(['ids' => [$twoLoan], 'days' => $renewDays]); +$response = $staff->bulkExtend($request, new SlimResponse()); +$body = json_decode((string) $response->getBody(), true); +$check($response->getStatusCode() === 409 && ($body['meta']['max_due_date'] ?? '') === $expectedMaxDue + && ($body['meta']['loan_id'] ?? 0) === $twoLoan, + '18 Mobile bulk extension proposes the earlier copy limit and names the loan', json_encode($body)); + +$db->query("UPDATE prestiti SET origine = 'ncip' WHERE id = {$twoLoan}"); +$ncip = new NcipServerPlugin($db, new HookManager($db)); +$extend = new ReflectionMethod(NcipServerPlugin::class, 'extendLoan'); +$failureReason = null; +$ncipResult = $extend->invokeArgs($ncip, [$twoLoan, $twoBook, $borrower, &$failureReason]); +$ncipCap = (new ReflectionProperty(NcipServerPlugin::class, 'renewalCap'))->getValue($ncip); +$check($ncipResult === null && $failureReason === 'no_capacity' && ($ncipCap['max_due_date'] ?? '') === $expectedMaxDue + && ($ncipCap['first_conflict_date'] ?? '') === $conflictStart, + '19 NCIP computes the same earlier copy limit', json_encode([$failureReason, $ncipCap])); +$handleRenew = new ReflectionMethod(NcipServerPlugin::class, 'handleRenewItem'); +$xml = new SimpleXMLElement("{$twoBook}{$borrower}"); +$response = $handleRenew->invoke($ncip, (new ServerRequestFactory())->createServerRequest('POST', '/ncip'), new SlimResponse(), $xml, ['tipo_utente' => 'admin']); +$problem = (string) $response->getBody(); +$check(str_contains($problem, 'a conflicting loan or reservation blocks the extension from ' . $conflictStart) + && !str_contains($problem, 'every copy is booked'), + '19b NCIP describes the limiting commitment without claiming every copy is unavailable', $problem); +$db->query("UPDATE prestiti SET origine = 'diretto' WHERE id = {$twoLoan}"); +[$status, $body] = $staffRenew($twoLoan, ['new_due_date' => $expectedMaxDue]); +$check($status === 200 && ($loanRow($twoLoan)['data_scadenza'] ?? '') === $expectedMaxDue, + '20 the proposed shorter renewal succeeds on the next request', json_encode([$status, $body])); + +// Bulk extends an overdue loan from today, so its reported number of days +// must use that same base rather than include the overdue gap. +[$lateBook, $lateCopies] = $mkBook(1, 3); +$lateDue = $addDays($today, -5); +$lateLoan = $mkLoan($lateBook, null, $borrower, 'in_ritardo', $addDays($today, -15), $lateDue); +$laterLoan = $mkLoan($lateBook, $lateCopies[0], $otherUser, 'prenotato', $addDays($today, 4), $addDays($today, 20)); +foreach ([4 => 3, 1 => 0, -1 => 0] as $conflictOffset => $expectedDays) { + $blockedFrom = $addDays($today, $conflictOffset); + $db->query("UPDATE prestiti SET data_prestito = '{$blockedFrom}' WHERE id = {$laterLoan}"); + $asAdmin(); + $request = (new ServerRequestFactory())->createServerRequest('POST', '/api/v1/staff/loans/bulk-extend') + ->withParsedBody(['ids' => [$lateLoan], 'days' => 10]); + $response = $staff->bulkExtend($request, new SlimResponse()); + $body = json_decode((string) $response->getBody(), true); + $check($response->getStatusCode() === 409 && ($body['meta']['max_extension_days'] ?? -1) === $expectedDays + && ($body['meta']['max_due_date'] ?? '') === $addDays($today, $expectedDays) + && ($body['meta']['first_conflict_date'] ?? '') === $blockedFrom + && ($loanRow($lateLoan)['data_scadenza'] ?? '') === $lateDue, + '21 overdue bulk caps count from today and never propose a past due date', json_encode($body)); +} +$previousLocale = I18n::getLocale(); +foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK'] as $locale) { + I18n::setLocale($locale); + $date = LoanWindow::displayDate('2030-10-25'); + $expected = $locale === 'en_US' ? '10-25-2030' : '25-10-2030'; + $check($date === $expected && str_contains(__('Rinnova fino al %s', $date), $expected) + && str_contains(LoanWindow::explainRenewal(3, '2030-10-25', '2030-10-26'), $expected), + "22 renewal action and explanation share the date format in {$locale}"); +} +I18n::setLocale($previousLocale); + +$cleanup(); +$db->close(); + +echo PHP_EOL . "Passed: {$passed} Failed: {$failed}" . PHP_EOL; +exit($failed === 0 ? 0 : 1); diff --git a/tests/loan-request-capped-by-scheduled-loan.spec.js b/tests/loan-request-capped-by-scheduled-loan.spec.js new file mode 100644 index 000000000..e6281f426 --- /dev/null +++ b/tests/loan-request-capped-by-scheduled-loan.spec.js @@ -0,0 +1,228 @@ +// @ts-check +// Regression: when a scheduled loan (or a reservation on every copy) begins +// before the default return date, the public request calendar used to propose +// start + the default duration anyway. flatpickr silently dropped that date +// because it fell on a disabled day, the request went out without end_date, +// and the server rejected it with a bare "no copies available" although a +// shorter loan was possible. The calendar must now cap the return date at the +// day before the conflict and say how long the loan can be; the server's +// rejection must carry the same figures for clients that skip the calendar. +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; + +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_HOST = process.env.E2E_DB_HOST || ''; +const DB_PORT = process.env.E2E_DB_PORT || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; + +// The seeded loan starts this many days after today: inside the default +// duration, so it must shorten the proposed loan to CONFLICT_OFFSET - 1 days. +const CONFLICT_OFFSET = 5; + +test.skip( + !ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_NAME, + 'E2E credentials not configured (set E2E_ADMIN_EMAIL, E2E_ADMIN_PASS, E2E_DB_USER, E2E_DB_NAME)', +); + +/** + * Runs one statement against the test database (TCP when E2E_DB_HOST is set, else the + * socket) and returns its tab-separated output, trimmed. + * @param {string} sql + * @returns {string} + */ +function dbQuery(sql) { + const args = []; + if (DB_HOST) { + args.push('-h', DB_HOST); + if (DB_PORT) args.push('-P', DB_PORT); + } else if (DB_SOCKET) { + args.push('-S', DB_SOCKET); + } + args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql); + return execFileSync('mysql', args, { + encoding: 'utf-8', timeout: 10000, + env: { ...process.env, MYSQL_PWD: DB_PASS }, + }).trim(); +} + +/** + * A loans setting as a positive integer, or the fallback the server uses when it is + * missing or below 1. + * @param {string} key + * @param {number} fallback + * @returns {number} + */ +function setting(key, fallback) { + const value = parseInt( + dbQuery(`SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='${key}' LIMIT 1`) || '', + 10, + ); + return Number.isFinite(value) && value >= 1 ? value : fallback; +} + +/** + * yyyy-mm-dd plus a number of days, computed in UTC so no time zone shifts the day. + * @param {string} iso + * @param {number} days + * @returns {string} + */ +function addDays(iso, days) { + const [y, m, d] = iso.split('-').map(Number); + const date = new Date(Date.UTC(y, m - 1, d)); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +test.describe('Book page: loan request capped by a scheduled loan', () => { + let bookId = 0; + let copyId = 0; + let seededLoanId = 0; + let borrowerId = 0; + let today = ''; + const RUN = Date.now().toString(36).slice(-6); + const BORROWER_EMAIL = `lw-cap-${RUN}@example.invalid`; + // password_hash() output contains no quote characters. + const BORROWER_HASH = execFileSync('php', ['-r', "echo password_hash('LoanWindow1!', PASSWORD_DEFAULT);"], { encoding: 'utf-8' }).trim(); + let defaultDays = 0; + + test.beforeAll(() => { + defaultDays = Math.min(setting('loan_duration_days', 30), setting('max_loan_duration_days', 90)); + test.skip(defaultDays <= CONFLICT_OFFSET, `Default loan duration must exceed ${CONFLICT_OFFSET} days for this scenario`); + + // A title with exactly one lendable copy and nothing in circulation on it, + // so the seeded scheduled loan is the only thing blocking the calendar. + bookId = parseInt( + dbQuery( + "SELECT c.libro_id FROM copie c JOIN libri l ON l.id=c.libro_id " + + "WHERE l.deleted_at IS NULL " + + // A pending request with a copy (attivo=0) holds that copy too. + "AND NOT EXISTS (SELECT 1 FROM prestiti p WHERE p.libro_id=c.libro_id " + + "AND (p.attivo=1 OR (p.stato='pendente' AND p.copia_id IS NOT NULL))) " + + "AND NOT EXISTS (SELECT 1 FROM prenotazioni r WHERE r.libro_id=c.libro_id AND r.stato='attiva') " + + "GROUP BY c.libro_id HAVING COUNT(*)=1 AND SUM(c.stato='disponibile')=1 LIMIT 1", + ) || '0', + 10, + ); + if (!bookId) return; + copyId = parseInt(dbQuery(`SELECT id FROM copie WHERE libro_id=${bookId} LIMIT 1`) || '0', 10); + // The application's clock (DateHelper::today(): app.timezone, default + // Europe/Rome), not the database session's: near midnight they differ. + const tz = dbQuery("SELECT setting_value FROM system_settings WHERE category='app' AND setting_key='timezone' LIMIT 1") || 'Europe/Rome'; + today = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); + + // Another patron's scheduled loan: the admin's own commitments never block + // the admin (and a second loan on the same book is refused as a duplicate), + // so the borrower is a patron this spec creates. The fresh CI database + // holds only the admin, and other specs' users come and go. + dbQuery(`DELETE FROM utenti WHERE email='${BORROWER_EMAIL}'`); + dbQuery( + `INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, email_verificata, tipo_utente, created_at) ` + + `VALUES ('LWC${RUN}', 'Loan', 'Window', '${BORROWER_EMAIL}', '${BORROWER_HASH}', 'attivo', 1, 'standard', NOW())`, + ); + borrowerId = parseInt(dbQuery(`SELECT id FROM utenti WHERE email='${BORROWER_EMAIL}' LIMIT 1`) || '0', 10); + if (!borrowerId) { bookId = 0; return; } + + const loanStart = addDays(today, CONFLICT_OFFSET); + const loanEnd = addDays(today, CONFLICT_OFFSET + 14); + dbQuery( + `INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) ` + + `VALUES (${bookId}, ${copyId}, ${borrowerId}, '${loanStart}', '${loanEnd}', 'prenotato', 'prenotazione', 1)`, + ); + seededLoanId = parseInt(dbQuery(`SELECT id FROM prestiti WHERE libro_id=${bookId} AND utente_id=${borrowerId} ORDER BY id DESC LIMIT 1`) || '0', 10); + }); + + test.afterAll(() => { + if (seededLoanId) { + dbQuery(`DELETE FROM prestiti WHERE id=${seededLoanId}`); + dbQuery(`DELETE FROM prestiti WHERE libro_id=${bookId} AND utente_id=(SELECT id FROM utenti WHERE email='${ADMIN_EMAIL.replace(/'/g, "''")}' LIMIT 1) AND data_prestito>='${today}'`); + dbQuery(`UPDATE copie SET stato='disponibile' WHERE id=${copyId}`); + } + if (borrowerId) { + dbQuery(`DELETE FROM prestiti WHERE utente_id=${borrowerId}`); + dbQuery(`DELETE FROM utenti WHERE id=${borrowerId}`); + } + }); + + // Runs first: the calendar test below ends by creating the admin's own loan. + test('the server rejection names the maximum loan length and the blocked day', async ({ page }) => { + test.skip(!bookId || !seededLoanId, 'No single-copy book free of circulation to seed the scenario on'); + + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/(\/$|admin|profilo|account)/, { timeout: 15000 }).catch(() => {}); + await page.goto(`${BASE}/libro/${bookId}`); + + // A client that skips the calendar and asks for the full default window. + const result = await page.evaluate(async ({ id, start, end }) => { + const meta = document.querySelector('meta[name="csrf-token"]'); + const csrf = meta ? meta.getAttribute('content') : ''; + const base = /^\/en\//.test(location.pathname) ? '/en/api/book' : '/api/libro'; + const res = await fetch(`${base}/${id}/reservation`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ start_date: start, end_date: end, csrf_token: csrf }), + }); + return { status: res.status, body: await res.json() }; + }, { id: bookId, start: today, end: addDays(today, CONFLICT_OFFSET + 1) }); + + expect(result.status).toBe(400); + expect(result.body.success).toBe(false); + expect(result.body.first_conflict_date).toBe(addDays(today, CONFLICT_OFFSET)); + expect(result.body.max_end_date).toBe(addDays(today, CONFLICT_OFFSET - 1)); + expect(result.body.max_duration_days).toBe(CONFLICT_OFFSET - 1); + expect(result.body.message).toContain(String(CONFLICT_OFFSET - 1)); + }); + + test('the return date stops the day before the scheduled loan and the limit is explained', async ({ page }) => { + test.skip(!bookId || !seededLoanId, 'No single-copy book free of circulation to seed the scenario on'); + + const expectedEnd = addDays(today, CONFLICT_OFFSET - 1); + const expectedDays = CONFLICT_OFFSET - 1; + + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/(\/$|admin|profilo|account)/, { timeout: 15000 }).catch(() => {}); + + await page.goto(`${BASE}/libro/${bookId}`); + const btn = page.locator('#btn-request-loan'); + test.skip((await btn.count()) === 0, 'This book has no request-loan button'); + + await btn.click(); + await page.waitForSelector('.swal2-popup', { timeout: 8000 }); + await page.waitForFunction( + () => { + const start = document.querySelector('#swal-date-start'); + const end = document.querySelector('#swal-date-end'); + return start && end && /** @type {any} */ (start)._flatpickr && /** @type {any} */ (end)._flatpickr; + }, + { timeout: 8000 }, + ); + + expect(await page.locator('#swal-date-start').inputValue()).toBe(today); + // Not start + default duration: the day before the other patron's loan. + expect(await page.locator('#swal-date-end').inputValue()).toBe(expectedEnd); + await expect(page.locator('#swal-date-end-display')).not.toHaveValue(''); + + const note = page.locator('#swal-loan-limit'); + await expect(note).toBeVisible(); + await expect(note).toContainText(String(expectedDays)); + + // The capped return date is accepted and the request goes through. + const createResponse = page.waitForResponse((r) => /\/reservation$/.test(r.url()) && r.request().method() === 'POST'); + await page.locator('.swal2-confirm').click(); + const created = await (await createResponse).json(); + expect(created.success, JSON.stringify(created)).toBe(true); + await page.waitForSelector('.swal2-popup.swal2-icon-success', { timeout: 8000 }); + }); +}); diff --git a/tests/loan-window-cap.unit.php b/tests/loan-window-cap.unit.php new file mode 100644 index 000000000..6b50b60dd --- /dev/null +++ b/tests/loan-window-cap.unit.php @@ -0,0 +1,318 @@ +set_charset('utf8mb4'); + DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — loan window suite is mandatory: {$e->getMessage()}\n"); + exit(1); +} + +$run = bin2hex(random_bytes(5)); +$prefix = "ZZ_LWCAP_{$run}"; +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label, string $detail = '') use (&$passed, &$failed): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . ($ok || $detail === '' ? '' : " [{$detail}]") . PHP_EOL; + $ok ? $passed++ : $failed++; +}; + +/** @var int[] $bookIds */ +$bookIds = []; +/** @var int[] $userIds */ +$userIds = []; +$cleanup = static function () use ($db, &$bookIds, &$userIds): void { + foreach ($bookIds as $id) { + $db->query("DELETE FROM prestiti WHERE libro_id = {$id}"); + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$id}"); + $db->query("DELETE FROM copie WHERE libro_id = {$id}"); + $db->query("DELETE FROM libri WHERE id = {$id}"); + } + foreach ($userIds as $id) { + $db->query("DELETE FROM utenti WHERE id = {$id}"); + } +}; +set_exception_handler(static function (Throwable $e) use ($cleanup, $db): void { + try { $cleanup(); } catch (Throwable) {} + fwrite(STDERR, 'FAIL: uncaught ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . PHP_EOL); + $db->close(); + exit(1); +}); + +// ── Sandbox ────────────────────────────────────────────────────────────────── +$mkUser = static function (int $n) use ($db, $prefix, $run, &$userIds): int { + // codice_tessera is a short column: a run tag plus the sequence fits it. + $card = 'LW' . substr($run, 0, 8) . $n; + $email = strtolower("{$prefix}-u{$n}@example.invalid"); + $cognome = "{$prefix} {$n}"; + $stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) + VALUES (?, 'LoanWindow', ?, ?, 'x', 'attivo', 'standard', 1)"); + $stmt->bind_param('sss', $card, $cognome, $email); + $stmt->execute(); + $stmt->close(); + $userIds[] = (int) $db->insert_id; + return (int) $db->insert_id; +}; +$mkBook = static function (int $copies, int $n) use ($db, $prefix, &$bookIds): array { + $title = "{$prefix} book {$n}"; + $stmt = $db->prepare("INSERT INTO libri (titolo, copie_totali, copie_disponibili, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())"); + $stmt->bind_param('sii', $title, $copies, $copies); + $stmt->execute(); + $stmt->close(); + $bookId = (int) $db->insert_id; + $bookIds[] = $bookId; + $copyIds = []; + for ($i = 1; $i <= $copies; $i++) { + $code = "{$prefix}-{$bookId}-C{$i}"; + $stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'disponibile')"); + $stmt->bind_param('is', $bookId, $code); + $stmt->execute(); + $stmt->close(); + $copyIds[] = (int) $db->insert_id; + } + return [$bookId, $copyIds]; +}; +$mkScheduledLoan = static function (int $bookId, int $copyId, int $userId, string $from, string $to) use ($db): int { + $stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo, warning_sent, overdue_notification_sent) + VALUES (?, ?, ?, ?, ?, 'prenotato', 'diretto', 1, 0, 0)"); + $stmt->bind_param('iiiss', $bookId, $copyId, $userId, $from, $to); + $stmt->execute(); + $stmt->close(); + return (int) $db->insert_id; +}; +$addDays = static fn (string $ymd, int $days): string => (new DateTimeImmutable($ymd))->modify(($days >= 0 ? '+' : '') . "{$days} days")->format('Y-m-d'); +/** Drop what a successful request wrote for one patron on one title, so later checks start clean. */ +$resetRequester = static function (int $bookId, int $requesterId, array $copyIds) use ($db): void { + $db->query("DELETE FROM prestiti WHERE libro_id = {$bookId} AND utente_id = {$requesterId}"); + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$bookId} AND utente_id = {$requesterId}"); + // The sandbox's scheduled loans were inserted directly and never changed a + // copy's state, so every copy is physically 'disponibile' again here. + foreach ($copyIds as $copyId) { + $db->query("UPDATE copie SET stato = 'disponibile' WHERE id = {$copyId}"); + } +}; + +$settings = new SettingsRepository($db); +$defaultDays = $settings->loanDurationDays(); +$maxDays = max(1, (int) ($settings->get('loans', 'max_loan_duration_days', '90') ?? 90)); +$defaultDays = min($defaultDays, $maxDays); +// The other patron's loan starts inside the default duration, so it must +// shorten the proposed loan to offset - 1 days. +$offset = max(1, min(5, $defaultDays - 1)); +$today = DateHelper::today(); +$conflictStart = $addDays($today, $offset); +$conflictEnd = $addDays($today, $offset + 14); +$expectedMaxEnd = $addDays($today, $offset - 1); +$expectedMaxDays = $offset - 1; + +$otherUser = $mkUser(1); +$requester = $mkUser(2); +[$singleBook, $singleCopies] = $mkBook(1, 1); +[$doubleBook, $doubleCopies] = $mkBook(2, 2); +$mkScheduledLoan($singleBook, $singleCopies[0], $otherUser, $conflictStart, $conflictEnd); +$mkScheduledLoan($doubleBook, $doubleCopies[0], $otherUser, $conflictStart, $conflictEnd); +echo "Sandbox: today {$today}, scheduled loan {$conflictStart}..{$conflictEnd}, default {$defaultDays} days, max {$maxDays} days" . PHP_EOL; + +$requestCalendar = static function (int $bookId, array $body) use ($db, $requester): array { + $_SESSION['user'] = ['tipo_utente' => 'standard', 'id' => $requester]; + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', "/api/libro/{$bookId}/reservation") + ->withParsedBody($body); + $response = (new ReservationsController($db))->createReservation($request, new SlimResponse(), ['id' => (string) $bookId]); + $decoded = json_decode((string) $response->getBody(), true); + return ['status' => $response->getStatusCode(), 'body' => is_array($decoded) ? $decoded : []]; +}; +$userAction = static function (string $method, int $bookId, array $body) use ($db, $requester): array { + $_SESSION['user'] = ['tipo_utente' => 'standard', 'id' => $requester]; + $_SERVER['HTTP_REFERER'] = "/libro/{$bookId}"; + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', "/user/{$method}") + ->withParsedBody(['libro_id' => $bookId] + $body); + $response = (new UserActionsController())->{$method}($request, new SlimResponse(), $db); + $query = parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY); + $params = []; + parse_str(is_string($query) ? $query : '', $params); + return $params; +}; + +// ═════════ 1-3: the capacity service and the helper agree on the window ══════ +$capacity = new CapacityService($db); +$blocked = $capacity->unavailableDatesInRange($singleBook, $today, $addDays($today, $defaultDays), $requester); +$check($blocked !== [] && min($blocked) === $conflictStart, + '1 CapacityService reports the scheduled loan\'s first day as the first blocked day', + json_encode($blocked)); + +$cap = LoanWindow::cap($today, $blocked); +$check($cap !== null + && $cap['first_conflict_date'] === $conflictStart + && $cap['max_end_date'] === $expectedMaxEnd + && $cap['max_duration_days'] === $expectedMaxDays, + "2 LoanWindow::cap bounds the loan to {$expectedMaxDays} days ending {$expectedMaxEnd}", + json_encode($cap)); + +$check(LoanWindow::cap($conflictStart, $blocked) === null && LoanWindow::cap($today, []) === null, + '3 LoanWindow::cap reports no cap when the start day is blocked or nothing is blocked'); + +// ═════════ 4: the calendar data the pickers colour ═══════════════════════════ +$availability = (new ReservationsController($db))->getBookAvailabilityData($singleBook, null, $offset + 20, $requester); +$byDate = is_array($availability) ? array_column($availability['days'] ?? [], null, 'date') : []; +$check(($byDate[$expectedMaxEnd]['state'] ?? null) === 'free' + && ($byDate[$conflictStart]['state'] ?? null) === 'borrowed' + && in_array($conflictStart, $availability['unavailable_dates'] ?? [], true), + '4 availability payload: free the day before the scheduled loan, borrowed from its first day', + json_encode([$byDate[$expectedMaxEnd] ?? null, $byDate[$conflictStart] ?? null])); + +// ═════════ 5-7: the public calendar endpoint explains the cap ════════════════ +$r = $requestCalendar($singleBook, ['start_date' => $today]); +$check($r['status'] === 400 + && ($r['body']['max_duration_days'] ?? null) === $expectedMaxDays + && ($r['body']['max_end_date'] ?? null) === $expectedMaxEnd + && ($r['body']['first_conflict_date'] ?? null) === $conflictStart + && str_contains((string) ($r['body']['message'] ?? ''), (string) $expectedMaxDays), + '5 createReservation without end_date: 400 with the maximum duration, latest return date and blocked day', + json_encode($r)); + +$r = $requestCalendar($singleBook, ['start_date' => $today, 'end_date' => $addDays($today, $offset + 2)]); +$check($r['status'] === 400 + && ($r['body']['max_end_date'] ?? null) === $expectedMaxEnd + && ($r['body']['error'] ?? null) === 'no_capacity', + '6 createReservation with an end_date crossing the scheduled loan: same figures, error no_capacity', + json_encode($r)); + +$r = $requestCalendar($singleBook, ['start_date' => $conflictStart, 'end_date' => $addDays($conflictStart, 2)]); +$check($r['status'] === 400 + && !array_key_exists('max_end_date', $r['body']) + && ($r['body']['message'] ?? '') === __('Nessuna copia disponibile nelle date richieste'), + '7 createReservation starting on the blocked day: generic refusal, nothing to shorten', + json_encode($r)); + +// ═════════ 8: the capped request goes through ════════════════════════════════ +$r = $requestCalendar($singleBook, ['start_date' => $today, 'end_date' => $expectedMaxEnd]); +$check($r['status'] === 200 && ($r['body']['success'] ?? false) === true, + "8 createReservation ending {$expectedMaxEnd} (the day before the scheduled loan) is accepted", + json_encode($r)); +$resetRequester($singleBook, $requester, $singleCopies); + +// ═════════ 9-10: the other validations keep their own answers ════════════════ +$r = $requestCalendar($singleBook, ['start_date' => $today, 'end_date' => $addDays($today, $maxDays + 1)]); +$check($r['status'] === 400 && ($r['body']['error'] ?? null) === 'max_duration_exceeded', + "9 an end_date beyond max_loan_duration_days ({$maxDays}) is refused as max_duration_exceeded, before any capacity check", + json_encode($r)); + +$r1 = $requestCalendar($singleBook, ['start_date' => $addDays($today, 2), 'end_date' => $today]); +$r2 = $requestCalendar($singleBook, ['start_date' => $addDays($today, -1)]); +$check(($r1['body']['error'] ?? null) === 'invalid_range' && ($r2['body']['error'] ?? null) === 'past_date', + '10 an inverted range and a past start keep their dedicated errors', + json_encode([$r1['body'], $r2['body']])); + +// ═════════ 11-12: the user-area forms carry the explanation through the redirect +$params = $userAction('loan', $singleBook, []); +$check(($params['loan_error'] ?? null) === 'not_available' + && (int) ($params['max_loan_days'] ?? -1) === $expectedMaxDays + && ($params['max_end_date'] ?? null) === $expectedMaxEnd + && ($params['blocked_from'] ?? null) === $conflictStart, + '11 POST /user/loan: loan_error=not_available with max_loan_days, max_end_date and blocked_from', + json_encode($params)); + +$explained = LoanWindow::explainFromParams($params); +$check(is_string($explained) + && str_contains($explained, (string) $expectedMaxDays) + && LoanWindow::explainFromParams(['max_loan_days' => 'x'] + $params) === null, + '12 LoanWindow::explainFromParams rebuilds the message from the redirect, and ignores a tampered query', + (string) $explained); + +$params = $userAction('reserve', $singleBook, ['desired_date' => $today]); +$check(($params['reserve_error'] ?? null) === 'not_available' + && ($params['blocked_from'] ?? null) === $conflictStart, + '13 POST /user/reserve for today: reserve_error=not_available with the same blocked_from', + json_encode($params)); + +// ═════════ 14: a second free copy means no cap ═══════════════════════════════ +$blockedDouble = $capacity->unavailableDatesInRange($doubleBook, $today, $addDays($today, $defaultDays), $requester); +$r = $requestCalendar($doubleBook, ['start_date' => $today]); +$check($blockedDouble === [] && $r['status'] === 200 && ($r['body']['success'] ?? false) === true, + '14 two copies, one scheduled: no blocked day and the full default request is accepted', + json_encode(['blocked' => $blockedDouble, 'response' => $r])); +$resetRequester($doubleBook, $requester, $doubleCopies); + +// ═════════ 16-19: an explicit return date from the app (Mobile API 1.8.2) ════ +$params = $userAction('loan', $singleBook, ['end_date' => $expectedMaxEnd]); +$check(isset($params['loan_request_success']) || isset($params['reserve_success']), + "16 POST /user/loan with end_date = {$expectedMaxEnd} (the day before the scheduled loan) is accepted", + json_encode($params)); +$resetRequester($singleBook, $requester, $singleCopies); + +$params = $userAction('loan', $singleBook, ['end_date' => $addDays($today, $maxDays + 1)]); +$check(($params['loan_error'] ?? null) === 'max_duration_exceeded', + "17 POST /user/loan with an end_date beyond max_loan_duration_days ({$maxDays}) is refused as max_duration_exceeded", + json_encode($params)); + +$params = $userAction('loan', $singleBook, ['end_date' => $addDays($today, -1)]); +$check(($params['loan_error'] ?? null) === 'invalid_date', + '18 POST /user/loan with an end_date before the start is refused as invalid_date', + json_encode($params)); + +$params = $userAction('reserve', $singleBook, ['desired_date' => $today, 'end_date' => $expectedMaxEnd]); +$check(isset($params['reserve_success']), + '19 POST /user/reserve for today with the capped end_date joins the queue instead of being refused', + json_encode($params)); +$resetRequester($singleBook, $requester, $singleCopies); + +// ═════════ 20: the public calendar reads the same figures (view contract) ════ +$viewSrc = (string) file_get_contents($root . '/app/Views/frontend/book-detail.php'); +$check(str_contains($viewSrc, 'function loanWindowFrom(') + && str_contains($viewSrc, "endPicker.set('maxDate', loanWindow.maxEndDate)") + && str_contains($viewSrc, 'Durata massima del prestito: %1$d giorni') + && str_contains($viewSrc, 'LoanWindow::explainFromParams($_GET)'), + '20 book-detail.php caps the return picker, explains it with the shared message and renders the redirect explanation'); + +$cleanup(); +$db->close(); + +echo PHP_EOL . "Passed: {$passed} Failed: {$failed}" . PHP_EOL; +exit($failed === 0 ? 0 : 1); diff --git a/tests/loan-window-surfaces.unit.php b/tests/loan-window-surfaces.unit.php new file mode 100644 index 000000000..3753fc00b --- /dev/null +++ b/tests/loan-window-surfaces.unit.php @@ -0,0 +1,522 @@ +set_charset('utf8mb4'); + DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — loan window surfaces suite is mandatory: {$e->getMessage()}\n"); + exit(1); +} + +$run = bin2hex(random_bytes(5)); +$prefix = "ZZ_LWSRF_{$run}"; +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label, string $detail = '') use (&$passed, &$failed): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . ($ok || $detail === '' ? '' : " [{$detail}]") . PHP_EOL; + $ok ? $passed++ : $failed++; +}; + +/** @var int[] $bookIds */ +$bookIds = []; +/** @var int[] $userIds */ +$userIds = []; +$savedLoanSettings = []; +$cleanup = static function () use ($db, &$bookIds, &$userIds, &$savedLoanSettings): void { + $settings = new SettingsRepository($db); + foreach ($savedLoanSettings as $key => $value) { + if ($value === null) { + $settings->delete('loans', $key); + } else { + $settings->set('loans', $key, $value); + } + } + foreach ($bookIds as $id) { + $db->query("DELETE FROM prestiti WHERE libro_id = {$id}"); + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$id}"); + $db->query("DELETE FROM copie WHERE libro_id = {$id}"); + $db->query("DELETE FROM libri WHERE id = {$id}"); + } + foreach ($userIds as $id) { + $db->query("DELETE FROM utenti WHERE id = {$id}"); + } +}; +set_exception_handler(static function (Throwable $e) use ($cleanup, $db): void { + try { $cleanup(); } catch (Throwable) {} + fwrite(STDERR, 'FAIL: uncaught ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . PHP_EOL); + $db->close(); + exit(1); +}); + +// ── Sandbox ────────────────────────────────────────────────────────────────── +$mkUser = static function (int $n, string $tipo = 'standard') use ($db, $prefix, $run, &$userIds): int { + // codice_tessera is a short column: a run tag plus the sequence fits it. + $card = 'LW' . substr($run, 0, 8) . $n; + $email = strtolower("{$prefix}-u{$n}@example.invalid"); + $cognome = "{$prefix} {$n}"; + $stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) + VALUES (?, 'LoanWindow', ?, ?, 'x', 'attivo', ?, 1)"); + $stmt->bind_param('ssss', $card, $cognome, $email, $tipo); + $stmt->execute(); + $stmt->close(); + $userIds[] = (int) $db->insert_id; + return (int) $db->insert_id; +}; +$mkBook = static function (int $copies, int $n) use ($db, $prefix, &$bookIds): array { + $title = "{$prefix} book {$n}"; + $stmt = $db->prepare("INSERT INTO libri (titolo, copie_totali, copie_disponibili, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())"); + $stmt->bind_param('sii', $title, $copies, $copies); + $stmt->execute(); + $stmt->close(); + $bookId = (int) $db->insert_id; + $bookIds[] = $bookId; + $copyIds = []; + for ($i = 1; $i <= $copies; $i++) { + $code = "{$prefix}-{$bookId}-C{$i}"; + $stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'disponibile')"); + $stmt->bind_param('is', $bookId, $code); + $stmt->execute(); + $stmt->close(); + $copyIds[] = (int) $db->insert_id; + } + return [$bookId, $copyIds]; +}; +$mkLoan = static function (int $bookId, ?int $copyId, int $userId, string $stato, int $attivo, string $from, string $to) use ($db): int { + $stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo, warning_sent, overdue_notification_sent) + VALUES (?, ?, ?, ?, ?, ?, 'diretto', ?, 0, 0)"); + $stmt->bind_param('iiisssi', $bookId, $copyId, $userId, $from, $to, $stato, $attivo); + $stmt->execute(); + $stmt->close(); + return (int) $db->insert_id; +}; +$addDays = static fn (string $ymd, int $days): string => (new DateTimeImmutable($ymd))->modify(($days >= 0 ? '+' : '') . "{$days} days")->format('Y-m-d'); +$redirectParams = static function ($response): array { + $query = parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY); + $params = []; + parse_str(is_string($query) ? $query : '', $params); + return $params; +}; +$jsonBody = static function ($response): array { + $decoded = json_decode((string) $response->getBody(), true); + return is_array($decoded) ? $decoded : []; +}; +/** Drop what a request wrote for one patron on one title, so later checks start clean. */ +$resetPatron = static function (int $bookId, int $userId, array $copyIds) use ($db): void { + $db->query("DELETE FROM prestiti WHERE libro_id = {$bookId} AND utente_id = {$userId}"); + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$bookId} AND utente_id = {$userId}"); + foreach ($copyIds as $copyId) { + $db->query("UPDATE copie SET stato = 'disponibile' WHERE id = {$copyId}"); + } +}; + +$settings = new SettingsRepository($db); +$maxDays = max(1, (int) ($settings->get('loans', 'max_loan_duration_days', '90') ?? 90)); +$defaultDays = min($settings->loanDurationDays(), $maxDays); +$offset = max(2, min(5, $defaultDays - 1)); +$today = DateHelper::today(); +$conflictStart = $addDays($today, $offset); +$conflictEnd = $addDays($today, $offset + 14); +$expectedMaxEnd = $addDays($today, $offset - 1); +$expectedMaxDays = $offset - 1; + +$otherUser = $mkUser(1); +$requester = $mkUser(2); +$admin = $mkUser(3, 'admin'); +[$book, $copies] = $mkBook(1, 1); +$mkLoan($book, $copies[0], $otherUser, 'prenotato', 1, $conflictStart, $conflictEnd); +echo "Sandbox: today {$today}, scheduled loan {$conflictStart}..{$conflictEnd}, default {$defaultDays} days, max {$maxDays} days" . PHP_EOL; + +$asAdmin = static function () use ($admin): void { + $_SESSION['user'] = ['tipo_utente' => 'admin', 'id' => $admin]; +}; +$asRequester = static function () use ($requester, $book): void { + $_SESSION['user'] = ['tipo_utente' => 'standard', 'id' => $requester]; + $_SERVER['HTTP_REFERER'] = "/libro/{$book}"; +}; +$post = static fn (string $uri, array $body) => (new ServerRequestFactory())->createServerRequest('POST', $uri)->withParsedBody($body); + +// ═════════ 1: the admin loan form ════════════════════════════════════════════ +$asAdmin(); +$response = (new PrestitiController())->store($post('/admin/loans/store', [ + 'loan_submission_token' => \App\Support\OneTimeFormToken::issue('loan.create'), + 'utente_id' => (string) $requester, + 'libro_id' => (string) $book, + 'data_prestito' => $today, + 'data_scadenza' => $addDays($today, $offset + 2), +]), new SlimResponse(), $db); +$params = $redirectParams($response); +$check(($params['error'] ?? null) === 'no_copies_available' + && ($params['max_end_date'] ?? null) === $expectedMaxEnd + && (int) ($params['max_loan_days'] ?? -1) === $expectedMaxDays + && ($params['blocked_from'] ?? null) === $conflictStart, + "1 admin create loan crossing the scheduled loan: no_copies_available with max_end_date {$expectedMaxEnd}, max_loan_days {$expectedMaxDays}, blocked_from {$conflictStart}", + $response->getHeaderLine('Location')); +$check(LoanWindow::explainFromParams($params) !== null + && str_contains((string) LoanWindow::explainFromParams($params), (string) $expectedMaxDays), + '2 the admin form renders those parameters as the maximum-duration sentence'); + +// ═════════ 3: editing a loan's dates across the scheduled loan ═══════════════ +$ownLoan = $mkLoan($book, $copies[0], $requester, 'in_corso', 1, $addDays($today, -2), $addDays($today, 1)); +$db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$copies[0]}"); +$asAdmin(); +$response = (new PrestitiController())->update($post("/admin/loans/update/{$ownLoan}", [ + 'utente_id' => (string) $requester, + 'data_prestito' => $addDays($today, -2), + 'data_scadenza' => $addDays($today, $offset + 2), +]), new SlimResponse(), $db, $ownLoan); +$params = $redirectParams($response); +// Only the added days are checked, but the figures describe the whole loan +// from its own start (today - 2), and the loan being edited is never its own conflict. +$loanStart = $addDays($today, -2); +$check(($params['error'] ?? null) === 'no_copies_available' + && ($params['max_end_date'] ?? null) === $expectedMaxEnd + && ($params['blocked_from'] ?? null) === $conflictStart + && (int) ($params['max_loan_days'] ?? -1) === (int) (new DateTimeImmutable($loanStart))->diff(new DateTimeImmutable($expectedMaxEnd))->days, + '3 admin edit of a loan due date across the scheduled loan: explained from the loan\'s own start, own loan excluded', + $response->getHeaderLine('Location')); +$check(str_contains((string) LoanWindow::explainFromParams($params), (string) ((int) (new DateTimeImmutable($loanStart))->diff(new DateTimeImmutable($expectedMaxEnd))->days)), + '3b the loans list sentence names that whole-loan duration'); +$resetPatron($book, $requester, $copies); + +// ═════════ 4: approving a pending request ════════════════════════════════════ +$pending = $mkLoan($book, null, $requester, 'pendente', 0, $today, $addDays($today, $defaultDays)); +$asAdmin(); +$response = (new LoanApprovalController())->approveLoan($post('/admin/loans/approve', ['loan_id' => (string) $pending]), new SlimResponse(), $db); +$body = $jsonBody($response); +$check($response->getStatusCode() === 400 + && ($body['success'] ?? true) === false + && ($body['max_end_date'] ?? null) === $expectedMaxEnd + && ($body['max_duration_days'] ?? null) === $expectedMaxDays + && str_contains((string) ($body['message'] ?? ''), (string) $expectedMaxDays), + '4 approving a pending request whose default window crosses the scheduled loan: 400 JSON with the figures and the sentence', + json_encode($body)); +$resetPatron($book, $requester, $copies); + +// ═════════ 5-7: the admin reservation form, create then edit ═════════════════ +$asAdmin(); +$response = (new ReservationsAdminController())->store($post('/admin/reservations/store', [ + 'libro_id' => (string) $book, + 'utente_id' => (string) $requester, + 'data_prenotazione' => $today, + 'data_scadenza' => $addDays($today, $offset + 2), + 'data_inizio_richiesta' => $today, + 'data_fine_richiesta' => $addDays($today, $offset + 2), +]), new SlimResponse(), $db); +$params = $redirectParams($response); +$check(($params['error'] ?? null) === 'capacity_full' + && ($params['max_end_date'] ?? null) === $expectedMaxEnd + && ($params['blocked_from'] ?? null) === $conflictStart, + '5 admin reservation crossing the scheduled loan: capacity_full with the figures', + $response->getHeaderLine('Location')); + +$response = (new ReservationsAdminController())->store($post('/admin/reservations/store', [ + 'libro_id' => (string) $book, + 'utente_id' => (string) $requester, + 'data_prenotazione' => $today, + 'data_scadenza' => $expectedMaxEnd, + 'data_inizio_richiesta' => $today, + 'data_fine_richiesta' => $expectedMaxEnd, +]), new SlimResponse(), $db); +$reservationRow = $db->query("SELECT id FROM prenotazioni WHERE libro_id = {$book} AND utente_id = {$requester} AND stato = 'attiva' ORDER BY id DESC LIMIT 1")->fetch_assoc(); +$reservationId = (int) ($reservationRow['id'] ?? 0); +$check($reservationId > 0 && !isset($redirectParams($response)['error']), + "6 the same reservation ending {$expectedMaxEnd} is created", + $response->getHeaderLine('Location')); + +$response = (new ReservationsAdminController())->update($post("/admin/reservations/update/{$reservationId}", [ + 'stato' => 'attiva', + 'data_prenotazione' => $today, + 'data_scadenza_prenotazione' => $addDays($today, $offset + 2), + 'data_inizio_richiesta' => $today, + 'data_fine_richiesta' => $addDays($today, $offset + 2), +]), new SlimResponse(), $db, $reservationId); +$params = $redirectParams($response); +$check(($params['error'] ?? null) === 'capacity_full' + && ($params['max_end_date'] ?? null) === $expectedMaxEnd + && ($params['blocked_from'] ?? null) === $conflictStart, + '7 extending that reservation across the scheduled loan: capacity_full with the figures, own row excluded (cap still from the start)', + $response->getHeaderLine('Location')); + +// ═════════ 8: a reader moving their own reservation ══════════════════════════ +$asRequester(); +$response = (new UserActionsController())->changeReservationDate($post('/reservation/change-date', [ + 'reservation_id' => (string) $reservationId, + 'desired_date' => $today, +]), new SlimResponse(), $db); +$params = $redirectParams($response); +$check(($params['error'] ?? null) === 'not_available' + && ($params['blocked_from'] ?? null) === $conflictStart + && ($params['max_end_date'] ?? null) === $expectedMaxEnd, + '8 moving one\'s own reservation to a start whose default window crosses the scheduled loan: not_available with the figures', + $response->getHeaderLine('Location')); +$resetPatron($book, $requester, $copies); + +// ═════════ 9-12: the Mobile API contract ═════════════════════════════════════ +$mobile = static function (array $body) use ($db, $requester, $book, $asRequester, $jsonBody): array { + $asRequester(); + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/api/v1/reservations') + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]) + ->withParsedBody(['book_id' => $book] + $body); + $response = (new MobileActionsController($db))->requestReservation($request, new SlimResponse()); + return ['status' => $response->getStatusCode(), 'body' => $jsonBody($response)]; +}; + +$r = $mobile(['desired_date' => $today]); +$check($r['status'] === 409 + && ($r['body']['error']['code'] ?? null) === 'not_available' + && ($r['body']['meta']['max_end_date'] ?? null) === $expectedMaxEnd + && ($r['body']['meta']['max_duration_days'] ?? null) === $expectedMaxDays + && ($r['body']['meta']['first_conflict_date'] ?? null) === $conflictStart + && str_contains((string) ($r['body']['error']['message'] ?? ''), (string) $expectedMaxDays), + '9 Mobile POST /reservations without end_date: 409 not_available, the sentence as error.message, the figures in meta', + json_encode($r)); + +$r = $mobile(['desired_date' => $today, 'end_date' => $expectedMaxEnd]); +$check($r['status'] === 201 && in_array($r['body']['data']['type'] ?? null, ['loan', 'reservation'], true), + "10 Mobile POST /reservations with end_date {$expectedMaxEnd}: 201, the request is accepted", + json_encode($r)); +$resetPatron($book, $requester, $copies); + +$r = $mobile(['desired_date' => $today, 'end_date' => $addDays($today, $maxDays + 1)]); +$check($r['status'] === 422 && ($r['body']['error']['code'] ?? null) === 'max_duration_exceeded', + '11 Mobile end_date beyond max_loan_duration_days: 422 max_duration_exceeded', + json_encode($r)); + +$r = $mobile(['desired_date' => $today, 'end_date' => $addDays($today, -1)]); +$check($r['status'] === 422 && ($r['body']['error']['code'] ?? null) === 'invalid_date', + '12 Mobile end_date before the start: 422 invalid_date', + json_encode($r)); + +// ═════════ 13-14: the durations every calendar reads ═════════════════════════ +$availability = (new ReservationsController($db))->getBookAvailabilityData($book, null, 10, $requester); +$check(is_array($availability) + && ($availability['loan_duration_days'] ?? null) === $defaultDays + && ($availability['max_loan_duration_days'] ?? null) === $maxDays, + "13 the availability payload carries loan_duration_days ({$defaultDays}, capped by the maximum) and max_loan_duration_days ({$maxDays})", + json_encode([$availability['loan_duration_days'] ?? null, $availability['max_loan_duration_days'] ?? null])); + +// The HTTP route the calendars fetch whitelists the payload keys: the two +// durations must be among them, or the forms silently keep their rendered +// default (the controller method alone cannot catch that). +$routeSrc = (string) file_get_contents($root . '/app/Routes/web.php'); +$routeAt = strpos($routeSrc, "/{id:\\d+}/availability', function"); +$routeSlice = $routeAt === false ? '' : substr($routeSrc, $routeAt, 3000); +$check($routeSlice !== '' + && str_contains($routeSlice, "'loan_duration_days' => \$availability['loan_duration_days']") + && str_contains($routeSlice, "'max_loan_duration_days' => \$availability['max_loan_duration_days']"), + '13b the localized availability route passes both durations through to the calendars'); + +$blocked = (new CapacityService($db))->unavailableDatesInRange($book, $today, $addDays($today, $defaultDays), $requester); +$check(LoanWindow::redirectParams($today, $blocked) === ['max_loan_days' => $expectedMaxDays, 'max_end_date' => $expectedMaxEnd, 'blocked_from' => $conflictStart], + '14 LoanWindow::redirectParams carries exactly the three figures every redirect uses'); + +// ═════════ 15-17: a commitment starting the very next day ════════════════════ +[$tightBook, $tightCopies] = $mkBook(1, 2); +$mkLoan($tightBook, $tightCopies[0], $otherUser, 'prenotato', 1, $addDays($today, 1), $addDays($today, 10)); +$check(LoanWindow::cap($today, [$addDays($today, 1)]) === null, + '15 LoanWindow::cap: a conflict on the next day leaves no loan of at least one day, so no figures'); + +$asRequester(); +$request = $post("/api/libro/{$tightBook}/reservation", ['start_date' => $today]); +$response = (new ReservationsController($db))->createReservation($request, new SlimResponse(), ['id' => (string) $tightBook]); +$body = $jsonBody($response); +$check($response->getStatusCode() === 400 + && ($body['error'] ?? null) === 'no_capacity' + && !array_key_exists('max_end_date', $body) + && ($body['message'] ?? '') === __('Nessuna copia disponibile nelle date richieste'), + '16 public request from today with the next day booked: generic refusal, no misleading "0 days" figure', + json_encode($body)); + +$r = $mobile(['desired_date' => $today]); +$asRequester(); +$request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/api/v1/reservations') + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]) + ->withParsedBody(['book_id' => $tightBook, 'desired_date' => $today]); +$response = (new MobileActionsController($db))->requestReservation($request, new SlimResponse()); +$body = $jsonBody($response); +$check($response->getStatusCode() === 409 + && ($body['error']['code'] ?? null) === 'not_available' + && !isset($body['meta']['max_end_date']), + '17 Mobile request on that title: 409 not_available with no figures in meta', + json_encode($body)); + +// A lower configured maximum must govern every default, including Mobile API +// requests without end_date. A commitment at +10 must not reject a valid 7-day +// request just because the configured standard duration is 30 days. +foreach (['loan_duration_days', 'max_loan_duration_days'] as $key) { + $savedLoanSettings[$key] = $settings->get('loans', $key); +} +$settings->set('loans', 'loan_duration_days', '30'); +$settings->set('loans', 'max_loan_duration_days', '7'); +[$boundedBook, $boundedCopies] = $mkBook(1, 3); +$boundedScheduledLoan = $mkLoan($boundedBook, $boundedCopies[0], $otherUser, 'prenotato', 1, $addDays($today, 10), $addDays($today, 25)); +$expectedDue = $addDays($today, 7); +$createdDue = static function () use ($db, $boundedBook, $requester): ?string { + $row = $db->query("SELECT data_scadenza AS due FROM prestiti WHERE libro_id = {$boundedBook} AND utente_id = {$requester} + UNION ALL SELECT data_fine_richiesta AS due FROM prenotazioni WHERE libro_id = {$boundedBook} AND utente_id = {$requester}")->fetch_assoc(); + return $row['due'] ?? null; +}; +$availability = (new ReservationsController($db))->getBookAvailabilityData($boundedBook, null, 40, $requester); +$request = (new ServerRequestFactory())->createServerRequest('GET', "/api/v1/catalog/books/{$boundedBook}/availability") + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]); +$response = (new MobileCatalogController($db))->bookAvailability($request, new SlimResponse(), $boundedBook); +$mobileAvailability = $jsonBody($response)['data'] ?? []; +$check(($availability['loan_duration_days'] ?? null) === 7 + && ($mobileAvailability['loan_duration_days'] ?? null) === 7 + && ($mobileAvailability['max_loan_duration_days'] ?? null) === 7, + '18 web and Mobile availability advertise the same bounded 7-day default', json_encode($mobileAvailability)); + +foreach (['loan', 'reserve'] as $action) { + $asRequester(); + $response = (new UserActionsController())->$action($post("/user/{$action}", ['libro_id' => $boundedBook]), new SlimResponse(), $db); + $check(!isset($redirectParams($response)["{$action}_error"]) && $createdDue() === $expectedDue, + "19 {$action} without end_date persists the configured maximum instead of the longer default", $response->getHeaderLine('Location')); + $resetPatron($boundedBook, $requester, $boundedCopies); +} +$asRequester(); +$request = $post("/api/libro/{$boundedBook}/reservation", ['start_date' => $today]); +$response = (new ReservationsController($db))->createReservation($request, new SlimResponse(), ['id' => (string) $boundedBook]); +$check(($jsonBody($response)['success'] ?? false) && $createdDue() === $expectedDue, + '20 public calendar requests use the same bounded default'); +$resetPatron($boundedBook, $requester, $boundedCopies); + +foreach ([$today, $addDays($today, 1)] as $start) { + $asRequester(); + $request = $post('/api/v1/reservations', ['book_id' => $boundedBook, 'desired_date' => $start]) + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]); + $response = (new MobileActionsController($db))->requestReservation($request, new SlimResponse()); + $check($response->getStatusCode() === 201 && $createdDue() === $addDays($start, 7), + "21 Mobile request from {$start} without end_date succeeds with the bounded default", (string) $response->getBody()); + $resetPatron($boundedBook, $requester, $boundedCopies); +} +$asRequester(); +$request = $post('/api/v1/reservations', ['book_id' => $boundedBook, 'desired_date' => $today, 'end_date' => $addDays($today, 9)]) + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]); +$response = (new MobileActionsController($db))->requestReservation($request, new SlimResponse()); +$check($response->getStatusCode() === 422 && ($jsonBody($response)['error']['code'] ?? '') === 'max_duration_exceeded' && $createdDue() === null, + '22 an explicit return past the same maximum is still refused without writing a request'); +foreach ([[], ['date' => $expectedDue], new stdClass()] as $invalidEnd) { + $request = $post('/api/v1/reservations', ['book_id' => $boundedBook, 'desired_date' => $today, 'end_date' => $invalidEnd]) + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]); + $response = (new MobileActionsController($db))->requestReservation($request, new SlimResponse()); + $check($response->getStatusCode() === 422 && ($jsonBody($response)['error']['code'] ?? '') === 'invalid_date' && $createdDue() === null, + '22b a non-scalar end_date is refused instead of silently creating a default-duration request'); +} + +// The admin form and store use staff duration, not the capped reader default +// carried by the shared availability endpoint. +[$staffBook, $staffCopies] = $mkBook(1, 4); +$asAdmin(); +$response = (new PrestitiController())->store($post('/admin/loans/store', [ + 'loan_submission_token' => \App\Support\OneTimeFormToken::issue('loan.create'), + 'utente_id' => $requester, 'libro_id' => $staffBook, 'data_prestito' => $today, +]), new SlimResponse(), $db); +$staffDue = $db->query("SELECT data_scadenza FROM prestiti WHERE libro_id = {$staffBook} AND utente_id = {$requester}")->fetch_assoc(); +$staffLoanView = (string) file_get_contents($root . '/app/Views/prestiti/crea_prestito.php'); +$staffReservationView = (string) file_get_contents($root . '/app/Views/prenotazioni/crea_prenotazione.php'); +$check(($staffDue['data_scadenza'] ?? '') === $addDays($today, 30) + && !str_contains($staffLoanView, 'parseInt(data.availability.loan_duration_days') + && !str_contains($staffReservationView, 'parseInt(data.availability.loan_duration_days'), + '22c staff defaults retain the configured 30 days when reader requests are capped at 7', $response->getHeaderLine('Location')); +$resetPatron($staffBook, $requester, $staffCopies); + +// Refusing a huge staff range must not build millions of calendar days. +$capacity = new CapacityService($db); +$check($capacity->firstUnavailableDateInRange($boundedBook, $today, '9999-12-31') === $addDays($today, 10) + && $capacity->firstUnavailableDateInRange($boundedBook, $today, '9999-12-31', null, $boundedScheduledLoan) === null + && $capacity->firstUnavailableDateInRange($boundedBook, $addDays($today, 30), $today) === null, + '22d first-conflict lookup handles huge ranges, exclusions and inverted windows without enumerating dates'); +$asAdmin(); +$response = (new PrestitiController())->store($post('/admin/loans/store', [ + 'loan_submission_token' => \App\Support\OneTimeFormToken::issue('loan.create'), + 'utente_id' => $requester, 'libro_id' => $boundedBook, + 'data_prestito' => $today, 'data_scadenza' => '9999-12-31', +]), new SlimResponse(), $db); +$params = $redirectParams($response); +$check(($params['error'] ?? '') === 'no_copies_available' && ($params['max_end_date'] ?? '') === $addDays($today, 9), + '22e staff refusal of a range ending in year 9999 reports the same first conflict', $response->getHeaderLine('Location')); +[$adjacentBook, $adjacentCopies] = $mkBook(2, 5); +$mkLoan($adjacentBook, $adjacentCopies[0], $otherUser, 'prenotato', 1, $addDays($today, 5), $addDays($today, 15)); +$mkLoan($adjacentBook, $adjacentCopies[1], $requester, 'prenotato', 1, $addDays($today, 1), $addDays($today, 4)); +$check($capacity->firstUnavailableDateInRange($adjacentBook, $today, '9999-12-31') === null, + '22f adjacent commitments on different copies do not create a false full day'); +$overlapLoan = $mkLoan($adjacentBook, $adjacentCopies[1], $admin, 'prenotato', 1, $addDays($today, 10), $addDays($today, 12)); +$allBlocked = $capacity->unavailableDatesInRange($adjacentBook, $today, $addDays($today, 20)); +$check($capacity->firstUnavailableDateInRange($adjacentBook, $today, '9999-12-31') === ($allBlocked[0] ?? null) + && $capacity->firstUnavailableDateInRange($adjacentBook, $today, '9999-12-31', null, $overlapLoan) === null, + '22g first-conflict sweep agrees with per-day capacity and honors loan exclusions'); +[$zeroBook] = $mkBook(0, 6); +$check($capacity->firstUnavailableDateInRange($zeroBook, $today, '9999-12-31') === $today, + '22h zero lendable copies block the start without building a huge date list'); +$settings->set('loans', 'loan_duration_days', '0'); +$settings->set('loans', 'max_loan_duration_days', '0'); +$check($settings->maxLoanDurationDays() === 90 && $settings->requestLoanDurationDays() === 30, + '23 invalid durations retain the existing 30-day default and 90-day maximum fallbacks'); + +$cleanup(); +$db->close(); + +echo PHP_EOL . "Passed: {$passed} Failed: {$failed}" . PHP_EOL; +exit($failed === 0 ? 0 : 1); diff --git a/tests/mobile-api-circulation.unit.php b/tests/mobile-api-circulation.unit.php index 879ed5cf7..f2f401c8d 100644 --- a/tests/mobile-api-circulation.unit.php +++ b/tests/mobile-api-circulation.unit.php @@ -109,6 +109,8 @@ ['RENEW', 'loan_not_picked_up', 409, 'loan_not_active', null, 'loan_not_picked_up'], ['RENEW', 'loan_overdue', 409, 'loan_overdue', null, null], ['RENEW', 'max_renewals', 409, 'max_renewals', null, null], + ['RENEW', 'invalid_date_format', 422, 'validation_error', 'new_due_date', null], + ['RENEW', 'invalid_renewal_date', 422, 'validation_error', 'new_due_date', null], ['RENEW', 'extension_conflicts', 409, 'not_available', null, 'extension_conflicts'], ['BULK', 'bulk_extend_conflict', 409, 'not_available', null, 'bulk_extend_conflict'], ['BULK', 'bulk_extend_invalid', 422, 'validation_error', 'ids', 'bulk_extend_invalid'], diff --git a/tests/php-security-advisory.unit.php b/tests/php-security-advisory.unit.php new file mode 100644 index 000000000..83ae12966 --- /dev/null +++ b/tests/php-security-advisory.unit.php @@ -0,0 +1,85 @@ + PhpSecurityAdvisory::check($v)['fixed'] ?? null; + +foreach (PhpSecurityAdvisory::FIXED as $branch => $fixed) { + [$maj, $min, $patch] = array_map('intval', explode('.', $fixed)); + $previous = "{$maj}.{$min}." . ($patch - 1); + $next = "{$maj}.{$min}." . ($patch + 1); + check_advisory($fixedFor($previous) === $fixed, "{$previous} is reported, to move to {$fixed}"); + check_advisory($fixedFor("{$maj}.{$min}.0") === $fixed, "{$maj}.{$min}.0 is reported"); + check_advisory($fixedFor($fixed) === null, "{$fixed} has the fixes"); + check_advisory($fixedFor($next) === null, "{$next} has the fixes"); +} + +check_advisory($fixedFor('8.4.26') === '8.4.27', 'the 0.8.6 Docker image base (8.4.26) is reported'); +check_advisory($fixedFor('8.3.35') === '8.3.36', 'a cPanel host on 8.3.35 is reported'); +check_advisory($fixedFor('8.4.10-1+0~20260101+debian12') === '8.4.27', 'a version with a distribution suffix is read by its release number'); +check_advisory($fixedFor('8.1.31') === '8.2.35', 'a branch the PHP project no longer patches is reported against the oldest fix'); +check_advisory($fixedFor('7.4.33') === '8.2.35', '7.4 is reported'); +check_advisory($fixedFor('8.6.0') === null, 'a branch released after the fix is not reported'); +check_advisory($fixedFor('8.6.0RC3') === null, 'a release candidate of a newer branch is not reported'); +check_advisory($fixedFor('8.4.27RC1') === '8.4.27', 'a release candidate of the fixed release comes before it and is reported'); +check_advisory($fixedFor('8.4.27-dev') === '8.4.27', 'a development build of the fixed release is reported'); +check_advisory($fixedFor('8.4.28RC1') === null, 'a release candidate after the fixed release is not reported'); +check_advisory($fixedFor('8.2.35rc2') === '8.2.35', 'the suffix is read in any case'); +check_advisory($fixedFor('9.0.0') === null, 'a later major is not reported'); +check_advisory($fixedFor('not a version') === null, 'an unreadable version is not reported'); +check_advisory(PhpSecurityAdvisory::check(PHP_VERSION) === PhpSecurityAdvisory::check(), 'the default argument is the running PHP'); +check_advisory(PhpSecurityAdvisory::CVES === ['CVE-2026-109618', 'CVE-2026-109619'], 'both CVE ids are named'); +check_advisory(PhpSecurityAdvisory::changelogUrl('8.4.27') === 'https://www.php.net/ChangeLog-8.php#8.4.27', 'the link opens the changelog entry of the release to install'); + +// ── The partial ───────────────────────────────────────────────────────────── +$render = static function (?string $role, string $class = 'mb-8'): string { + $_SESSION = $role === null ? [] : ['user' => ['id' => 1, 'tipo_utente' => $role]]; + $phpAdvisoryClass = $class; + ob_start(); + require dirname(__DIR__) . '/app/Views/partials/php-security-advisory.php'; + return (string) ob_get_clean(); +}; +$affected = PhpSecurityAdvisory::check() !== null; +$admin = $render('admin'); +if ($affected) { + $fixed = (string) PhpSecurityAdvisory::check()['fixed']; + check_advisory(str_contains($admin, 'data-php-security-advisory') && str_contains($admin, 'role="alert"'), 'an administrator sees the notice on an affected PHP (' . PHP_VERSION . ')'); + check_advisory(str_contains($admin, PHP_VERSION) && str_contains($admin, $fixed), 'the notice names the running PHP and the release to move to'); + check_advisory(str_contains($admin, 'CVE-2026-109618, CVE-2026-109619'), 'the notice names both CVEs'); + check_advisory(str_contains($admin, 'href="https://www.php.net/ChangeLog-8.php#' . $fixed . '"') && str_contains($admin, 'rel="noopener noreferrer"'), 'the advisory link opens safely'); + check_advisory(str_contains($admin, 'class="mb-8 p-4 bg-red-50'), 'the including page sets the outer spacing'); +} else { + check_advisory($admin === '', 'on a patched PHP (' . PHP_VERSION . ') even an administrator sees nothing'); +} +check_advisory($render('staff') === '', 'staff see nothing: they cannot change the server'); +check_advisory($render('standard') === '', 'a reader sees nothing'); +check_advisory($render(null) === '', 'a visitor sees nothing'); +check_advisory(!str_contains($render('admin', '">', 'blocked_from' => '2030-03-17'], + ['max_loan_days' => ['6'], 'max_end_date' => '2030-03-16', 'blocked_from' => '2030-03-17'], + ['max_loan_days' => '-6', 'max_end_date' => '2030-03-16', 'blocked_from' => '2030-03-17'], + ['max_loan_days' => '6', 'max_end_date' => '2030-3-16', 'blocked_from' => '2030-03-17'], +]; +$rejected = array_map(static fn (array $p): ?string => LoanWindow::explainFromParams($p), $tampered); +$check($rejected === [null, null, null, null, null], '08 edited query parameters are ignored, never echoed', json_encode($rejected)); + +$one = LoanWindow::explain(1, '2030-03-11', '2030-03-12'); +$check(str_contains($one, '1 giorno') && !str_contains($one, '1 giorni') && str_contains($one, '11-03-2030'), + '09 one day uses the singular sentence', $one); + +// ── LoanWindow: renewals (#493) ───────────────────────────────────────────── +$renew = LoanWindow::renewalCap('2030-04-01', ['2030-04-02']); +$check(($renew['max_extension_days'] ?? -1) === 0 && ($renew['max_due_date'] ?? '') === '2030-04-01', + '10 a block on the day after the due date leaves 0 days and keeps the due date', json_encode($renew)); + +$renew = LoanWindow::renewalCap('2030-04-01', ['2030-04-05'], '2030-04-10'); +$check(($renew['max_due_date'] ?? '') === '2030-04-10' && ($renew['max_extension_days'] ?? -1) === 0, + '11 an overdue bulk extension counted from today never proposes a past due date', json_encode($renew)); + +$check(LoanWindow::renewalCap('2030-04-01', ['2030-03-20', '2030-04-01']) === null, + '12 conflicts on or before the current due date do not bound the renewal'); + +$renew = LoanWindow::renewalCap('2030-04-01', ['2030-04-09', '2030-04-06']); +$check(($renew['max_due_date'] ?? '') === '2030-04-05' && ($renew['max_extension_days'] ?? 0) === 4 + && ($renew['first_conflict_date'] ?? '') === '2030-04-06', '13 the earliest later conflict bounds the renewal', json_encode($renew)); + +$none = LoanWindow::explainRenewal(0, '2030-04-01', '2030-04-02'); +$check(str_contains($none, 'Rinnovo non possibile') && str_contains($none, '02-04-2030'), + '14 zero days says that no renewal is possible and from when', $none); + +$back = LoanWindow::explainRenewalFromParams(LoanWindow::renewalRedirectParams('2030-04-01', ['2030-04-06'])); +$check($back === LoanWindow::explainRenewal(4, '2030-04-05', '2030-04-06') + && LoanWindow::explainRenewalFromParams(['max_extension_days' => 'four', 'max_due_date' => '2030-04-05', 'blocked_from' => '2030-04-06']) === null, + '15 renewal parameters survive the redirect and reject non-numeric days', (string) $back); + +// ── CopyRepository::inventoryBase() (#489) ────────────────────────────────── +$check(CopyRepository::inventoryBase(42, 'BIB') === 'BIB', '16 the book prefix is the base when it is set'); + +$blank = [CopyRepository::inventoryBase(42, ''), CopyRepository::inventoryBase(42, ' '), CopyRepository::inventoryBase(42, null)]; +$check($blank === ['LIB-42', 'LIB-42', 'LIB-42'], '17 an empty, blank or missing prefix falls back to LIB-', json_encode($blank)); + +$check(CopyRepository::inventoryBase(7, ['BIB']) === 'LIB-7' && CopyRepository::inventoryBase(7, new stdClass()) === 'LIB-7', + '18 a non-scalar value is not a prefix'); + +$check(CopyRepository::inventoryBase(7, '0') === '0' && CopyRepository::inventoryBase(7, 2026) === '2026', + '19 a prefix of 0 and a numeric prefix are honoured'); + +$check(CopyRepository::inventoryBase(7, " FONDO-A \t") === 'FONDO-A', '20 surrounding whitespace is never part of a code'); + +// ── PhpSecurityAdvisory table (#491) ─────────────────────────────────────── +$branches = array_keys(PhpSecurityAdvisory::FIXED); +$sorted = $branches; +usort($sorted, 'version_compare'); +$consistent = $branches === $sorted; +foreach (PhpSecurityAdvisory::FIXED as $branch => $fixed) { + $consistent = $consistent && str_starts_with($fixed, $branch . '.') && preg_match('/^\d+\.\d+\.\d+$/', $fixed) === 1; +} +$check($consistent, '21 every fixed release belongs to its branch, branches in ascending order'); + +// ── The release itself ───────────────────────────────────────────────────── +$keys = [ + 'PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo', + 'Avviso di sicurezza di PHP', + 'Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.', +]; +$missing = []; +foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK'] as $locale) { + $table = json_decode((string) file_get_contents("{$root}/locale/{$locale}.json"), true); + foreach ($keys as $key) { + $value = $table[$key] ?? null; + if (!is_string($value) || substr_count($value, '%s') !== substr_count($key, '%s')) { + $missing[] = "{$locale}: {$key}"; + } + } +} +$check($missing === [], '22 the new notice and description strings exist in all five locales with their placeholders', implode('; ', $missing)); + +$version = (string) json_decode((string) file_get_contents("{$root}/version.json"), true)['version']; +$manifests = []; +foreach (['desiderata', 'mobile-api'] as $plugin) { + $manifest = json_decode((string) file_get_contents("{$root}/storage/plugins/{$plugin}/plugin.json"), true); + $required = (string) ($manifest['requires_app'] ?? ''); + $manifests[$plugin] = $required; +} +$usesHelper = str_contains((string) file_get_contents("{$root}/storage/plugins/desiderata/DesiderataPlugin.php"), 'inventoryBase(') + && str_contains((string) file_get_contents("{$root}/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php"), 'inventoryBase('); +$check($usesHelper && $manifests === ['desiderata' => '0.8.7', 'mobile-api' => '0.8.7'] && version_compare('0.8.7', $version, '<='), + '23 the two plugins that call inventoryBase() require the core that has it, and no later one', json_encode($manifests) . " app={$version}"); + +$changelog = (string) file_get_contents("{$root}/CHANGELOG.md"); +$readme = (string) file_get_contents("{$root}/README.md"); +$check(str_contains($changelog, "## [{$version}]") && str_contains($readme, "## What's New in v{$version}") + && preg_match('/badge\/dynamic\/json[^)]*version\.json/', $readme) === 1 + && preg_match('/img\.shields\.io\/badge\/version-\d/', $readme) === 0, + '24 changelog and README describe this version, and the version badge stays dynamic'); + +$pages = []; +foreach (['app/Views/dashboard/index.php', 'app/Views/admin/updates.php'] as $view) { + $pages[$view] = substr_count((string) file_get_contents("{$root}/{$view}"), "partials/php-security-advisory.php"); +} +$check($pages === ['app/Views/dashboard/index.php' => 1, 'app/Views/admin/updates.php' => 1], + '25 the dashboard and the updates page each show the PHP notice once', json_encode($pages)); + +I18n::setLocale($previousLocale); +echo "\nALL {$n} PASS\n"; diff --git a/version.json b/version.json index 5a881bbb1..f59714267 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "name": "Pinakes", - "version": "0.8.6", + "version": "0.8.7", "description": "Library Management System - Sistema di Gestione Bibliotecaria" }