diff --git a/.github/workflows/ci-deep-regression.yml b/.github/workflows/ci-deep-regression.yml index 896da77d3..d86b4c43c 100644 --- a/.github/workflows/ci-deep-regression.yml +++ b/.github/workflows/ci-deep-regression.yml @@ -73,7 +73,8 @@ jobs: sudo apt-get update -q # php-apcu: the app's page cache must run on its production backend # (APCu shared memory) under Apache/mod_php, not the file fallback. - sudo apt-get install -y apache2 libapache2-mod-php php-apcu default-mysql-client msmtp-mta + # PDF layout assertions need Poppler; without it they silently skip. + sudo apt-get install -y apache2 libapache2-mod-php php-apcu default-mysql-client msmtp-mta poppler-utils sudo a2enmod rewrite headers env php_module="$(find /etc/apache2/mods-available -maxdepth 1 -name 'php*.load' -printf '%f\n' | sed 's/\.load$//' | sort -V | tail -n1)" [ -z "${php_module}" ] || sudo a2enmod "${php_module}" @@ -232,6 +233,10 @@ jobs: RESULT_FILE: test-results/shard-${{ matrix.shard }}.json run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE" + - name: Reject PHP fatal errors in the server log + if: always() && steps.deep-regression.outcome != 'skipped' + run: bash scripts/ci-check-php-errors.sh /var/log/apache2/pinakes-error.log + - name: Upload browser evidence uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1afb419..3c039197a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ Full version-by-version history for Pinakes. The README shows only the last three releases; everything older lives here. +## [0.9.0-rc.3] + +Release candidate: published as a GitHub prerelease, offered only to installations that opt in with `UPDATER_CHANNEL=rc` or `UPDATER_ALLOW_PRERELEASE=1`. No migration and no schema change. + +### Loans follow closures added later + +- **Existing due dates move.** Saving a closure, a weekly closed day or a holiday in Admin → Calendar now moves every active loan (out, waiting for pickup or booked ahead) whose due date, today or later, falls on a day that is now closed for the copy's branch to the next open day, as a loan made after the change would get; pickup deadlines follow. Each move is recorded in the loan history and re-arms the due-soon reminder. A move the book cannot take (the next reservation of that copy starts on the new date, or the added days are promised to another reservation) is left out and the loan keeps its date. A loan already marked late whose due date is now closed goes back to in progress. When a move is blocked by a lock (the book row held by another transaction) the calendar page warns that the moves are incomplete and the next save or the scheduled maintenance completes them; nothing is half-written. +- **Overdue counts start from the first open day.** A loan due on a day that was closed afterwards is not late until the first open day after it: the overdue sweep, the overdue e-mail, the mobile overdue push and the fine all follow `LibraryCalendar::isPastDue()`. Notices and recalls state the days late in open days, as the fine counts them, and never say "0 days" for a loan that is late. +- **Renewals, reader alerts and native clients agree with the calendar.** A reader can renew on the first usable return day after a retroactive closure, and the renewal counts from that day, as it would had the closure been saved before the due date. A return on that day is not recorded as late. Web lists, reader alerts, the mobile notification feed and the due-soon push use that same deadline. Reader and staff loan responses expose `effective_due_at` and `is_overdue`, while `due_at` retains the stored date for history and editing. Calendar saves also re-read capacity under the book lock, including reservations confirmed by another operator after the initial scan. +- **Recalls and pushes re-read the loan under the lock.** A manual recall or an overdue push claimed on stale data (a loan renewed or returned in the meantime) no longer goes out; the librarian is asked to reload the page, or told that another recall for the same loan has just gone out. A failed release of the calendar lock is retried at once, so the next batch does not wait for the session to end. +- **NCIP, reminder e-mails and exported calendars use the usable return day too.** NCIP renewals count from the same first open day as the desk, including when the old late flag has not been reconciled yet. Due-soon e-mails use that day for both the warning window and the message, checked again under the calendar lock. The library feed, the reader's personal feed and the per-loan calendar file keep an outstanding loan through that day and use its actual overdue status. The staff dashboard's "due today" count uses the same day, including dates whose move was blocked by capacity. +- **The remaining e-mails and staff pages use the usable return day too.** The overdue, recall, fine, receipt (with its PDF), approval, pickup and staff alert e-mails name the first open day after a closed due date, beside the open days counted from it. The admin dashboard, the loan desk, the staff tiles and lists, the reader's home page and the book page show that same day and mark a loan late only once it has gone by; a loan due today is no longer red on the book page, and a cancelled one no longer reads as overdue in its history. Saving a loan without changing its date no longer moves it or resets its reminders, and a renewal of a loan that is not yet late clears an old late flag. +- **Book availability follows the usable return day, by the library's choice.** A loan due on a closed day holds its book through the first open day after it, the day the reader is told, even when the move was refused and the row kept its date. Settings → Loans gains "Same-day handover after a closure": when on, another reader can start on that open day itself. It is off by default. A request's start day now also follows the branch of the book's copies when they all sit in one. +- **Concurrent circulation changes keep their commitments.** A calendar save re-reads the current copy and branch after taking the book lock. Reservations made eligible by a return under a per-material limit, or by a payment settling a debt, remain counted when the calendar checks whether a due date can move. +- **Patron types and accounts save consistently.** Changing the default type, editing it and deleting another type decide from locked rows, so a concurrent default change cannot erase the type's assignments or rules. Creating or editing a reader saves the account and its patron type together: a type removed during the request leaves neither a half-created account nor partially saved account fields. + +### Large holdings + +- **Book details fit within the default PHP memory limit.** A title with 9,999 physical copies can be opened without exhausting the 128 MiB limit. Rendering uses temporary streams instead of keeping several full HTML documents in memory. The creation regression now checks the complete detail response, and the deep CI gate rejects PHP fatal errors even when browser assertions pass. + +### The 0.9.0 admin on a phone + +- **Circulation settings.** The patron types table and the patron type × material rules matrix stack as cards below the `sm` breakpoint, with the same inputs; on desktop their labels no longer break mid-word. +- **Patron photo.** On a phone or tablet "Scatta foto" opens the device's own camera app (either lens); the shot is cropped to the card's 4:5 format and scaled to 480 × 600 with the EXIF orientation honoured. Desktops keep the inline webcam. +- **Visitor log.** The card field has the self-hosted camera scanner of the loan desk (Code128 and QR): a card read by the camera records the entry at once, a typed code still waits for "Registra ingresso". +- **Every 0.9.0 page checked at 390 px.** Attendance, closures calendar, stocktake, MARC import, suppliers, patron import, the new settings tabs, the reader record with its ledger and the return with a fine: nothing scrolls sideways, no field is small enough to make iOS zoom, wide tables read as cards with each value under its column name (as real text, for screen readers), the calendar's month arrows sit side by side and a notification count over 99 shows as "99+". + +### Older admin pages on a phone + +- **Book list, book page and book form.** The row actions stay inside the book list at full size and titles are no longer clamped; the book page's reservations, copies, volumes and loan history stack as cards; the change log wraps long values; the TinyMCE toolbar wraps on several rows; the chip remove cross is readable in both themes; a cover whose file is gone shows no empty frame and keeps "Rimuovi". +- **Long values wrap.** Write-permission paths in Admin → Updates, catalogue URLs in copy cataloguing and the linked records of an archival authority no longer run past the screen edge. + ## [0.9.0-rc.2] Release candidate: published as a GitHub prerelease, offered only to installations that opt in with `UPDATER_CHANNEL=rc` or `UPDATER_ALLOW_PRERELEASE=1`. No migration: the only schema change is in the Mobile API plugin, which widens `mobile_import_jobs.kind` from an ENUM to `VARCHAR(32)` in its own `ensureSchema()` when it upgrades to 1.10.0. diff --git a/README.md b/README.md index 3ed144879..f5b0d8f8b 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ 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.9.0-rc.2 +## What's New in v0.9.0-rc.3 -**Release candidate. Everything the 0.9.0 admin does, ready for the Android app.** The Mobile API plugin (1.10.0) opens every new 0.9.0 tool to the staff app: stocktake with the phone as scanner, the patron ledger at the desk, library settings, patron types, the rules matrix and the closures calendar, suppliers, catalogue cards, MARC import and export, copy cataloguing, the visitor log, statistics and patron import from CSV. Each area follows its module switch and goes through the same controllers as the website; the Android app gains the matching screens in its next release. No migration. Full details in **[CHANGELOG.md](CHANGELOG.md)**. +**Release candidate. Loans follow closures added later, and the admin works on a phone.** Saving a closure, a weekly closed day or a holiday now moves the active loans that fall on a newly closed day to the next open day, with their pickup deadlines, history and due-soon reminder; a loan due on a day closed afterwards is not late until the first open day after it, and the overdue sweep, e-mail, push and fine all count open days only. Every 0.9.0 page and the older book list, book page, book form, updates and archival pages were checked at 390 px: nothing scrolls sideways, wide tables read as cards and long values wrap. No migration. Full details in **[CHANGELOG.md](CHANGELOG.md)**. --- @@ -53,13 +53,13 @@ The showcase contains 50 catalogue books, 8 desiderata, 3 periodicals with 10 is Highlights of the two releases before the current one are below; the README never lists more than the last three versions. The full version-by-version history lives in **[CHANGELOG.md](CHANGELOG.md)**. -### v0.9.0-rc.1 +### v0.9.0-rc.2 -**Stocktake, MARC in and out, and the circulation tools of a full library system.** Inventory stocktake sessions with barcode, camera or list scanning and a live report; MARC 21, UNIMARC and danMARC2 bulk import and export with copy cataloguing over SRU; a visitor log, ISBD catalogue cards, suppliers and RDA types; printable library cards, patron photos and user import from CSV; patron types with a rules matrix, a closures calendar and optional fines with a patron ledger. Every module is optional and starts switched off on an upgrade. One additive migration (`migrate_0.9.0-rc.1.sql`). Full details in **[CHANGELOG.md](CHANGELOG.md)**. +**Everything the 0.9.0 admin does, ready for the Android app.** The Mobile API plugin (1.10.0) opens every new 0.9.0 tool to the staff app: stocktake with the phone as scanner, the patron ledger at the desk, library settings, patron types, the rules matrix, the closures calendar, suppliers, catalogue cards, MARC import and export, copy cataloguing, the visitor log, statistics and patron import, each through the same controllers as the website. No migration. Full details in **[CHANGELOG.md](CHANGELOG.md)**. -### v0.8.9 +### v0.9.0-rc.1 -**Search Console verification, a pickup reminder, and an SEO pass.** Settings → Advanced has a field for the `` verification tags of Google, Bing and the other search engines ([#497](https://github.com/fabiodalez-dev/Pinakes/pull/497)). Readers get a reminder before the pickup deadline passes, "Loan approved" can say how many days they have to pick the book up, and the staff is told when a pickup expires ([#498](https://github.com/fabiodalez-dev/Pinakes/pull/498)); the new approval text ships to new installations only, existing ones keep their template and can add `{{giorni_ritiro}}` and `{{scadenza_ritiro}}`. The 404 page is no longer indexed, empty pages past the last one answer 404, structured data and the sitemap are more complete, and the loan request calendar fills its row. One additive migration (`migrate_0.8.9.sql`). Full details in **[CHANGELOG.md](CHANGELOG.md)**. +**Stocktake, MARC in and out, and the circulation tools of a full library system.** Inventory stocktake sessions with barcode, camera or list scanning and a live report; MARC 21, UNIMARC and danMARC2 bulk import and export with copy cataloguing over SRU; a visitor log, ISBD catalogue cards, suppliers and RDA types; printable library cards, patron photos and user import from CSV; patron types with a rules matrix, a closures calendar and optional fines with a patron ledger. Every module is optional and starts switched off on an upgrade. One additive migration (`migrate_0.9.0-rc.1.sql`). Full details in **[CHANGELOG.md](CHANGELOG.md)**. Older releases are in **[CHANGELOG.md](CHANGELOG.md)**. diff --git a/app/Controllers/Admin/LibraryCalendarController.php b/app/Controllers/Admin/LibraryCalendarController.php index b65ef4dd9..08a0b8398 100644 --- a/app/Controllers/Admin/LibraryCalendarController.php +++ b/app/Controllers/Admin/LibraryCalendarController.php @@ -625,6 +625,7 @@ private function commitChange(\mysqli $db, callable $write): ?string LibraryCalendar::reset(); LibraryCalendar::extendDueDates($db); LibraryCalendar::extendPickupDeadlines($db); + LibraryCalendar::bumpRevision($db); $db->commit(); return null; } diff --git a/app/Controllers/CirculationSettingsController.php b/app/Controllers/CirculationSettingsController.php index 7ab40803a..792186199 100644 --- a/app/Controllers/CirculationSettingsController.php +++ b/app/Controllers/CirculationSettingsController.php @@ -261,6 +261,15 @@ public function updateType(Request $request, Response $response, mysqli $db, int } try { $db->begin_transaction(); + $locked = $this->lockType($db, $id); + if ($locked === null) { + $db->rollback(); + $_SESSION['error_message'] = __('Tipologia lettore non trovata.'); + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + if ((int) $locked['predefinita'] === 1) { + $fields['attiva'] = 1; + } $this->update($db, 'tipologie_lettore', $fields, $id); if (!empty($data['predefinita'])) { $this->setDefaultTx($db, $id); @@ -289,6 +298,17 @@ public function deleteType(Request $request, Response $response, mysqli $db, int } else { try { $db->begin_transaction(); + // The initial form read may precede another administrator's + // set-default transaction. Decide before touching readers or + // rules, from the same locked row setDefaultTx() uses. + $locked = $this->lockType($db, $id); + if ($locked === null || (int) $locked['predefinita'] === 1) { + $db->rollback(); + $_SESSION['error_message'] = $locked === null + ? __('Tipologia lettore non trovata.') + : __('La tipologia predefinita non può essere eliminata: scegli prima un\'altra predefinita.'); + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } // Patrons of the removed type fall back to the default one // (the FK is ON DELETE SET NULL; done explicitly for clarity). $stmt = $db->prepare('UPDATE utenti SET tipologia_id = NULL WHERE tipologia_id = ?'); @@ -603,6 +623,11 @@ private function codeTaken(mysqli $db, string $code, ?int $exceptId): bool /** Exactly one default type: clear the flag everywhere, then set it (inside the caller's transaction). */ private function setDefaultTx(mysqli $db, int $id): void { + // A target deleted after the page read must not clear the old default. + // Lock before either write so deletion and default selection serialize. + if ($this->lockType($db, $id) === null) { + throw new \RuntimeException('Patron type no longer exists.'); + } $db->query('UPDATE tipologie_lettore SET predefinita = 0 WHERE predefinita = 1'); $stmt = $db->prepare('UPDATE tipologie_lettore SET predefinita = 1, attiva = 1 WHERE id = ?'); $stmt->bind_param('i', $id); @@ -610,6 +635,17 @@ private function setDefaultTx(mysqli $db, int $id): void $stmt->close(); } + /** Current patron type inside the caller's transaction. @return array|null */ + private function lockType(mysqli $db, int $id): ?array + { + $stmt = $db->prepare('SELECT * FROM tipologie_lettore WHERE id = ? FOR UPDATE'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + return $row ?: null; + } + /** @param array $fields */ private function insert(mysqli $db, string $table, array $fields): int { diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index 584d1d508..19ac94ee3 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -467,6 +467,8 @@ public function show(Request $request, Response $response, mysqli $db, int $id): $loanHistoryQuery = " SELECT p.id, + p.copia_id, + p.attivo, p.data_prestito, p.data_scadenza, p.data_restituzione, @@ -488,11 +490,14 @@ public function show(Request $request, Response $response, mysqli $db, int $id): $stmt = $db->prepare($loanHistoryQuery); $stmt->bind_param('i', $id); $stmt->execute(); - $result = $stmt->get_result(); - while ($row = $result->fetch_assoc()) { - $loanHistory[] = $row; - } + $historyRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); $stmt->close(); + // One branch read for the page, not one per loan. + \App\Support\LibraryCalendar::primeCopyBranches($db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $historyRows)); + $historyToday = \App\Support\DateHelper::today(); + foreach ($historyRows as $row) { + $loanHistory[] = $row + \App\Support\LibraryCalendar::loanDates($db, $row, $historyToday); + } } // Active reservations for this book (admin/staff only - contains PII) @@ -602,20 +607,34 @@ public function show(Request $request, Response $response, mysqli $db, int $id): ); } - ob_start(); - // extract([ - // 'libro' => $libro, - // 'activeLoan' => $activeLoan, - // 'bookPath' => $bookPath, - // 'loanHistory' => $loanHistory, - // 'libraryThingInstalled' => $libraryThingInstalled, - // ]); - require __DIR__ . '/../Views/libri/scheda_libro.php'; - $content = ob_get_clean(); - ob_start(); - require __DIR__ . '/../Views/layout.php'; - $html = ob_get_clean(); - $response->getBody()->write($html); + // A book may have thousands of physical copies. Keep both rendering + // stages in temporary streams instead of holding full copies of the + // view, layout buffer and final HTML alongside the database rows. + // HTTP emission still happens after this action returns. + $content = (new \Slim\Psr7\Factory\StreamFactory())->createStream(); + $body = $response->getBody(); + $bufferLevel = ob_get_level(); + try { + ob_start(static function (string $chunk) use ($content): string { + $content->write($chunk); + return ''; + }, 8192); + require __DIR__ . '/../Views/libri/scheda_libro.php'; + ob_end_flush(); + $content->rewind(); + + ob_start(static function (string $chunk) use ($body): string { + $body->write($chunk); + return ''; + }, 8192); + require __DIR__ . '/../Views/layout.php'; + ob_end_flush(); + } finally { + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + $content->close(); + } return $response; } diff --git a/app/Controllers/LoanApprovalController.php b/app/Controllers/LoanApprovalController.php index 955f27d5b..3ea1fe2f8 100644 --- a/app/Controllers/LoanApprovalController.php +++ b/app/Controllers/LoanApprovalController.php @@ -77,7 +77,7 @@ public function pendingLoans(Request $request, Response $response, mysqli $db): $scheduledLoans = $scheduledStmt->get_result()->fetch_all(MYSQLI_ASSOC); $scheduledStmt->close(); - // Get active loans (in_corso) + // Get the loans that are out, split below by is_overdue $activeStmt = $db->prepare(" SELECT p.*, l.titolo, l.copertina_url, CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email, @@ -88,32 +88,30 @@ public function pendingLoans(Request $request, Response $response, mysqli $db): JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL JOIN utenti u ON p.utente_id = u.id LEFT JOIN copie c ON p.copia_id = c.id - WHERE p.stato = 'in_corso' AND p.attivo = 1 + WHERE p.stato IN ('in_corso', 'in_ritardo') AND p.attivo = 1 ORDER BY p.data_scadenza ASC "); $activeStmt->execute(); $activeLoans = $activeStmt->get_result()->fetch_all(MYSQLI_ASSOC); $activeStmt->close(); - // Get overdue loans (in_ritardo) - $overdueStmt = $db->prepare(" - SELECT p.*, l.titolo, l.copertina_url, - CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email, - p.data_prestito as data_richiesta_inizio, - p.data_scadenza as data_richiesta_fine, - c.numero_inventario as copia_inventario, - DATEDIFF(?, p.data_scadenza) as giorni_ritardo - FROM prestiti p - JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL - JOIN utenti u ON p.utente_id = u.id - LEFT JOIN copie c ON p.copia_id = c.id - WHERE p.stato = 'in_ritardo' AND p.attivo = 1 - ORDER BY p.data_scadenza ASC - "); - $overdueStmt->bind_param('s', $today); - $overdueStmt->execute(); - $overdueLoans = $overdueStmt->get_result()->fetch_all(MYSQLI_ASSOC); - $overdueStmt->close(); + // A loan out sits in one list, by what is_overdue says whatever a sweep + // has stored so far. Both read the usable return day, and the late one the + // open-day late count, as the notices and the fine do. One branch read for the page. + \App\Support\LibraryCalendar::primeCopyBranches($db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $activeLoans)); + $loansOut = $activeLoans; + $activeLoans = []; + $overdueLoans = []; + foreach ($loansOut as $loan) { + $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; + $dated = $loan + \App\Support\LibraryCalendar::loanDates($db, $loan, $today); + if ($dated['is_overdue']) { + $dated['giorni_ritardo'] = max(1, \App\Support\LibraryCalendar::lateDaysForNotice($db, (string) $loan['data_scadenza'], $copyId, $today)); + $overdueLoans[] = $dated; + } else { + $activeLoans[] = $dated; + } + } // Get active reservations $reservationsStmt = $db->prepare(" @@ -1529,7 +1527,7 @@ public function returnLoan(Request $request, Response $response, mysqli $db): Re // This is the primary quick-return path — without it late returns keep the // flag at 0 and Stats/Recensioni undercount late returners. $scadenza = (string) ($loan['data_scadenza'] ?? ''); - $ritardo = ($scadenza !== '' && $scadenza < $dataRestituzione) ? 1 : 0; + $ritardo = \App\Support\LibraryCalendar::isPastDue($db, $scadenza, $copiaId, $dataRestituzione) ? 1 : 0; // Close the loan $stmt = $db->prepare("UPDATE prestiti SET stato = 'restituito', restituito_in_ritardo = ?, data_restituzione = ?, attivo = 0 WHERE id = ? AND attivo = 1"); diff --git a/app/Controllers/PrestitiApiController.php b/app/Controllers/PrestitiApiController.php index ff8894ebe..d3dec7459 100644 --- a/app/Controllers/PrestitiApiController.php +++ b/app/Controllers/PrestitiApiController.php @@ -59,7 +59,13 @@ public function list(Request $request, Response $response, mysqli $db): Response $params[] = $to; $param_types .= 's'; } - if ($statoSpecifico !== '') { + if ($statoSpecifico === 'in_corso' || $statoSpecifico === 'in_ritardo') { + // Same verdict as the row's is_overdue, not the stored stato a sweep + // may not have updated yet. + $late = \App\Support\LibraryCalendar::overdueLoanCondition($db, \App\Support\DateHelper::today()); + $where_prepared .= " AND p.attivo = 1 AND p.stato IN ('in_corso', 'in_ritardo') AND " + . ($statoSpecifico === 'in_ritardo' ? "({$late})" : "NOT ({$late})") . ' '; + } elseif ($statoSpecifico !== '') { $where_prepared .= " AND p.stato = ? "; $params[] = $statoSpecifico; $param_types .= 's'; @@ -138,7 +144,7 @@ public function list(Request $request, Response $response, mysqli $db): Response $param_types .= 'ii'; $sql_prepared = "SELECT p.id, l.titolo AS libro, CONCAT(u.nome,' ',u.cognome) AS utente, - p.data_prestito, p.data_scadenza, p.data_restituzione, p.attivo, p.stato, + p.copia_id, p.data_prestito, p.data_scadenza, p.data_restituzione, p.attivo, p.stato, CONCAT(staff.nome,' ',staff.cognome) AS processed_by $base $where_prepared ORDER BY $orderColumn $orderDir LIMIT ?, ?"; @@ -154,14 +160,17 @@ public function list(Request $request, Response $response, mysqli $db): Response $stmt->execute(); $res = $stmt->get_result(); + $fetched = $res->fetch_all(MYSQLI_ASSOC); + // One branch read for the page, not one per loan. + \App\Support\LibraryCalendar::primeCopyBranches($db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $fetched)); $rows = []; - while ($r = $res->fetch_assoc()) { + foreach ($fetched as $r) { $actions = ''.htmlspecialchars(__('Dettagli'), ENT_QUOTES, 'UTF-8').''; $actions .= ' | '.htmlspecialchars(__('Modifica'), ENT_QUOTES, 'UTF-8').''; if ((int)$r['attivo'] === 1 && in_array((string) $r['stato'], ['in_corso', 'in_ritardo'], true)) { $actions .= ' | '.htmlspecialchars(__('Restituito'), ENT_QUOTES, 'UTF-8').''; } - $rows[] = [ + $rows[] = \App\Support\LibraryCalendar::loanDates($db, $r) + [ 'id' => (int)$r['id'], 'libro' => $r['libro'] ?? '', 'utente' => $r['utente'] ?? '', diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index e1eb7fb88..8ced56956 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -903,9 +903,19 @@ public function update(Request $request, Response $response, mysqli $db, int $id $libroId = (int) $current['libro_id']; $newUserId = isset($updateData['utente_id']) ? (int) $updateData['utente_id'] : (int) $current['utente_id']; $newPrestito = (string) ($updateData['data_prestito'] ?? $current['data_prestito']); - // Closures calendar: a submitted due date on a closed day moves forward. - if (isset($updateData['data_scadenza']) && \App\Support\DateHelper::isISODateFormat((string) $updateData['data_scadenza'])) { - $updateData['data_scadenza'] = \App\Services\CirculationPolicy::dueDateFor((string) $updateData['data_scadenza'], 0, $db, $current['copia_id'] !== null ? (int) $current['copia_id'] : null); + // Closures calendar: a changed due date on a closed day moves forward. + // The edit form always submits the field, so the stored value coming + // back is no change at all and stays as it is, closed day or not. + $submittedScadenza = isset($updateData['data_scadenza']) && \App\Support\DateHelper::isISODateFormat((string) $updateData['data_scadenza']) + ? (string) $updateData['data_scadenza'] + : null; + $dueFromSubmitted = static function (array $row) use ($submittedScadenza, $db): string { + return $submittedScadenza === substr((string) $row['data_scadenza'], 0, 10) + ? (string) $row['data_scadenza'] + : \App\Services\CirculationPolicy::dueDateFor((string) $submittedScadenza, 0, $db, $row['copia_id'] !== null ? (int) $row['copia_id'] : null); + }; + if ($submittedScadenza !== null) { + $updateData['data_scadenza'] = $dueFromSubmitted($current); } $newScadenza = (string) ($updateData['data_scadenza'] ?? $current['data_scadenza']); @@ -984,6 +994,11 @@ public function update(Request $request, Response $response, mysqli $db, int $id // come fast-fail senza aprire la transazione). $newUserId = isset($updateData['utente_id']) ? (int) $updateData['utente_id'] : (int) $locked['utente_id']; $newPrestito = (string) ($updateData['data_prestito'] ?? $locked['data_prestito']); + // Decided again against the locked row: the date or the copy may have + // changed since the first read. + if ($submittedScadenza !== null) { + $updateData['data_scadenza'] = $dueFromSubmitted($locked); + } $newScadenza = (string) ($updateData['data_scadenza'] ?? $locked['data_scadenza']); // A pickup sender owns an immutable recipient snapshot while its @@ -1571,7 +1586,9 @@ public function processReturn(Request $request, Response $response, mysqli $db, // Ritardo: un rientro oltre la scadenza è 'restituito' + flag (I4/BUG5). // Confronto lessicografico Y-m-d sicuro. Significativo solo per 'restituito'. $scadenza = (string) ($loan['data_scadenza'] ?? ''); - $ritardo = ($loan_stato === 'restituito' && $scadenza !== '' && $scadenza < $data_restituzione) ? 1 : 0; + // Late by the calendar, as the fine: a due day closed afterwards is + // met by a return on the next open day. + $ritardo = ($loan_stato === 'restituito' && \App\Support\LibraryCalendar::isPastDue($db, $scadenza, $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null, (string) $data_restituzione)) ? 1 : 0; // Aggiorna il prestito (state-guard sull'attivo per evitare doppie restituzioni) $stmt = $db->prepare("UPDATE prestiti SET stato = ?, data_restituzione = ?, note = ?, sanzione = ?, attivo = 0, restituito_in_ritardo = ? WHERE id = ? AND attivo = 1"); @@ -1766,12 +1783,13 @@ public function processReturn(Request $request, Response $response, mysqli $db, } catch (\Throwable $e) { $db->rollback(); SecureLogger::error(__('Errore elaborazione restituzione'), ['loan_id' => $id, 'error' => $e->getMessage()]); - // Deadlock (1213) o lock wait timeout (1205): la transazione è stata - // annullata integralmente, ritentare è sicuro e quasi sempre risolve. + // Deadlock (1213), lock wait timeout (1205) o riga cambiata dopo lo + // snapshot (1020, MariaDB con innodb_snapshot_isolation): la transazione + // è stata annullata, ritentare è sicuro e quasi sempre risolve. // Un errore dedicato evita il generico "update_failed" che non dice // all'operatore che basta ripetere l'operazione. $errno = $e instanceof \mysqli_sql_exception ? (int) $e->getCode() : 0; - $errorCode = in_array($errno, [1213, 1205], true) ? 'concurrent_retry' : 'update_failed'; + $errorCode = in_array($errno, [1020, 1213, 1205], true) ? 'concurrent_retry' : 'update_failed'; if ($redirectTo) { $separator = strpos($redirectTo, '?') === false ? '?' : '&'; return $response->withHeader('Location', url($redirectTo . $separator . 'error=' . $errorCode))->withStatus(302); @@ -1867,6 +1885,7 @@ public function details(Request $request, Response $response, mysqli $db, int $i $prestito = $result->fetch_assoc(); $stmt->close(); + $prestito += \App\Support\LibraryCalendar::loanDates($db, $prestito); // Plan §7: fine accruing on an open overdue loan (shown, written only at // return) and the late fine already charged on a closed one. $ledger = \App\Services\PatronLedger::forDb($db); @@ -2171,8 +2190,18 @@ private function applyBulkLoanExtension( // in the future and clears the "Overdue" status — instead of staying // overdue when N is smaller than the overdue gap. A loan not yet due keeps // extending from its due date. + // The due date counts as its usable return day, as for a single renewal: + // a stored date on a day closed afterwards is due on the next open day. + // The capacity window below still starts after the stored date (wider). + $usableDue = $dueDate; + if ($db !== null) { + $usable = \DateTimeImmutable::createFromFormat('!Y-m-d', \App\Support\LibraryCalendar::dueDate($db, $dueDate->format('Y-m-d'), $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null)); + if ($usable !== false && $usable > $dueDate) { + $usableDue = $usable; + } + } $todayDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $today); - $base = ($todayDate !== false && $todayDate > $dueDate) ? $todayDate : $dueDate; + $base = ($todayDate !== false && $todayDate > $usableDue) ? $todayDate : $usableDue; // With the connection, a new due date on a closed day moves forward. $newDueDate = \App\Services\CirculationPolicy::dueDateFor($base->format('Y-m-d'), $days, $db, $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null); @@ -2256,15 +2285,12 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) return $response->withHeader('Location', url($errorUrl . $separator . 'error=loan_not_active'))->withStatus(302); } - // Check if loan is overdue — by STATE or by DATE (#366 residual): a loan - // whose data_scadenza is already past but that the maintenance sweep has - // not yet flipped to 'in_ritardo' is just as overdue. Renewing it would - // compute the new due date from the stale past date AND reset - // warning_sent/overdue_notification_sent, re-arming a duplicate overdue - // email. Same refusal, same error code. + // A due day closed after lending remains usable on its next open day, + // as in the overdue sweep and fines. Check both before and under the + // loan lock so renewal and the calendar agree on who is actually late. $todayApp = \App\Support\DateHelper::today(); - $isLate = ($loan['stato'] === 'in_ritardo') - || ($loan['stato'] === 'in_corso' && (string) $loan['data_scadenza'] < $todayApp); + $isLate = in_array($loan['stato'], ['in_corso', 'in_ritardo'], true) + && \App\Support\LibraryCalendar::isPastDue($db, (string) $loan['data_scadenza'], isset($loan['copia_id']) ? (int) $loan['copia_id'] : null, $todayApp); if ($isLate) { $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; @@ -2309,7 +2335,10 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // Calculate proposed new due date for conflict checking $currentDueDate = $loan['data_scadenza']; $loanCopyId = isset($loan['copia_id']) ? (int) $loan['copia_id'] : null; - $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor((string) $currentDueDate, $renewDays, $db, $loanCopyId); + // The renewal counts from the usable return day: a stored due date on + // a day closed afterwards is due on the next open day, as the late check. + $usableDueDate = \App\Support\LibraryCalendar::dueDate($db, (string) $currentDueDate, $loanCopyId); + $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor($usableDueDate, $renewDays, $db, $loanCopyId); // 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 @@ -2324,7 +2353,7 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $separator = strpos($errorUrl, '?') === false ? '?' : '&'; return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_date_format'))->withStatus(302); } - if ($requestedDue !== '' && ($requestedDue <= (string) $currentDueDate || $requestedDue > $proposedNewDueDate)) { + if ($requestedDue !== '' && ($requestedDue <= $usableDueDate || $requestedDue > $proposedNewDueDate)) { $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_renewal_date'))->withStatus(302); @@ -2372,9 +2401,8 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $separator = strpos($errorUrl, '?') === false ? '?' : '&'; return $response->withHeader('Location', url($errorUrl . $separator . 'error=loan_not_active'))->withStatus(302); } - // State- OR date-based overdue, same predicate as the pre-lock check. - if ($lockedLoan['stato'] === 'in_ritardo' - || ($lockedLoan['stato'] === 'in_corso' && (string) $lockedLoan['data_scadenza'] < $todayApp)) { + if (in_array($lockedLoan['stato'], ['in_corso', 'in_ritardo'], true) + && \App\Support\LibraryCalendar::isPastDue($db, (string) $lockedLoan['data_scadenza'], isset($lockedLoan['copia_id']) ? (int) $lockedLoan['copia_id'] : null, $todayApp)) { $db->rollback(); $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; @@ -2399,10 +2427,12 @@ 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 = \App\Services\CirculationPolicy::dueDateFor((string) $currentDueDate, $renewDays, $db, $lockedLoan['copia_id'] !== null ? (int) $lockedLoan['copia_id'] : null); + $lockedCopyId = $lockedLoan['copia_id'] !== null ? (int) $lockedLoan['copia_id'] : null; + $usableDueDate = \App\Support\LibraryCalendar::dueDate($db, (string) $currentDueDate, $lockedCopyId); + $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor($usableDueDate, $renewDays, $db, $lockedCopyId); if ($requestedDue !== '') { // Re-bound under the lock: the due date may have moved meanwhile. - if ($requestedDue <= (string) $currentDueDate || $requestedDue > $proposedNewDueDate) { + if ($requestedDue <= $usableDueDate || $requestedDue > $proposedNewDueDate) { $db->rollback(); $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; @@ -2430,9 +2460,11 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $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, $db, $copiaId): string { + // The offered "renew until D" counts from the usable return day, the + // same base the renewal bound above accepts. + $explainRefusal = static function (array $blocked) use ($id, $currentDueDate, $usableDueDate, $db, $copiaId): string { return http_build_query(['error' => 'extension_conflicts', 'loan_id' => $id] - + \App\Support\LoanWindow::renewalRedirectParams((string) $currentDueDate, $blocked, null, \App\Support\LibraryCalendar::forDb($db), \App\Support\LibraryCalendar::branchOfCopy($db, $copiaId))); + + \App\Support\LoanWindow::renewalRedirectParams((string) $currentDueDate, $blocked, $usableDueDate, \App\Support\LibraryCalendar::forDb($db), \App\Support\LibraryCalendar::branchOfCopy($db, $copiaId))); }; if (!$capacity->hasFreeCapacity($libroId, $extensionStart, $extensionEnd, excludePrestitoId: $id)) { $blocked = $capacity->renewalConflictDates($libroId, $id, $copiaId, (string) $currentDueDate, $extensionEnd); @@ -2483,10 +2515,12 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // non scade un prestito già in corso e rinnovato (F2). Azzera anche i // flag di notifica (M5a): la nuova scadenza deve ricevere il suo // promemoria e l'eventuale sollecito, altrimenti un prestito rinnovato - // dopo il warning non verrebbe mai più avvisato. + // dopo il warning non verrebbe mai più avvisato. Arrivano qui solo + // prestiti non in ritardo per isPastDue(): un vecchio stato 'in_ritardo' + // con la nuova scadenza futura non lo correggerebbe nessun altro. $updateStmt = $db->prepare(" UPDATE prestiti - SET data_scadenza = ?, renewals = ?, pickup_deadline = NULL, + SET data_scadenza = ?, renewals = ?, stato = 'in_corso', pickup_deadline = NULL, warning_sent = 0, overdue_notification_sent = 0, recall_count = 0, last_recall_at = NULL WHERE id = ? diff --git a/app/Controllers/ReservationManager.php b/app/Controllers/ReservationManager.php index 272c33b67..9ec16b615 100644 --- a/app/Controllers/ReservationManager.php +++ b/app/Controllers/ReservationManager.php @@ -284,6 +284,9 @@ public function processBookAvailability($bookId) // deadline; selecting them without normalising the start would // still make isDateRangeAvailable() reject them forever. $nextReservation['data_inizio_richiesta'] = $startDate; + // Capacity is checked on the day the loan will end, which a + // closure can put after the requested one (an open day stays as it is). + $endDate = \App\Support\LibraryCalendar::requestDueDate($this->db, (string) $endDate, 0, $bookId); $nextReservation['data_fine_richiesta'] = $endDate; // #157: pass the promoted reservation's queue_position so the @@ -461,7 +464,7 @@ private function createLoanFromReservation($reservation) $bookId = (int) $reservation['libro_id']; $startDate = $reservation['data_inizio_richiesta']; // Closures calendar: the loan's due date moves to the next open day. - $endDate = \App\Services\CirculationPolicy::dueDateFor((string) $reservation['data_fine_richiesta'], 0, $this->db); + $endDate = \App\Support\LibraryCalendar::requestDueDate($this->db, (string) $reservation['data_fine_richiesta'], 0, $bookId); // Always create as 'pendente' (attivo=0) - requires admin confirmation of physical pickup // Origin is 'prenotazione' to distinguish from manual requests @@ -712,6 +715,11 @@ private function maxActiveQueuePosition(int $bookId): int return $max; } + protected function notificationService(): \App\Support\NotificationService + { + return new \App\Support\NotificationService($this->db); + } + /** * Send notification email when reservation book becomes available * @@ -779,7 +787,7 @@ private function sendReservationNotification(array $reservation): bool // #360 parity: formatta le date nella lingua del DESTINATARIO come // tutta la pipeline email (warning/overdue/solleciti), non nel // locale di installazione. - $notificationService = new \App\Support\NotificationService($this->db); + $notificationService = $this->notificationService(); $recipientLocale = $notificationService->resolveRecipientLocale((string) $reservation['email']); $variables = [ @@ -790,7 +798,8 @@ private function sendReservationNotification(array $reservation): bool 'libro_autore' => $book['autore'] ?: $notificationService->translateInLocale('Autore non specificato', $recipientLocale), 'libro_isbn' => $book['isbn'] ?: 'N/A', 'data_inizio' => $notificationService->formatEmailDate((string) $reservation['data_inizio_richiesta'], false, $recipientLocale), - 'data_fine' => $notificationService->formatEmailDate((string) $reservation['data_fine_richiesta'], false, $recipientLocale), + // The day createLoanFromReservation() stores: a closed end day moves to the next open day. + 'data_fine' => $notificationService->formatEmailDate(\App\Support\LibraryCalendar::requestDueDate($this->db, (string) $reservation['data_fine_richiesta'], 0, (int) $reservation['libro_id']), false, $recipientLocale), 'book_url' => absoluteUrl($bookLink), 'profile_url' => absoluteUrl(RouteTranslator::route('profile')) ]; diff --git a/app/Controllers/ReservationsAdminController.php b/app/Controllers/ReservationsAdminController.php index c6b1bdbd1..f485f36b9 100644 --- a/app/Controllers/ReservationsAdminController.php +++ b/app/Controllers/ReservationsAdminController.php @@ -516,7 +516,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons $loanDays = (new \App\Services\CirculationPolicy($db))->forLoan($utenteId, $libroId)->durationDays; // The default duration starts from the requested start date, not // from today (critical for future reservations). - $dataScadenzaDate = \App\Services\CirculationPolicy::dueDateFor($dataPrenotazioneDate, $loanDays, $db); + $dataScadenzaDate = \App\Support\LibraryCalendar::requestDueDate($db, $dataPrenotazioneDate, $loanDays, (int) $libroId); $dataScadenza = $dataScadenzaDate . ' 23:59:59'; } else { $dataScadenzaDate = $dataScadenza; // Keep date only for loan period diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php index d5e75bc6e..6b4962add 100644 --- a/app/Controllers/ReservationsController.php +++ b/app/Controllers/ReservationsController.php @@ -81,8 +81,9 @@ private function calculateAvailability($currentLoans, $existingReservations, int if ($loanStatus === 'da_ritirare') { // Block the full loan period: the copy is committed to this user // even though they haven't picked it up yet - $endDateLoan = $loan['data_scadenza'] - ?? (new DateTime($startDateLoan))->add(new DateInterval('P7D'))->format('Y-m-d'); + $endDateLoan = $loan['data_scadenza'] !== null + ? $this->holdingEnd($loan) + : (new DateTime($startDateLoan))->add(new DateInterval('P7D'))->format('Y-m-d'); } elseif ( empty($loan['data_restituzione']) && ( @@ -99,7 +100,8 @@ private function calculateAvailability($currentLoans, $existingReservations, int $endDateLoan = '9999-12-31'; } else { // For other states: data_restituzione > data_scadenza > null - $endDateLoan = $loan['data_restituzione'] ?? $loan['data_scadenza'] ?? null; + $endDateLoan = $loan['data_restituzione'] + ?? ($loanStatus !== 'pendente' && !empty($loan['data_scadenza']) ? $this->holdingEnd($loan) : ($loan['data_scadenza'] ?? null)); } // Fallback: if still no end date, use start date (single day block) @@ -293,7 +295,7 @@ public function createReservation($request, $response, $args) // A start day the library is closed moves to the next open day, as the // request calendar proposes it (a crafted request cannot start on a // closure); identical without closures. - $startDate = \App\Support\LibraryCalendar::forDb($this->db)->nextOpenDay($startDate); + $startDate = \App\Support\LibraryCalendar::requestStartDay($this->db, $startDate, $bookId); if ($endDate && $endDate < $startDate) { $response->getBody()->write(json_encode(['success' => false, 'error' => 'invalid_range', 'message' => __('La data di fine non può precedere la data di inizio')])); @@ -314,13 +316,13 @@ public function createReservation($request, $response, $args) // The longest loan ends on the maximum day, moved forward like every // due date when it falls on a closed day (closed days are not loan // days); without closures this is start + maximum, as before. - if ($endDate > \App\Services\CirculationPolicy::dueDateFor((string) $startDate, $maxDurationDays, $this->db)) { + if ($endDate > \App\Support\LibraryCalendar::requestDueDate($this->db, (string) $startDate, $maxDurationDays, (int) $bookId)) { $response->getBody()->write(json_encode(['success' => false, 'error' => 'max_duration_exceeded', 'message' => __('Il periodo richiesto supera la durata massima consentita di %d giorni', $maxDurationDays)])); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } // Closures calendar: a return date on a closed day moves to the next // open day (after the duration cap: closed days are not loan days). - $endDate = \App\Services\CirculationPolicy::dueDateFor($endDate, 0, $this->db); + $endDate = \App\Support\LibraryCalendar::requestDueDate($this->db, (string) $endDate, 0, (int) $bookId); // Availability decision routes through CapacityService — the single // capacity authority — so this web loan-request gate matches the mobile @@ -768,6 +770,13 @@ private function autoApproveLoanRequest($request, int $loanId, ?bool $knownEnabl return null; } + /** @param array $loan */ + private function holdingEnd(array $loan): string + { + $copyId = !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null; + return \App\Support\LibraryCalendar::holdingEnd($this->db, substr((string) $loan['data_scadenza'], 0, 10), $copyId); + } + /** * Per-day availability payload for a book, or NULL when the book does not * exist or is soft-deleted. Every caller must 404 on null: without this @@ -847,7 +856,11 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int $days = $closedHorizon + 1; } $closedDates = \App\Support\LibraryCalendar::forDb($this->db) - ->closedDatesBetween($from, \App\Support\LibraryCalendar::shift($from, $closedHorizon)); + ->closedDatesBetween( + $from, + \App\Support\LibraryCalendar::shift($from, $closedHorizon), + \App\Support\LibraryCalendar::requestBranch($this->db, (int) $bookId) + ); return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId) + [ 'loan_duration_days' => $policy->requestDurationDays(), 'max_loan_duration_days' => $policy->maxRequestDays, diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 995cd0102..23b21a8e8 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -1694,7 +1694,7 @@ public function updateCatalogHeader(Request $request, Response $response, mysqli } /** - * @return array{loan_duration_days: int, pickup_expiry_days: int, pickup_reminder_days: int, card_expiry_warning_days: int, max_renewals: int, max_active_loans_per_user: int, max_loan_duration_days: int, auto_approve_requests: bool, allow_multiple_loans_same_book: bool, recall_auto_enabled: bool, recall_interval_days: int, recall_max_count: int, app_timezone: string, daily_fine: string, fine_grace_mode: string, card_renewal_base: string, fine_grace_days: int, fine_max_per_loan: string, debt_block_threshold: string, fine_charged_email: bool, currency: string, fines_enabled: bool, closures_calendar_enabled: bool, circulation_rules_enabled: bool} + * @return array{loan_duration_days: int, pickup_expiry_days: int, pickup_reminder_days: int, card_expiry_warning_days: int, max_renewals: int, max_active_loans_per_user: int, max_loan_duration_days: int, auto_approve_requests: bool, allow_multiple_loans_same_book: bool, same_day_handover_after_closure: bool, recall_auto_enabled: bool, recall_interval_days: int, recall_max_count: int, app_timezone: string, daily_fine: string, fine_grace_mode: string, card_renewal_base: string, fine_grace_days: int, fine_max_per_loan: string, debt_block_threshold: string, fine_charged_email: bool, currency: string, fines_enabled: bool, closures_calendar_enabled: bool, circulation_rules_enabled: bool} */ public function resolveLoansSettings(SettingsRepository $repository): array { @@ -1709,6 +1709,7 @@ public function resolveLoansSettings(SettingsRepository $repository): array 'max_loan_duration_days' => (int) ($repository->get('loans', 'max_loan_duration_days', '90') ?? 90), 'auto_approve_requests' => $repository->autoApproveLoanRequests(), 'allow_multiple_loans_same_book' => $repository->allowsMultipleLoansSameBook(), + 'same_day_handover_after_closure' => $repository->allowsSameDayHandover(), // #360: automatic recall (sollecito) schedule for overdue loans. 'recall_auto_enabled' => (string) ($repository->get('loans', 'recall_auto_enabled', '0') ?? '0') === '1', 'recall_interval_days' => (int) ($repository->get('loans', 'recall_interval_days', '7') ?? 7), @@ -1783,6 +1784,9 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $autoApprove = isset($data['auto_approve_requests']) && is_scalar($data['auto_approve_requests']) && (string) $data['auto_approve_requests'] === '1'; + $sameDayHandover = isset($data['same_day_handover_after_closure']) + && is_scalar($data['same_day_handover_after_closure']) + && (string) $data['same_day_handover_after_closure'] === '1'; $allowMultipleLoansSameBook = isset($data['allow_multiple_loans_same_book']) && is_scalar($data['allow_multiple_loans_same_book']) && (string) $data['allow_multiple_loans_same_book'] === '1'; @@ -1803,6 +1807,7 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $repository->set('loans', 'max_loan_duration_days', (string) $maxLoanDuration); $repository->set('loans', 'auto_approve_requests', $autoApprove ? '1' : '0'); $repository->set('loans', 'allow_multiple_loans_same_book', $allowMultipleLoansSameBook ? '1' : '0'); + $repository->set('loans', 'same_day_handover_after_closure', $sameDayHandover ? '1' : '0'); $repository->set('loans', 'recall_auto_enabled', $recallEnabled ? '1' : '0'); $repository->set('loans', 'recall_interval_days', (string) $recallInterval); $repository->set('loans', 'recall_max_count', (string) $recallMaxCount); @@ -1844,6 +1849,11 @@ public function updateLoansSettings(Request $request, Response $response, mysqli // next open day, as when the calendar itself is saved. if (\App\Support\Features::enabled('closures_calendar') !== $calendarWasOn) { \App\Support\LibraryCalendar::reset(); + try { + \App\Support\LibraryCalendar::bumpRevision($db); + } catch (\Throwable $e) { + \App\Support\SecureLogger::warning('Calendar: revision not recorded: ' . $e->getMessage()); + } if (!\App\Support\LibraryCalendar::extendDueDatesAfterSave($db)) { $_SESSION['error_message'] = __('Il calendario è salvato, ma alcune scadenze dei prestiti non sono state ancora spostate: lo farà la prossima manutenzione automatica.'); } diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php index 3634d1ca4..58b7da599 100644 --- a/app/Controllers/UserActionsController.php +++ b/app/Controllers/UserActionsController.php @@ -55,7 +55,7 @@ public function reservationsPage(Request $request, Response $response, mysqli $d // e col titolo REALE (convenzione unica con mobile ed email — // CI-SOFT-DELETE-EXEMPT: prestito proprio dell'utente, che per // quel titolo riceve anche i solleciti). - $sql = "SELECT pr.id, pr.libro_id, pr.data_prestito, pr.data_scadenza, pr.stato, + $sql = "SELECT pr.id, pr.libro_id, pr.copia_id, pr.data_prestito, pr.data_scadenza, pr.stato, l.titolo, l.copertina_url, EXISTS(SELECT 1 FROM recensioni r WHERE r.libro_id = pr.libro_id AND r.utente_id = ?) as has_review FROM prestiti pr @@ -69,7 +69,7 @@ public function reservationsPage(Request $request, Response $response, mysqli $d $activePrestiti = []; while ($r = $res->fetch_assoc()) { $r['titolo'] = $r['titolo'] ?? __('Titolo non disponibile'); - $activePrestiti[] = $r; + $activePrestiti[] = $r + \App\Support\LibraryCalendar::loanDates($db, $r); } $stmt->close(); @@ -560,8 +560,6 @@ public function changeReservationDate(Request $request, Response $response, mysq } $uid = (int) $user['id']; - // A closed start day moves to the next open day (see reserve()). - $date = \App\Support\LibraryCalendar::forDb($db)->nextOpenDay($date); $startDate = $date; $db->begin_transaction(); @@ -582,8 +580,11 @@ public function changeReservationDate(Request $request, Response $response, mysq } $libroId = (int) $reservation['libro_id']; + // A closed start day moves to the next open day (see reserve()). + $date = \App\Support\LibraryCalendar::requestStartDay($db, $date, $libroId); + $startDate = $date; $loanDays = (new \App\Services\CirculationPolicy($db))->forLoan($uid, $libroId)->requestDurationDays(); - $endDate = \App\Services\CirculationPolicy::dueDateFor($date, $loanDays, $db); + $endDate = \App\Support\LibraryCalendar::requestDueDate($db, $date, $loanDays, $libroId); // Lock book row first to serialize every capacity decision. $lockStmt = $db->prepare("SELECT id FROM libri WHERE id = ? AND deleted_at IS NULL FOR UPDATE"); @@ -721,15 +722,15 @@ 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. // A day the library is closed starts on the next open day instead. - $data_prestito = \App\Support\LibraryCalendar::forDb($db)->nextOpenDay(\App\Support\DateHelper::today()); + $data_prestito = \App\Support\LibraryCalendar::requestStartDay($db, \App\Support\DateHelper::today(), $libroId); $circulation = new \App\Services\CirculationPolicy($db); $loanPolicy = $circulation->forLoan($utenteId, $libroId); - $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($data_prestito, $loanPolicy->requestDurationDays(), $db); + $data_scadenza = \App\Support\LibraryCalendar::requestDueDate($db, $data_prestito, $loanPolicy->requestDurationDays(), $libroId); // 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($loanPolicy->maxRequestDays, $data, $data_prestito, $db); + $endOverride = $this->requestedEndDate($loanPolicy->maxRequestDays, $data, $data_prestito, $db, $libroId); if ($endOverride === false) { return $this->back($response, ['loan_error' => 'invalid_date']); } @@ -738,7 +739,7 @@ public function loan(Request $request, Response $response, mysqli $db): Response } if ($endOverride !== null) { // A chosen return date on a closed day moves to the next open day. - $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($endOverride, 0, $db); + $data_scadenza = \App\Support\LibraryCalendar::requestDueDate($db, $endOverride, 0, $libroId); } // Use transaction + lock to prevent race conditions @@ -991,11 +992,11 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo // Calculate date range for availability check. A start day the library // is closed moves to the next open day, as the request calendar // proposes it; identical without closures. - $start = \App\Support\LibraryCalendar::forDb($db)->nextOpenDay(($desired !== '') ? $desired : $today); + $start = \App\Support\LibraryCalendar::requestStartDay($db, ($desired !== '') ? $desired : $today, $libroId); $reservePolicy = (new \App\Services\CirculationPolicy($db))->forLoan($utenteId, $libroId); - $end = \App\Services\CirculationPolicy::dueDateFor($start, $reservePolicy->requestDurationDays(), $db); + $end = \App\Support\LibraryCalendar::requestDueDate($db, $start, $reservePolicy->requestDurationDays(), $libroId); // Optional return date (Mobile API 1.8.2), bounded as in loan(). - $endOverride = $this->requestedEndDate($reservePolicy->maxRequestDays, $data, $start, $db); + $endOverride = $this->requestedEndDate($reservePolicy->maxRequestDays, $data, $start, $db, $libroId); if ($endOverride === false) { return $this->back($response, ['reserve_error' => 'invalid_date']); } @@ -1004,7 +1005,7 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo } if ($endOverride !== null) { // A chosen end date on a closed day moves to the next open day. - $end = \App\Services\CirculationPolicy::dueDateFor($endOverride, 0, $db); + $end = \App\Support\LibraryCalendar::requestDueDate($db, $endOverride, 0, $libroId); } // Start transaction for concurrency control @@ -1148,7 +1149,7 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo * @param array $data * @return string|false|null */ - private function requestedEndDate(int $maxDays, array $data, string $startDate, ?\mysqli $db = null): string|false|null + private function requestedEndDate(int $maxDays, array $data, string $startDate, \mysqli $db, int $bookId): string|false|null { $raw = $data['end_date'] ?? null; if (!is_scalar($raw)) { @@ -1164,7 +1165,7 @@ private function requestedEndDate(int $maxDays, array $data, string $startDate, // The longest loan ends on the maximum day, moved forward like every // due date when it falls on a closed day (closed days are not loan // days); without closures this is start + maximum, as before. - return $end > \App\Services\CirculationPolicy::dueDateFor($startDate, $maxDays, $db) ? 'max_duration_exceeded' : $end; + return $end > \App\Support\LibraryCalendar::requestDueDate($db, $startDate, $maxDays, $bookId) ? 'max_duration_exceeded' : $end; } private function back(Response $response, array $params): Response diff --git a/app/Controllers/UserDashboardController.php b/app/Controllers/UserDashboardController.php index 522e49e37..0c55978e7 100644 --- a/app/Controllers/UserDashboardController.php +++ b/app/Controllers/UserDashboardController.php @@ -82,7 +82,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons // Get user active loans with book titles $stmt = $db->prepare(" - SELECT p.id, p.libro_id, p.data_scadenza, + SELECT p.id, p.libro_id, p.copia_id, p.data_scadenza, p.stato, l.titolo AS titolo_libro, l.copertina_url FROM prestiti p JOIN libri l ON p.libro_id = l.id @@ -94,7 +94,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons $stmt->execute(); $res = $stmt->get_result(); while ($row = $res->fetch_assoc()) { - $prestitiAttivi[] = $row; + $prestitiAttivi[] = $row + \App\Support\LibraryCalendar::loanDates($db, $row); } $stmt->close(); @@ -153,7 +153,7 @@ public function prenotazioni(Request $request, Response $response, mysqli $db, m // Active loans $stmt = $db->prepare(" - SELECT pr.id, pr.libro_id, pr.data_prestito, pr.data_scadenza, pr.stato, + SELECT pr.id, pr.libro_id, pr.copia_id, pr.data_prestito, pr.data_scadenza, pr.stato, l.titolo, l.copertina_url, EXISTS(SELECT 1 FROM recensioni r WHERE r.libro_id = pr.libro_id AND r.utente_id = ?) AS has_review FROM prestiti pr @@ -165,7 +165,7 @@ public function prenotazioni(Request $request, Response $response, mysqli $db, m $stmt->execute(); $result = $stmt->get_result(); while ($row = $result->fetch_assoc()) { - $activePrestiti[] = $row; + $activePrestiti[] = $row + \App\Support\LibraryCalendar::loanDates($db, $row); } $stmt->close(); diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index e4972081e..4d0538f78 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -258,9 +258,14 @@ public function store(Request $request, Response $response, mysqli $db): Respons $customRoleId, $digitalAccess ); - $stmt->execute(); - $userId = (int) $stmt->insert_id; - \App\Support\PatronTypeAssignment::save($db, $userId, $patronTypeId); + // The type may have been removed since form validation. Account + // creation and assignment must succeed together; otherwise a + // reported failure would still leave a newly created account. + \App\Support\DbAtomic::run($db, static function () use ($stmt, $db, $patronTypeId, &$userId): void { + $stmt->execute(); + $userId = (int) $stmt->insert_id; + \App\Support\PatronTypeAssignment::save($db, $userId, $patronTypeId); + }, 'Users.create'); } catch (\Throwable $e) { $code = ($e instanceof \mysqli_sql_exception) ? \App\Support\UniqueViolation::errorCode($e) : 'db_error'; if ($code === 'db_error') { @@ -579,10 +584,14 @@ public function update(Request $request, Response $response, mysqli $db, int $id $digitalAccess, $id ); - $stmt->execute(); - if ($patronTypeId !== $originalPatronType) { - \App\Support\PatronTypeAssignment::save($db, $id, $patronTypeId); - } + // A failed type assignment must also undo the account fields, + // password and privileges saved by this same form. + \App\Support\DbAtomic::run($db, static function () use ($stmt, $db, $id, $patronTypeId, $originalPatronType): void { + $stmt->execute(); + if ($patronTypeId !== $originalPatronType) { + \App\Support\PatronTypeAssignment::save($db, $id, $patronTypeId); + } + }, 'Users.update'); } catch (\Throwable $e) { // $stmt stays null if prepare() itself threw — guard the close(). if ($stmt instanceof \mysqli_stmt) { diff --git a/app/Models/CopyRepository.php b/app/Models/CopyRepository.php index e58cb2dee..9d1401e9c 100644 --- a/app/Models/CopyRepository.php +++ b/app/Models/CopyRepository.php @@ -73,6 +73,8 @@ public function getByBookId(int $bookId): array 'utente_nome' => null, 'utente_cognome' => null, 'utente_email' => null, + 'effective_due_at' => null, + 'is_overdue' => false, ]; $copie[] = $row; } @@ -84,7 +86,10 @@ public function getByBookId(int $bookId): array foreach ($copie as $index => $copy) { $copyIndexes[(int) $copy['id']] = $index; } - foreach ($this->getScheduleByBookId($bookId) as $commitment) { + $schedule = $this->getScheduleByBookId($bookId); + \App\Support\LibraryCalendar::primeCopyBranches($this->db, array_map(static fn (array $c): int => (int) $c['id'], $schedule)); + $today = \App\Support\DateHelper::today(); + foreach ($schedule as $commitment) { $copyId = (int) $commitment['id']; if (!isset($copyIndexes[$copyId])) { continue; @@ -96,6 +101,14 @@ public function getByBookId(int $bookId): array foreach (['prestito_id', 'utente_id', 'data_prestito', 'data_scadenza', 'prestito_stato', 'utente_nome', 'utente_cognome', 'utente_email'] as $field) { $copie[$index][$field] = $commitment[$field]; } + $dates = \App\Support\LibraryCalendar::loanDates($this->db, [ + 'data_scadenza' => $commitment['data_scadenza'], + 'stato' => $commitment['prestito_stato'], + 'attivo' => $commitment['attivo'], + 'copia_id' => $copyId, + ], $today); + $copie[$index]['effective_due_at'] = $dates['effective_due_at']; + $copie[$index]['is_overdue'] = $dates['is_overdue']; } return $copie; @@ -111,7 +124,7 @@ public function getScheduleByBookId(int $bookId): array $stmt = $this->db->prepare(" SELECT c.id, c.numero_inventario, p.id AS prestito_id, p.utente_id, p.data_prestito, - p.data_scadenza, p.stato AS prestito_stato, + p.data_scadenza, p.stato AS prestito_stato, p.attivo, u.nome AS utente_nome, u.cognome AS utente_cognome, u.email AS utente_email FROM copie c diff --git a/app/Models/DashboardStats.php b/app/Models/DashboardStats.php index 202977934..738da374b 100644 --- a/app/Models/DashboardStats.php +++ b/app/Models/DashboardStats.php @@ -85,12 +85,7 @@ public function activeLoans(): array JOIN utenti u ON p.utente_id = u.id WHERE p.stato IN ('in_corso','in_ritardo') AND p.attivo = 1"; $res = $this->db->query($sql); - if ($res) { - while ($row = $res->fetch_assoc()) { - $rows[] = $row; - } - } - return $rows; + return $res ? $this->withLoanDates($res->fetch_all(MYSQLI_ASSOC)) : $rows; } public function overdueLoans(): array @@ -100,14 +95,35 @@ public function overdueLoans(): array FROM prestiti p JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL JOIN utenti u ON p.utente_id = u.id - WHERE p.stato='in_ritardo' AND p.attivo = 1"; - $res = $this->db->query($sql); - if ($res) { - while ($row = $res->fetch_assoc()) { - $rows[] = $row; - } + WHERE p.stato IN ('in_corso','in_ritardo') AND p.attivo = 1 AND p.data_scadenza < ?"; + $stmt = $this->db->prepare($sql); + if ($stmt === false) { + return $rows; } - return $rows; + // The usable day is never before the stored one, so a loan due today or later + // cannot be late. Late by the calendar, whatever the stored flag says: a loan the + // sweep has not flagged yet is listed, one flagged before a closure moved its day is not. + $today = \App\Support\DateHelper::today(); + $stmt->bind_param('s', $today); + $stmt->execute(); + return array_values(array_filter( + $this->withLoanDates($stmt->get_result()->fetch_all(MYSQLI_ASSOC)), + static fn (array $row): bool => (bool) $row['is_overdue'] + )); + } + + /** + * Adds the usable return day and the calendar flags to loan rows, with one + * branch read for the whole list. The rows stay the ones the caller selected. + * + * @param list> $rows rows carrying copia_id, stato, attivo, data_scadenza + * @return list> + */ + private function withLoanDates(array $rows): array + { + \App\Support\LibraryCalendar::primeCopyBranches($this->db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $rows)); + $today = \App\Support\DateHelper::today(); + return array_map(fn (array $r): array => $r + \App\Support\LibraryCalendar::loanDates($this->db, $r, $today), $rows); } public function pendingLoans(int $limit = 4): array @@ -225,7 +241,7 @@ public function calendarEvents(): array // Fetch active/scheduled loans (in_corso, prenotato, in_ritardo, pendente) // Include pendente loans with attivo=0 (waiting for admin approval) - $loanSql = "SELECT p.id, p.stato, p.data_prestito, p.data_scadenza, + $loanSql = "SELECT p.id, p.copia_id, p.attivo, p.stato, p.data_prestito, p.data_scadenza, l.titolo, CONCAT(u.nome, ' ', u.cognome) AS utente_nome, 'prestito' AS tipo FROM prestiti p @@ -241,18 +257,16 @@ public function calendarEvents(): array $stmt = $this->db->prepare($loanSql); $stmt->bind_param('sss', $today, $today, $sixMonthsLater); $stmt->execute(); - $res = $stmt->get_result(); - while ($row = $res->fetch_assoc()) { - $isOpenEnded = $row['stato'] === 'in_ritardo' - || ($row['stato'] === 'in_corso' && (string) $row['data_scadenza'] < $today); + foreach ($this->withLoanDates($stmt->get_result()->fetch_all(MYSQLI_ASSOC)) as $row) { + // A loan out ends on its usable return day, or stays open through + // today once that day has gone by. The status stays the stored one. + $loanEnd = (string) ($row['effective_due_at'] ?? $row['data_scadenza']); $events[] = [ 'id' => 'loan_' . $row['id'], 'title' => $row['titolo'], 'user' => $row['utente_nome'], 'start' => $row['data_prestito'], - 'end' => $isOpenEnded - ? max((string) $row['data_scadenza'], $today) - : $row['data_scadenza'], + 'end' => $row['is_overdue'] ? max($loanEnd, $today) : $loanEnd, 'type' => 'prestito', 'status' => $row['stato'] ]; diff --git a/app/Models/LoanRepository.php b/app/Models/LoanRepository.php index 7316a572b..efbe7edb0 100644 --- a/app/Models/LoanRepository.php +++ b/app/Models/LoanRepository.php @@ -17,7 +17,7 @@ public function listRecent(int $limit = 100): array { $rows = []; $sql = "SELECT p.id, l.titolo AS libro_titolo, CONCAT(u.nome,' ',u.cognome) AS utente_nome, - u.email as utente_email, p.data_prestito, p.data_scadenza, p.data_restituzione, p.stato, p.attivo + u.email as utente_email, p.copia_id, p.data_prestito, p.data_scadenza, p.data_restituzione, p.stato, p.attivo FROM prestiti p LEFT JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL LEFT JOIN utenti u ON p.utente_id = u.id @@ -28,7 +28,7 @@ public function listRecent(int $limit = 100): array $stmt->execute(); $res = $stmt->get_result(); while ($row = $res->fetch_assoc()) { - $rows[] = $row; + $rows[] = $row + \App\Support\LibraryCalendar::loanDates($this->db, $row); } return $rows; } @@ -55,7 +55,8 @@ public function getById(int $id): ?array $stmt->bind_param('i', $id); $stmt->execute(); $res = $stmt->get_result(); - return $res->fetch_assoc() ?: null; + $row = $res->fetch_assoc(); + return $row ? $row + \App\Support\LibraryCalendar::loanDates($this->db, $row) : null; } /** @@ -130,7 +131,7 @@ public function getActiveLoanByBook(int $bookId): ?array $loan = $res->fetch_assoc(); $stmt->close(); - return $loan ?: null; + return $loan ? $loan + \App\Support\LibraryCalendar::loanDates($this->db, $loan) : null; } /** @@ -238,7 +239,7 @@ public function close(int $id, ?int $expectedBookId = null, ?int $expectedUserId // mezzanotte gmdate() registrerebbe il giorno sbagliato. $today = DateHelper::today(); $scadenza = (string) ($lockedRow['data_scadenza'] ?? ''); - $ritardo = ($scadenza !== '' && $scadenza < $today) ? 1 : 0; + $ritardo = \App\Support\LibraryCalendar::isPastDue($this->db, $scadenza, $closedCopiaId, $today) ? 1 : 0; $stmt = $this->db->prepare('UPDATE prestiti SET attivo=0, data_restituzione=?, stato="restituito", restituito_in_ritardo=? WHERE id=? AND attivo=1'); $stmt->bind_param('sii', $today, $ritardo, $id); if (!$stmt->execute()) { diff --git a/app/Models/SettingsRepository.php b/app/Models/SettingsRepository.php index bfc008cad..736a703d5 100644 --- a/app/Models/SettingsRepository.php +++ b/app/Models/SettingsRepository.php @@ -107,6 +107,15 @@ public function allowsMultipleLoansSameBook(): bool return ($this->get('loans', 'allow_multiple_loans_same_book', '0') ?? '0') === '1'; } + /** + * Whether a loan due on a closed day frees its book on the next open day + * itself, for a reader who starts that day, or only after it. + */ + public function allowsSameDayHandover(): bool + { + return ($this->get('loans', 'same_day_handover_after_closure', '0') ?? '0') === '1'; + } + /** * Configured default loan duration (global rule, no patron context). * diff --git a/app/Services/CapacityService.php b/app/Services/CapacityService.php index 7f0f0b284..73af55cc4 100644 --- a/app/Services/CapacityService.php +++ b/app/Services/CapacityService.php @@ -43,7 +43,12 @@ final class CapacityService /** Circulation rules, resolved once per instance (the settings rows are cached there). */ private ?CirculationPolicy $circulation = null; - public function __construct(private mysqli $db) {} + /** + * $lockingReads is for writers holding the book row in a transaction. + * Current reads must see commitments confirmed after an earlier calendar + * read established a REPEATABLE READ snapshot on the same connection. + */ + public function __construct(private mysqli $db, private bool $lockingReads = false) {} /** * Lendable physical copies of a book (the capacity ceiling). If the book has @@ -54,7 +59,8 @@ public function __construct(private mysqli $db) {} */ public function totalCopies(int $libroId): int { - $stmt = $this->db->prepare("SELECT COUNT(*) AS total FROM copie WHERE libro_id = ?"); + $lock = $this->lockingReads ? ' FOR UPDATE' : ''; + $stmt = $this->db->prepare("SELECT COUNT(*) AS total FROM copie WHERE libro_id = ?" . $lock); $stmt->bind_param('i', $libroId); $stmt->execute(); $copyRows = (int) ($stmt->get_result()->fetch_assoc()['total'] ?? 0); @@ -65,7 +71,7 @@ public function totalCopies(int $libroId): int "SELECT COUNT(*) AS total FROM copie WHERE libro_id = ? AND stato NOT IN ('perso','danneggiato','manutenzione','in_restauro','in_trasferimento') - AND " . ($this->circulation ??= new CirculationPolicy($this->db))->lendableCopySql('') . "" + AND " . ($this->circulation ??= new CirculationPolicy($this->db))->lendableCopySql('') . $lock ); $stmt->bind_param('i', $libroId); $stmt->execute(); @@ -75,7 +81,7 @@ public function totalCopies(int $libroId): int } // No per-copy rows: legacy fallback to libri.copie_totali (NULL → 1, 0 → 0). - $stmt = $this->db->prepare("SELECT IFNULL(copie_totali, 1) AS total FROM libri WHERE id = ? AND deleted_at IS NULL"); + $stmt = $this->db->prepare("SELECT IFNULL(copie_totali, 1) AS total FROM libri WHERE id = ? AND deleted_at IS NULL" . $lock); $stmt->bind_param('i', $libroId); $stmt->execute(); $row = $stmt->get_result()->fetch_assoc(); @@ -401,7 +407,9 @@ private function holdingLoanIntervals(int $libroId, string $start, string $end, LEAST(CASE WHEN {$openEnded} THEN ? ELSE p.data_scadenza - END, ?) AS e + END, ?) AS e, + IF(p.attivo = 1, p.data_scadenza, NULL) AS stored_due, + p.copia_id AS stored_copy FROM prestiti p WHERE p.libro_id = ? AND p.data_prestito <= ? @@ -409,7 +417,11 @@ private function holdingLoanIntervals(int $libroId, string $start, string $end, AND ( (p.attivo = 1 AND p.stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) OR (p.attivo = 0 AND p.stato = 'pendente' AND p.copia_id IS NOT NULL) )"; $types = 'ssssisss'; - $params = [$start, $today, $end, $end, $libroId, $end, $today, $start]; + // A loan whose stored day is closed ends on the next open day, which the + // reader is told, even when the move was refused and the row kept its + // date. With a calendar, a stored day before the window can still reach it. + $hasCalendar = \App\Support\LibraryCalendar::forDb($this->db)->hasRules(); + $params = [$start, $today, $end, $end, $libroId, $end, $today, $hasCalendar ? '1000-01-01' : $start]; if ($excludePrestitoId !== null) { $sql .= ' AND p.id <> ?'; $types .= 'i'; @@ -420,7 +432,7 @@ private function holdingLoanIntervals(int $libroId, string $start, string $end, $types .= 'i'; $params[] = $excludeUserId; } - return $this->fetchIntervals($sql, $types, $params); + return $this->fetchIntervals($sql, $types, $params, $end); } /** @@ -449,7 +461,7 @@ private function activeReservationIntervals(int $libroId, string $start, string JOIN utenti u ON u.id = r.utente_id WHERE r.libro_id = ? AND r.stato = 'attiva' - AND " . \App\Support\LoanEligibility::eligibleUserWhere('u', $this->db) . " + AND " . \App\Support\LoanEligibility::eligibleUserWhere('u', $this->db, $this->lockingReads) . " AND $rStart IS NOT NULL AND $rStart <= ? AND $rEnd >= ?"; @@ -458,6 +470,10 @@ private function activeReservationIntervals(int $libroId, string $start, string if ($circulation->anyTotalLoanLimit()) { // Per-patron limit (CirculationPolicy; 0 = no limit for that patron). $limitSql = $circulation->totalLoanLimitSql('u'); + // A locking outer read does not lock a nested subquery, which would + // still count from the transaction's snapshot: lock it too, in the + // syntax MySQL and MariaDB both accept inside a subquery. + $countLock = $this->lockingReads ? ' LOCK IN SHARE MODE' : ''; // Future reservations reserve capacity against overlapping commitments. // Once they become promotable, promotion and approval enforce the total // active-loan cap (including future scheduled loans). Use the SHARED @@ -477,7 +493,7 @@ private function activeReservationIntervals(int $libroId, string $start, string OR (cap.data_prestito <= $rEnd AND (cap.stato = 'in_ritardo' OR (cap.stato = 'in_corso' AND cap.data_scadenza < ?) - OR cap.data_scadenza >= $rStart))) + OR cap.data_scadenza >= $rStart))){$countLock} ) < {$limitSql})"; $types .= 'sss'; $params[] = $today; @@ -487,7 +503,7 @@ private function activeReservationIntervals(int $libroId, string $start, string // Same pause as queue promotion for the per-material rules: a // promotable reservation of a patron the matrix refuses for this // title does not hold capacity (ReservationManager skips it too). - $materialRefused = $circulation->materialRefusedReservationUserIds($libroId); + $materialRefused = $circulation->materialRefusedReservationUserIds($libroId, $this->lockingReads); if ($materialRefused !== []) { $sql .= ' AND NOT (r.utente_id IN (' . implode(',', array_map('intval', $materialRefused)) . ') AND ' . \App\Support\LoanEligibility::promotableReservationWhere('r') . ')'; @@ -523,8 +539,11 @@ private function activeReservationIntervals(int $libroId, string $start, string * @param list $params * @return list */ - private function fetchIntervals(string $sql, string $types, array $params): array + private function fetchIntervals(string $sql, string $types, array $params, ?string $windowEnd = null): array { + if ($this->lockingReads) { + $sql .= ' FOR UPDATE'; + } $stmt = $this->db->prepare($sql); $refs = [$types]; foreach ($params as $k => $v) { @@ -537,6 +556,11 @@ private function fetchIntervals(string $sql, string $types, array $params): arra while ($row = $res->fetch_assoc()) { $s = (string) $row['s']; $e = (string) $row['e']; + $storedDue = (string) ($row['stored_due'] ?? ''); + if ($windowEnd !== null && $storedDue !== '' && $e === $storedDue) { + $copyId = $row['stored_copy'] !== null ? (int) $row['stored_copy'] : null; + $e = min(\App\Support\LibraryCalendar::holdingEnd($this->db, $storedDue, $copyId), $windowEnd); + } if ($s !== '' && $e !== '' && $s <= $e) { $out[] = [$s, $e]; } diff --git a/app/Services/CirculationPolicy.php b/app/Services/CirculationPolicy.php index 622eea63b..cd8634165 100644 --- a/app/Services/CirculationPolicy.php +++ b/app/Services/CirculationPolicy.php @@ -281,15 +281,24 @@ public function countActiveLoans(int $userId, ?int $excludeLoanId = null): int } /** Open loans of $userId on titles of material $media (same states as countActiveLoans()). */ - public function countActiveLoansForMedia(int $userId, string $media, ?int $excludeLoanId = null): int + public function countActiveLoansForMedia(int $userId, string $media, ?int $excludeLoanId = null, bool $lockingReads = false): int { // CI-SOFT-DELETE-EXEMPT: a loan on a title archived after checkout is still out and still counts against the limit. - $sql = 'SELECT COUNT(*) FROM prestiti p JOIN libri l ON l.id = p.libro_id + // The material test stays in a nested subquery: an outer locking read + // does not lock its rows, so a calendar writer holding one book's lock + // takes no lock on the other books this patron has out. + $sql = 'SELECT COUNT(*) FROM prestiti p WHERE p.utente_id = ? AND p.attivo = 1 AND p.stato IN (' . self::ACTIVE_LOAN_STATES_SQL . ') - AND l.tipo_media = ?'; + AND EXISTS (SELECT 1 FROM libri l WHERE l.id = p.libro_id AND l.tipo_media = ?)'; if ($excludeLoanId !== null) { $sql .= ' AND p.id <> ?'; } + // Calendar writers may already have a REPEATABLE READ snapshot from + // their initial scan. A return committed since then makes a paused + // reservation eligible again and must be included in capacity now. + if ($lockingReads) { + $sql .= ' LOCK IN SHARE MODE'; + } $stmt = $this->db->prepare($sql); if ($excludeLoanId !== null) { $stmt->bind_param('isi', $userId, $media, $excludeLoanId); @@ -337,13 +346,13 @@ public function loanLimitError(LoanPolicy $policy, int $userId, ?int $excludeLoa * * @return list */ - public function materialRefusedReservationUserIds(int $libroId): array + public function materialRefusedReservationUserIds(int $libroId, bool $lockingReads = false): array { if ($this->rules() === []) { return []; } $media = $this->mediaOf($libroId); - $stmt = $this->db->prepare("SELECT DISTINCT utente_id FROM prenotazioni WHERE libro_id = ? AND stato = 'attiva'"); + $stmt = $this->db->prepare("SELECT DISTINCT utente_id FROM prenotazioni WHERE libro_id = ? AND stato = 'attiva'" . ($lockingReads ? ' FOR UPDATE' : '')); $stmt->bind_param('i', $libroId); $stmt->execute(); $result = $stmt->get_result(); @@ -358,7 +367,7 @@ public function materialRefusedReservationUserIds(int $libroId): array $policy = $this->forType($this->patronTypeOf($userId), $media); if (!$policy->loanable || ($policy->maxActiveForMedia > 0 && $media !== self::ALL_MEDIA - && $this->countActiveLoansForMedia($userId, $media) >= $policy->maxActiveForMedia)) { + && $this->countActiveLoansForMedia($userId, $media, lockingReads: $lockingReads) >= $policy->maxActiveForMedia)) { $refused[] = $userId; } } diff --git a/app/Services/PatronLedger.php b/app/Services/PatronLedger.php index 9e9a90ff2..016097925 100644 --- a/app/Services/PatronLedger.php +++ b/app/Services/PatronLedger.php @@ -158,10 +158,15 @@ public static function resetSchemaCache(): void self::$schemaReady = null; } - /** SQL expression for a patron's balance; $userIdExpr is a fixed column reference. */ - public static function balanceSql(string $userIdExpr): string + /** + * SQL expression for a patron's balance; $userIdExpr is a fixed column + * reference. Capacity writers use a current read so a payment committed + * after their initial snapshot can re-enable the reader's reservation. + */ + public static function balanceSql(string $userIdExpr, bool $lockingReads = false): string { - return "(SELECT COALESCE(SUM(ml.importo), 0) FROM movimenti_lettore ml WHERE ml.utente_id = {$userIdExpr})"; + $lock = $lockingReads ? ' LOCK IN SHARE MODE' : ''; + return "(SELECT COALESCE(SUM(ml.importo), 0) FROM movimenti_lettore ml WHERE ml.utente_id = {$userIdExpr}{$lock})"; } /** @@ -187,10 +192,11 @@ public static function legacyPenaltiesPending(mysqli $db): bool * charge, so adding it gives the balance the patron will have once the * conversion completes. $userIdExpr is a fixed column reference. */ - public static function pendingLegacyPenaltiesSql(string $userIdExpr): string + public static function pendingLegacyPenaltiesSql(string $userIdExpr, bool $lockingReads = false): string { + $lock = $lockingReads ? ' LOCK IN SHARE MODE' : ''; return "(SELECT COALESCE(SUM(lp.sanzione), 0) FROM prestiti lp WHERE lp.utente_id = {$userIdExpr} AND lp.sanzione > 0" - . " AND NOT EXISTS (SELECT 1 FROM movimenti_lettore lm WHERE lm.chiave_idempotenza = CONCAT('penalty:', lp.id)))"; + . " AND NOT EXISTS (SELECT 1 FROM movimenti_lettore lm WHERE lm.chiave_idempotenza = CONCAT('penalty:', lp.id){$lock}){$lock})"; } /** @@ -200,12 +206,12 @@ public static function pendingLegacyPenaltiesSql(string $userIdExpr): string * patron over the threshold until maintenance finishes it. Once the * marker is set this is balanceSql() and costs nothing more. */ - public static function debtBalanceSql(string $userIdExpr, mysqli $db): string + public static function debtBalanceSql(string $userIdExpr, mysqli $db, bool $lockingReads = false): string { if (!self::legacyPenaltiesPending($db)) { - return self::balanceSql($userIdExpr); + return self::balanceSql($userIdExpr, $lockingReads); } - return '(' . self::balanceSql($userIdExpr) . ' + ' . self::pendingLegacyPenaltiesSql($userIdExpr) . ')'; + return '(' . self::balanceSql($userIdExpr, $lockingReads) . ' + ' . self::pendingLegacyPenaltiesSql($userIdExpr, $lockingReads) . ')'; } /** PHP twin of debtBalanceSql() for one patron. */ diff --git a/app/Services/ReservationReassignmentService.php b/app/Services/ReservationReassignmentService.php index f95039119..6f0c1bde1 100644 --- a/app/Services/ReservationReassignmentService.php +++ b/app/Services/ReservationReassignmentService.php @@ -729,7 +729,7 @@ private function notifyUserCopyAvailable(int $prestitoId): void { // Recupera dati necessari per la notifica $stmt = $this->db->prepare(" - SELECT p.id, p.utente_id, p.libro_id, p.data_prestito, p.data_scadenza, + SELECT p.id, p.utente_id, p.libro_id, p.copia_id, p.data_prestito, p.data_scadenza, u.email, u.nome as utente_nome, l.titolo as libro_titolo, l.isbn13, l.isbn10 FROM prestiti p @@ -777,7 +777,7 @@ private function notifyUserCopyAvailable(int $prestitoId): void 'libro_autore' => $author['nome'] ?? $this->notificationService->translateInLocale('Autore sconosciuto', $recipientLocale), 'libro_isbn' => $isbn, 'data_inizio' => $data['data_prestito'] ? $this->notificationService->formatEmailDate((string) $data['data_prestito'], false, $recipientLocale) : '', - 'data_fine' => $data['data_scadenza'] ? $this->notificationService->formatEmailDate((string) $data['data_scadenza'], false, $recipientLocale) : '', + 'data_fine' => $data['data_scadenza'] ? $this->notificationService->formatEmailDate(\App\Support\LibraryCalendar::dueDate($this->db, (string) $data['data_scadenza'], $data['copia_id'] !== null ? (int) $data['copia_id'] : null), false, $recipientLocale) : '', 'book_url' => absoluteUrl($bookLink), 'profile_url' => absoluteUrl(RouteTranslator::route('profile')) ]; diff --git a/app/Support/IcsGenerator.php b/app/Support/IcsGenerator.php index db2423906..468b8b7a1 100644 --- a/app/Support/IcsGenerator.php +++ b/app/Support/IcsGenerator.php @@ -80,7 +80,7 @@ public function generateForLoan(int $loanId): ?string // loan on a record must keep being able to add it to their calendar, // even in the legacy case of a flagged title that still carries one. - $stmt = $this->db->prepare("SELECT p.id, p.stato, p.data_prestito, p.data_scadenza, p.updated_at, + $stmt = $this->db->prepare("SELECT p.id, p.stato, p.attivo, p.copia_id, p.data_prestito, p.data_scadenza, p.updated_at, l.titolo, CONCAT(u.nome, ' ', u.cognome) AS utente_nome FROM prestiti p JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL @@ -103,7 +103,11 @@ public function generateForLoan(int $loanId): ?string // Mirror fetchEvents(): an overdue copy is still physically occupied // through today, so ending the event on the missed due date would // falsely render it as already available. - $endDate = (string) $row['data_scadenza']; + $dates = LibraryCalendar::loanDates($this->db, $row); + $endDate = (string) ($dates['effective_due_at'] ?? $row['data_scadenza']); + if ((int) $row['attivo'] === 1 && in_array($row['stato'], ['in_corso', 'in_ritardo'], true)) { + $row['stato'] = $dates['is_overdue'] ? 'in_ritardo' : 'in_corso'; + } if ($row['stato'] === 'in_ritardo') { $endDate = max($endDate, DateHelper::today()); } @@ -196,7 +200,7 @@ private function fetchEvents(bool $catalogueOnly = false, bool $includePersonalD : ''; // Fetch active/scheduled loans - $loanSql = "SELECT p.id, p.stato, p.data_prestito, p.data_scadenza, p.pickup_deadline, + $loanSql = "SELECT p.id, p.stato, p.copia_id, p.data_prestito, p.data_scadenza, p.pickup_deadline, l.titolo, CONCAT(u.nome, ' ', u.cognome) AS utente_nome, u.email, p.updated_at FROM prestiti p @@ -210,7 +214,7 @@ private function fetchEvents(bool $catalogueOnly = false, bool $includePersonalD THEN p.pickup_deadline ELSE p.data_scadenza END) >= ? - OR p.stato = 'in_ritardo' + OR p.stato IN ('in_corso', 'in_ritardo', 'prenotato') ) ORDER BY p.data_prestito ASC"; $stmt = $this->db->prepare($loanSql); @@ -222,12 +226,22 @@ private function fetchEvents(bool $catalogueOnly = false, bool $includePersonalD return $events; } $res = $stmt->get_result(); - while ($row = $res->fetch_assoc()) { + $loans = $res->fetch_all(MYSQLI_ASSOC); + LibraryCalendar::primeCopyBranches($this->db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $loans)); + foreach ($loans as $row) { + $dates = LibraryCalendar::loanDates($this->db, $row, $today); + // A booked loan ends on its usable day, which a closure can put after the stored one. + if ($row['stato'] === 'prenotato' && (string) ($dates['effective_due_at'] ?? $row['data_scadenza']) < $today) { + continue; + } + if (in_array($row['stato'], ['in_corso', 'in_ritardo'], true)) { + $row['stato'] = $dates['is_overdue'] ? 'in_ritardo' : 'in_corso'; + } // For da_ritirare state, use pickup_deadline as end date (shows pickup window) // For other states, use data_scadenza (shows full loan period) $endDate = ($row['stato'] === 'da_ritirare' && !empty($row['pickup_deadline'])) ? $row['pickup_deadline'] - : $row['data_scadenza']; + : ($dates['effective_due_at'] ?? $row['data_scadenza']); if ($row['stato'] === 'in_ritardo') { // The copy remains physically occupied through today; ending the // event on the missed due date falsely shows it as available. diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php index c7211ffaf..45baba744 100644 --- a/app/Support/LibraryCalendar.php +++ b/app/Support/LibraryCalendar.php @@ -63,6 +63,8 @@ final class LibraryCalendar /** @var array per-connection cache, keyed by spl_object_id */ private static array $instances = []; + private const REVISION_STEP = 3660; + /** @var array branchOfCopy() memo: copy id => sede_id (null when none) */ private array $copyBranches = []; @@ -211,6 +213,75 @@ public static function isPastDue(\mysqli $db, string $dueDate, ?int $copiaId, st return $calendar->nextOpenDay($due, self::branchOfCopy($db, $copiaId)) < $today; } + /** + * Calendar facts shared by web lists and native clients. The persisted + * date remains available for history and editing; only an outstanding + * physical loan receives a later usable return day. + * @param array $loan + * @return array{effective_due_at: ?string, is_overdue: bool, due_attention: bool} + */ + public static function loanDates(\mysqli $db, array $loan, ?string $today = null): array + { + $today ??= DateHelper::today(); + $due = !empty($loan['data_scadenza']) ? substr((string) $loan['data_scadenza'], 0, 10) : null; + $holding = (int) ($loan['attivo'] ?? 1) === 1 && in_array($loan['stato'] ?? '', ['in_corso', 'in_ritardo'], true); + $copyId = isset($loan['copia_id']) ? (int) $loan['copia_id'] : null; + // A scheduled loan whose move was refused also ends on the next open day; + // it cannot be late or due yet, so only the date follows the calendar. + $scheduled = (int) ($loan['attivo'] ?? 1) === 1 && in_array($loan['stato'] ?? '', ['prenotato', 'da_ritirare'], true); + $effectiveDue = ($holding || $scheduled) && $due !== null ? self::dueDate($db, $due, $copyId) : $due; + return [ + 'effective_due_at' => $effectiveDue, + 'is_overdue' => $holding && $due !== null && self::isPastDue($db, $due, $copyId, $today), + 'due_attention' => $holding && $effectiveDue !== null && $effectiveDue <= $today, + ]; + } + + /** + * Ids of the loans out that are late on $today by isPastDue(), whatever + * their stored stato says. Reads prestiti only: a caller that lists or + * counts titles keeps its own join with libri. + * + * @return list + */ + public static function overdueLoanIds(\mysqli $db, string $today): array + { + $stmt = $db->prepare( + "SELECT id, data_scadenza, copia_id FROM prestiti + WHERE attivo = 1 AND stato IN ('in_corso', 'in_ritardo') AND data_scadenza < ?" + ); + if ($stmt === false) { + throw new \RuntimeException('LibraryCalendar: overdue loans query not prepared'); + } + $stmt->bind_param('s', $today); + $stmt->execute(); + $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + $stmt->close(); + self::primeCopyBranches($db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $rows)); + $ids = []; + foreach ($rows as $row) { + if (self::isPastDue($db, (string) $row['data_scadenza'], $row['copia_id'] !== null ? (int) $row['copia_id'] : null, $today)) { + $ids[] = (int) $row['id']; + } + } + return $ids; + } + + /** + * SQL condition, for a row of prestiti already restricted to loans out, that + * holds when the loan is late by isPastDue(). With no calendar rules that is + * the plain date comparison, served by the data_scadenza indexes; with rules + * it is the id list of overdueLoanIds(). Contains no bound parameter. + */ + public static function overdueLoanCondition(\mysqli $db, string $today, string $alias = 'p'): string + { + if (!self::forDb($db)->hasRules()) { + return "{$alias}.data_scadenza < '" . $db->real_escape_string($today) . "'"; + } + $ids = self::overdueLoanIds($db, $today); + return $ids === [] ? '1 = 0' : "{$alias}.id IN (" . implode(',', $ids) . ')'; + } + /** * After a calendar change: an active loan whose due date (today or later) * now falls on a closed day of the copy's branch is due on the next open @@ -260,7 +331,7 @@ public static function extendDueDates(\mysqli $db, ?int $operatorId = null): int $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); $stmt->close(); - $capacity = new \App\Services\CapacityService($db); + $capacity = new \App\Services\CapacityService($db, lockingReads: true); $moved = 0; $notMoved = 0; foreach ($rows as $row) { @@ -292,6 +363,34 @@ public static function extendDueDates(\mysqli $db, ?int $operatorId = null): int $lockBook->execute(); $lockBook->get_result(); $lockBook->close(); + // The copy of a scheduled loan can be replaced while this + // save waits for the book lock (e.g. the original was lost). + // Re-read the loan and its current branch with a current read; + // applying the scan's branch would move an open-day deadline. + $lockLoan = $db->prepare( + "SELECT p.data_scadenza, c.sede_id + FROM prestiti p LEFT JOIN copie c ON c.id = p.copia_id + WHERE p.id = ? AND p.libro_id = ? AND p.attivo = 1 + AND p.stato IN ('in_corso', 'da_ritirare', 'prenotato') + AND p.data_scadenza >= ? FOR UPDATE" + ); + $lockLoan->bind_param('iis', $id, $bookId, $today); + $lockLoan->execute(); + $current = $lockLoan->get_result()->fetch_assoc(); + $lockLoan->close(); + if ($current === null) { + if ($ownTransaction) { $db->commit(); } + continue; + } + $due = substr((string) $current['data_scadenza'], 0, 10); + $next = $calendar->nextOpenDay($due, $current['sede_id'] !== null ? (int) $current['sede_id'] : null); + if (!DateHelper::isISODateFormat($due) || $next <= $due) { + if ($ownTransaction) { $db->commit(); } + continue; + } + // The calendar scan may already have opened an older MVCC + // snapshot. Locking capacity reads see the operator who held + // this book lock before us, including copyless reservations. if ($capacity->hasFreeCapacity($bookId, self::shift($due, 1), $next, excludePrestitoId: $id)) { $before = ActivityLog::loadLoanSnapshot($db, $id); $update = $db->prepare( @@ -316,10 +415,13 @@ public static function extendDueDates(\mysqli $db, ?int $operatorId = null): int } catch (\Throwable) { } } - $lockConflict = $e instanceof \mysqli_sql_exception && in_array($e->getCode(), [1205, 1213], true); - // Deadlock / lock wait timeout inside a caller's transaction: - // MySQL rolled it back (or will), and it must not commit as if - // the move had worked. + $lockConflict = $e instanceof \mysqli_sql_exception && in_array($e->getCode(), [1020, 1205, 1213], true); + // Deadlock, lock wait timeout or, on MariaDB with + // innodb_snapshot_isolation, a row changed since the snapshot + // (1020) inside a caller's transaction: the server rolled it + // back (or will), and it must not commit as if the move had + // worked. Carrying on would give each remaining loan its own + // committed transaction, moved for a closure no longer saved. if ($lockConflict && !$ownTransaction) { throw $e; } @@ -384,7 +486,7 @@ public static function extendDueDatesAfterSave(\mysqli $db): bool private static function reconcileOverdue(\mysqli $db, string $today, ?int $operatorId): void { $stmt = $db->prepare( - "SELECT id, data_scadenza, copia_id FROM prestiti + "SELECT id, libro_id, data_scadenza, copia_id FROM prestiti WHERE attivo = 1 AND stato = 'in_ritardo' AND data_scadenza IS NOT NULL AND data_scadenza < ?" ); if ($stmt === false) { @@ -400,17 +502,131 @@ private static function reconcileOverdue(\mysqli $db, string $today, ?int $opera continue; } $id = (int) $row['id']; - $before = ActivityLog::loadLoanSnapshot($db, $id); - $update = $db->prepare("UPDATE prestiti SET stato = 'in_corso' WHERE id = ? AND attivo = 1 AND stato = 'in_ritardo' AND data_scadenza = ?"); $due = (string) $row['data_scadenza']; - $update->bind_param('is', $id, $due); - $update->execute(); - $changed = $update->affected_rows > 0; - $update->close(); - if ($changed) { - ActivityLog::recordLoanEvent($db, $id, 'loan.updated', $before, 'aggiornamento', 'calendar', $operatorId); + self::withBookLock($db, (int) $row['libro_id'], static function () use ($db, $id, $due, $operatorId): void { + $before = ActivityLog::loadLoanSnapshot($db, $id); + $update = $db->prepare("UPDATE prestiti SET stato = 'in_corso' WHERE id = ? AND attivo = 1 AND stato = 'in_ritardo' AND data_scadenza = ?"); + $update->bind_param('is', $id, $due); + $update->execute(); + $changed = $update->affected_rows > 0; + $update->close(); + if ($changed) { + ActivityLog::recordLoanEvent($db, $id, 'loan.updated', $before, 'aggiornamento', 'calendar', $operatorId); + } + }); + } + } + + /** + * Runs a change to a loan with its book row locked first, the order desk + * operations use. A caller's transaction keeps the lock until it commits; + * without one the change gets its own. + */ + private static function withBookLock(\mysqli $db, int $bookId, callable $change): void + { + $ownTransaction = !DbAtomic::inTransaction($db); + if ($ownTransaction) { + $db->begin_transaction(); + } + try { + // CI-SOFT-DELETE-EXEMPT: a lock, not a read for display; an active + // loan may belong to an archived book and its row is locked all the same. + // @include-soft-deleted (tests/code-quality.spec.js, check 5). + $lock = $db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); + $lock->bind_param('i', $bookId); + $lock->execute(); + $lock->get_result(); + $lock->close(); + $change(); + if ($ownTransaction) { + $db->commit(); + } + } catch (\Throwable $e) { + if ($ownTransaction) { + $db->rollback(); } + throw $e; + } + } + + /** + * Records that the calendar's rules changed. The personal feed adds this to + * SEQUENCE, so it keeps rising when a change brings an event's end back: each + * step is larger than the days a closure can have added to that end. + */ + public static function bumpRevision(\mysqli $db): void + { + $step = self::REVISION_STEP; + $db->query( + "INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('library_calendar', 'revision', '{$step}') + ON DUPLICATE KEY UPDATE setting_value = CAST(setting_value AS UNSIGNED) + {$step}" + ); + } + + /** Last bumpRevision() value, 0 when the calendar has never changed. */ + public static function revision(\mysqli $db): int + { + $result = $db->query("SELECT setting_value FROM system_settings WHERE category = 'library_calendar' AND setting_key = 'revision'"); + $row = $result instanceof \mysqli_result ? $result->fetch_row() : null; + return $row !== null ? (int) $row[0] : 0; + } + + /** + * Last day a loan with this stored due date keeps its book: the usable + * return day the reader is told, or the day before it when the library hands + * the book over on that day itself. Both capacity and the request calendar read it. + */ + public static function holdingEnd(\mysqli $db, string $storedDue, ?int $copiaId): string + { + $usable = self::dueDate($db, $storedDue, $copiaId); + if ($usable > $storedDue && (new \App\Models\SettingsRepository($db))->allowsSameDayHandover()) { + return (new \DateTimeImmutable($usable))->modify('-1 day')->format('Y-m-d'); } + return $usable; + } + + /** + * The next open day a request for this book can start on: its copies' + * branch when they all sit in one, otherwise the library as a whole, since + * no copy is bound yet. + */ + public static function requestStartDay(\mysqli $db, string $ymd, int $bookId): string + { + $calendar = self::forDb($db); + if (!$calendar->hasRules()) { + return $ymd; + } + return $calendar->nextOpenDay($ymd, self::requestBranch($db, $bookId)); + } + + /** + * Due date of a request for this book starting on $startYmd and lasting + * $days: moved past the closures of the same branch requestStartDay() and + * the request calendar use, so every return day the page offers is accepted. + */ + public static function requestDueDate(\mysqli $db, string $startYmd, int $days, int $bookId): string + { + $due = \App\Services\CirculationPolicy::dueDateFor($startYmd, $days); + $calendar = self::forDb($db); + if (!$calendar->hasRules()) { + return $due; + } + return $calendar->nextOpenDay($due, self::requestBranch($db, $bookId)); + } + + /** The branch of the book's lendable copies when they all sit in one, otherwise null. */ + public static function requestBranch(\mysqli $db, int $bookId): ?int + { + $lendable = (new \App\Services\CirculationPolicy($db))->lendableCopySql(''); + $stmt = $db->prepare( + "SELECT DISTINCT sede_id FROM copie + WHERE libro_id = ? AND stato NOT IN ('perso','danneggiato','manutenzione','in_restauro','in_trasferimento') AND {$lendable}" + ); + $stmt->bind_param('i', $bookId); + $stmt->execute(); + $branches = $stmt->get_result()->fetch_all(MYSQLI_NUM); + $stmt->close(); + return count($branches) === 1 && $branches[0][0] !== null ? (int) $branches[0][0] : null; } /** @@ -484,7 +700,7 @@ public static function extendPickupDeadlines(\mysqli $db): int } $today = DateHelper::today(); $result = $db->query( - "SELECT p.id, p.pickup_deadline, p.data_scadenza, c.sede_id + "SELECT p.id, p.libro_id, p.pickup_deadline, p.data_scadenza, c.sede_id FROM prestiti p LEFT JOIN copie c ON c.id = p.copia_id WHERE p.stato = 'da_ritirare' AND p.attivo = 1 AND p.pickup_deadline IS NOT NULL" @@ -496,10 +712,6 @@ public static function extendPickupDeadlines(\mysqli $db): int $result->free(); $moved = 0; - $stmt = $db->prepare( - "UPDATE prestiti SET pickup_deadline = ? - WHERE id = ? AND stato = 'da_ritirare' AND attivo = 1 AND pickup_deadline = ?" - ); foreach ($rows as $row) { $deadline = substr((string) $row['pickup_deadline'], 0, 10); $next = $calendar->nextOpenDay($deadline, $row['sede_id'] !== null ? (int) $row['sede_id'] : null); @@ -511,11 +723,17 @@ public static function extendPickupDeadlines(\mysqli $db): int continue; } $id = (int) $row['id']; - $stmt->bind_param('sis', $next, $id, $deadline); - $stmt->execute(); - $moved += max(0, $stmt->affected_rows); + self::withBookLock($db, (int) $row['libro_id'], static function () use ($db, $next, $id, $deadline, &$moved): void { + $stmt = $db->prepare( + "UPDATE prestiti SET pickup_deadline = ? + WHERE id = ? AND stato = 'da_ritirare' AND attivo = 1 AND pickup_deadline = ?" + ); + $stmt->bind_param('sis', $next, $id, $deadline); + $stmt->execute(); + $moved += max(0, $stmt->affected_rows); + $stmt->close(); + }); } - $stmt->close(); return $moved; } @@ -1137,6 +1355,45 @@ public static function branchOfCopy(\mysqli $db, ?int $copiaId): ?int return $calendar->copyBranches[$copiaId] = self::queryBranchOfCopy($db, $copiaId); } + /** + * Read the branches of many copies in one query (a page of a loan list), + * so the per-loan calendar reads that follow hit the cache. Nothing to + * read when the calendar has no rules: no date then depends on a branch. + * @param list $copiaIds + */ + public static function primeCopyBranches(\mysqli $db, array $copiaIds): void + { + $calendar = self::forDb($db); + if (!$calendar->hasRules()) { + return; + } + $ids = array_values(array_unique(array_filter( + array_map('intval', $copiaIds), + static fn (int $id): bool => $id > 0 && !array_key_exists($id, $calendar->copyBranches) + ))); + if ($ids === []) { + return; + } + try { + $result = $db->query('SELECT id, sede_id FROM copie WHERE id IN (' . implode(',', $ids) . ')'); + if (!$result instanceof \mysqli_result) { + throw new \RuntimeException('copy branches query failed'); + } + $found = []; + while (($row = $result->fetch_row()) !== null) { + $found[(int) $row[0]] = $row[1] === null ? null : (int) $row[1]; + } + $result->free(); + } catch (\Throwable $e) { + SecureLogger::warning('LibraryCalendar: copy branches not readable: ' . $e->getMessage()); + throw new \RuntimeException('LibraryCalendar: copy branches not readable', 0, $e); + } + // A missing copy has no branch, as branchOfCopy() answers for it. + foreach ($ids as $id) { + $calendar->copyBranches[$id] = $found[$id] ?? null; + } + } + private static function queryBranchOfCopy(\mysqli $db, int $copiaId): ?int { try { diff --git a/app/Support/LoanEligibility.php b/app/Support/LoanEligibility.php index c484d63be..b362628a0 100644 --- a/app/Support/LoanEligibility.php +++ b/app/Support/LoanEligibility.php @@ -163,7 +163,7 @@ public static function promotableReservationWhere(string $alias): string * for the 0.9.0 tables (code ships before its migration runs): without * the ledger table the fragment carries no debt predicate at all. */ - public static function eligibleUserWhere(string $alias, \mysqli $db): string + public static function eligibleUserWhere(string $alias, \mysqli $db, bool $lockingReads = false): string { return "( {$alias}.stato = 'attivo' @@ -172,7 +172,7 @@ public static function eligibleUserWhere(string $alias, \mysqli $db): string OR {$alias}.data_scadenza_tessera IS NULL OR {$alias}.data_scadenza_tessera >= ? ) - AND " . self::debtBlockSql($alias, $db) . " + AND " . self::debtBlockSql($alias, $db, $lockingReads) . " )"; } @@ -190,7 +190,7 @@ public static function eligibleUserWhere(string $alias, \mysqli $db): string * penalty backfill is pending the unconverted prestiti.sanzione amounts * count too (PatronLedger::debtBalanceSql()). */ - public static function debtBlockSql(string $alias, \mysqli $db): string + public static function debtBlockSql(string $alias, \mysqli $db, bool $lockingReads = false): string { if (!\App\Services\PatronLedger::activeOrFail($db) || !Features::enabled('debt_block')) { return '1 = 1'; @@ -198,7 +198,7 @@ public static function debtBlockSql(string $alias, \mysqli $db): string $threshold = self::debtThresholdSql($alias, $db); return "( COALESCE({$threshold}, 0) <= 0 - OR " . \App\Services\PatronLedger::debtBalanceSql($alias . '.id', $db) . " <= {$threshold} + OR " . \App\Services\PatronLedger::debtBalanceSql($alias . '.id', $db, $lockingReads) . " <= {$threshold} )"; } diff --git a/app/Support/LoanPdfGenerator.php b/app/Support/LoanPdfGenerator.php index 68fb1abc3..49689fcee 100644 --- a/app/Support/LoanPdfGenerator.php +++ b/app/Support/LoanPdfGenerator.php @@ -176,7 +176,7 @@ private function renderLoanDetails(TCPDF $pdf, array $loan): void $this->renderSection($pdf, __('Informazioni Prestito'), [ __('ID Prestito:') => $loan['id'], __('Data Prestito:') => format_date($loan['data_prestito'], false, '/'), - __('Data Scadenza:') => format_date($loan['data_scadenza'], false, '/'), + __('Data Scadenza:') => format_date($loan['effective_due_at'] ?? $loan['data_scadenza'], false, '/'), __('Stato:') => $this->translateStatus($loan['stato']), ]); diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php index 3a11725d5..d5ee2c87c 100644 --- a/app/Support/NotificationService.php +++ b/app/Support/NotificationService.php @@ -116,6 +116,20 @@ private function expectedPickupDeadline(string $start, string $end, ?int $copiaI return LibraryCalendar::pickupDeadline($this->db, $start, $this->pickupExpiryDays(), $end !== '' ? $end : null, $copiaId); } + /** + * The return day a notice quotes for a loan row: the first open day on or + * after its stored data_scadenza, in the calendar of the copy's branch. The + * stored column stays what claims, reverts and late-day counts compare; with + * no calendar rules this is the stored date. + * + * @param array $loan a row carrying data_scadenza and, when the loan has a copy, copia_id + */ + private function usableDueDate(array $loan): string + { + $copyId = !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null; + return LibraryCalendar::dueDate($this->db, substr((string) ($loan['data_scadenza'] ?? ''), 0, 10), $copyId); + } + /** * Format date for email templates. * @@ -559,7 +573,7 @@ public function notifyLoanRequest(int $loanId): bool { // Send email to admins — dates formatted per admin locale. $emailSent = $this->sendToAdmins('loan_request_notification', $variables, [ 'data_inizio' => [$loan['data_prestito'], false], - 'data_fine' => [$loan['data_scadenza'], false], + 'data_fine' => [$this->usableDueDate($loan), false], 'data_richiesta' => [$loan['created_at'], true], ]); @@ -570,7 +584,7 @@ public function notifyLoanRequest(int $loanId): bool { $loan['libro_titolo'], $loan['utente_nome'], format_date($loan['data_prestito'], false, '/'), - format_date($loan['data_scadenza'], false, '/') + format_date($this->usableDueDate($loan), false, '/') ); $notificationLink = '/admin/loans'; @@ -605,6 +619,7 @@ public function sendLoanExpirationWarnings(): int { // dipende dalla session timezone del client DB, che differiva tra cron // (UTC forzato) e web (nessuna impostazione). $today = DateHelper::today(); + $warningLimit = (new \DateTimeImmutable($today))->modify('+' . $daysBeforeWarning . ' days')->format('Y-m-d'); // Include every still-unnotified loan from today through the configured // warning horizon. An exact "today + X" match misses short loans created @@ -612,18 +627,18 @@ public function sendLoanExpirationWarnings(): int { // Overdue loans remain handled separately below by the overdue workflow. // CI-SOFT-DELETE-EXEMPT: the expiry warning must keep firing for active loans whose book was archived — the copy is still out and the borrower must be chased (same rationale as sendLoanRecalls). $stmt = $this->db->prepare(" - SELECT p.id, p.data_scadenza, l.titolo as libro_titolo, - CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email as utente_email, - DATEDIFF(p.data_scadenza, ?) as giorni_rimasti + SELECT p.id, p.utente_id, p.copia_id, c.sede_id, p.data_scadenza, l.titolo as libro_titolo, + CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email as utente_email FROM prestiti p JOIN libri l ON p.libro_id = l.id JOIN utenti u ON p.utente_id = u.id - WHERE p.stato = 'in_corso' + LEFT JOIN copie c ON c.id = p.copia_id + WHERE p.stato IN ('in_corso', 'in_ritardo') AND p.attivo = 1 - AND p.data_scadenza BETWEEN ? AND DATE_ADD(?, INTERVAL ? DAY) + AND p.data_scadenza <= DATE_ADD(?, INTERVAL ? DAY) AND (p.warning_sent IS NULL OR p.warning_sent = 0) "); - $stmt->bind_param('sssi', $today, $today, $today, $daysBeforeWarning); + $stmt->bind_param('si', $today, $daysBeforeWarning); $stmt->execute(); $result = $stmt->get_result(); @@ -640,8 +655,16 @@ public function sendLoanExpirationWarnings(): int { // session locale (this runs from the admin-login maintenance path // as well as cron). $todayLabels = []; + $candidateCalendar = LibraryCalendar::forDb($this->db); foreach ($loans as $loan) { + $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; + // The sweep may include many genuinely overdue rows: their + // branches come from the same query, without a query per copy. + $effectiveDue = $candidateCalendar->nextOpenDay((string) $loan['data_scadenza'], $loan['sede_id'] !== null ? (int) $loan['sede_id'] : null); + if ($effectiveDue < $today || $effectiveDue > $warningLimit) { + continue; + } $recipientLocale = $this->resolveRecipientLocale((string) $loan['utente_email']); $todayLabels[$recipientLocale] ??= $this->translateInLocale('oggi', $recipientLocale); // ATOMIC: Mark warning as sent BEFORE sending email @@ -649,11 +672,23 @@ public function sendLoanExpirationWarnings(): int { // Re-assert data_scadenza too: renew()/update() may have moved the // due date between the SELECT and this claim; without it we'd send a // warning quoting a now-stale date (#252). - $updateStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 1 WHERE id = ? AND attivo = 1 AND stato = 'in_corso' AND data_scadenza = ? AND (warning_sent IS NULL OR warning_sent = 0)"); - $updateStmt->bind_param('is', $loan['id'], $loan['data_scadenza']); - $updateStmt->execute(); - $claimed = $updateStmt->affected_rows === 1; - $updateStmt->close(); + $claimed = (bool) $this->underCalendarLock(function () use (&$loan, $copyId, $today, $warningLimit): bool { + // A save may change the usable day without changing the + // stored date when capacity blocks the move. Read it again + // under the calendar lock, before claiming or rendering. + $effectiveDue = LibraryCalendar::dueDate($this->db, (string) $loan['data_scadenza'], $copyId); + if ($effectiveDue < $today || $effectiveDue > $warningLimit) { + return false; + } + $loan['effective_due_at'] = $effectiveDue; + $loan['giorni_rimasti'] = (int) (new \DateTimeImmutable($today))->diff(new \DateTimeImmutable($effectiveDue))->days; + $updateStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 1 WHERE id = ? AND utente_id = ? AND copia_id <=> ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo') AND data_scadenza = ? AND (warning_sent IS NULL OR warning_sent = 0)"); + $updateStmt->bind_param('iiis', $loan['id'], $loan['utente_id'], $copyId, $loan['data_scadenza']); + $updateStmt->execute(); + $claimed = $updateStmt->affected_rows === 1; + $updateStmt->close(); + return $claimed; + }, false); if (!$claimed) { // Another process already claimed this loan, skip @@ -687,7 +722,7 @@ public function sendLoanExpirationWarnings(): int { $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale), + 'data_scadenza' => $this->formatEmailDate($loan['effective_due_at'], false, $recipientLocale), 'giorni_rimasti' => $daysRemaining === 0 ? $todayLabels[$recipientLocale] : (string)$daysRemaining ]; @@ -705,8 +740,8 @@ public function sendLoanExpirationWarnings(): int { $sentCount++; } else { // Email failed after retries, revert the flag so it can be retried next run - $revertStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 0 WHERE id = ? AND attivo = 1 AND stato = 'in_corso'"); - $revertStmt->bind_param('i', $loan['id']); + $revertStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 0 WHERE id = ? AND data_scadenza = ? AND utente_id = ? AND copia_id <=> ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')"); + $revertStmt->bind_param('isii', $loan['id'], $loan['data_scadenza'], $loan['utente_id'], $copyId); $revertStmt->execute(); $revertStmt->close(); SecureLogger::warning("Failed to send expiration warning for loan {$loan['id']} after retries, flag reverted"); @@ -777,6 +812,7 @@ public function sendOverdueLoanNotifications(): int { // The e-mail and the in-app notice say the count of the // calendar as it is now, under the lock. $loan['giorni_ritardo'] = LibraryCalendar::lateDaysForNotice($this->db, (string) $loan['data_scadenza'], $copyId, $today); + $loan['effective_due_at'] = $this->usableDueDate($loan); // ATOMIC: mark the notice sent BEFORE the e-mail; only an // affected row proceeds. The state guard (M3) keeps a loan // returned meanwhile out, and re-asserting data_scadenza @@ -814,7 +850,7 @@ public function sendOverdueLoanNotifications(): int { $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza'], false, $this->resolveRecipientLocale((string) $loan['utente_email'])), + 'data_scadenza' => $this->formatEmailDate($loan['effective_due_at'], false, $this->resolveRecipientLocale((string) $loan['utente_email'])), 'giorni_ritardo' => $loan['giorni_ritardo'] ]; @@ -1050,7 +1086,7 @@ public function sendPickupReminders(): int { 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], 'data_inizio' => $this->formatEmailDate((string) $loan['data_prestito'], false, $recipientLocale), - 'data_fine' => $this->formatEmailDate((string) $loan['data_scadenza'], false, $recipientLocale), + 'data_fine' => $this->formatEmailDate($this->usableDueDate($loan), false, $recipientLocale), 'giorni_ritiro' => $this->effectivePickupDays((string) $loan['data_prestito'], $deadline, !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), 'giorni_rimasti' => $daysLeft === 0 ? $todayLabels[$recipientLocale] : (string) $daysLeft, 'scadenza_ritiro' => $formattedDeadline, @@ -1387,9 +1423,10 @@ private function recalledTodayUnchanged(array $loan): bool /** * Shared claim-then-send for one recall (automatic and manual paths). * Expects a row carrying id, data_scadenza, recall_count, last_recall_at, - * libro_titolo, utente_nome, utente_email, giorni_ritardo. Returns true iff - * the recall email actually went out; on failure the claim is reverted so a - * later run can retry. + * libro_titolo, utente_nome, utente_email, giorni_ritardo, and copia_id when + * the loan has one (it selects the branch calendar of the date in the e-mail). + * Returns true iff the recall email actually went out; on failure the claim + * is reverted so a later run can retry. * * @param ?callable(): (int|false) $stillDue re-checked under the calendar * lock right before the claim: the days late by the calendar as it @@ -1412,6 +1449,7 @@ private function claimAndSendRecall(array $loan, int $maxRetries = 3, int $retry } $loan['giorni_ritardo'] = $days; } + $loan['effective_due_at'] = $this->usableDueDate($loan); $updateStmt = $this->db->prepare("UPDATE prestiti SET recall_count = recall_count + 1, last_recall_at = ? WHERE id = ? AND data_scadenza = ? AND recall_count = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')"); $updateStmt->bind_param('sisi', $now, $loan['id'], $loan['data_scadenza'], $expectedCount); $updateStmt->execute(); @@ -1439,7 +1477,7 @@ private function claimAndSendRecall(array $loan, int $maxRetries = 3, int $retry $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza'], false, $this->resolveRecipientLocale((string) $loan['utente_email'])), + 'data_scadenza' => $this->formatEmailDate($loan['effective_due_at'], false, $this->resolveRecipientLocale((string) $loan['utente_email'])), 'giorni_ritardo' => $loan['giorni_ritardo'], 'numero_sollecito' => $recallNumber, ]; @@ -1527,7 +1565,7 @@ public function sendLoanReceiptEmail(int $loanId): array { 'utente_nome' => (string) ($loan['utente'] ?? ''), 'libro_titolo' => (string) ($loan['libro'] ?? ''), 'data_prestito' => $this->formatEmailDate((string) ($loan['data_prestito'] ?? ''), false, $recipientLocale), - 'data_scadenza' => $this->formatEmailDate((string) ($loan['data_scadenza'] ?? ''), false, $recipientLocale), + 'data_scadenza' => $this->formatEmailDate((string) ($loan['effective_due_at'] ?? $loan['data_scadenza'] ?? ''), false, $recipientLocale), ]; $attachment = [ 'content' => $pdfContent, @@ -1561,7 +1599,7 @@ public function notifyAdminsOverdue(int $loanId): void // the admin alert for the SAME overdue loan must be too — an archived // title's outstanding loan is exactly the case staff must chase. $stmt = $this->db->prepare(" - SELECT p.id, p.data_scadenza, p.data_prestito, l.titolo AS libro_titolo, + SELECT p.id, p.copia_id, p.data_scadenza, p.data_prestito, l.titolo AS libro_titolo, CONCAT(u.nome, ' ', u.cognome) AS utente_nome, u.email AS utente_email FROM prestiti p JOIN libri l ON p.libro_id = l.id @@ -1590,7 +1628,7 @@ public function notifyAdminsOverdue(int $loanId): void // leggeva solo il PHP hardcoded (GAP-4). Le date sono formattate per la // lingua di ogni admin. $this->sendToAdmins('loan_overdue_admin', $variables, [ - 'data_scadenza' => [$loan['data_scadenza'], false], + 'data_scadenza' => [$this->usableDueDate($loan), false], 'data_prestito' => [$loan['data_prestito'], false], ]); @@ -2033,7 +2071,7 @@ public function sendLoanPickedUpNotification(int $loanId, ?string $pickupDate = 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'] ?? $this->translateInLocale('Non disponibile', $recipientLocale), 'data_prestito' => $this->formatEmailDate($pickupDate, false, $recipientLocale), - 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale), + 'data_scadenza' => $this->formatEmailDate($this->usableDueDate($loan), false, $recipientLocale), ]; return $this->sendWithRetry($loan['utente_email'], 'loan_picked_up', $variables); @@ -2081,7 +2119,7 @@ public function sendLoanApprovedNotification(int $loanId): bool { 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], 'data_inizio' => $this->formatEmailDate($loan['data_prestito'], false, $recipientLocale), - 'data_fine' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale), + 'data_fine' => $this->formatEmailDate($this->usableDueDate($loan), false, $recipientLocale), 'giorni_prestito' => $days, // A loan starting later gets its pickup deadline only on the start // day; say now which one it will be and how many days that is. @@ -2089,7 +2127,7 @@ public function sendLoanApprovedNotification(int $loanId): bool { 'scadenza_ritiro' => $this->formatEmailDate($this->expectedPickupDeadline((string) $loan['data_prestito'], (string) $loan['data_scadenza'], !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), false, $recipientLocale), 'pickup_deadline' => $this->formatEmailDate($this->expectedPickupDeadline((string) $loan['data_prestito'], (string) $loan['data_scadenza'], !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), false, $recipientLocale), 'pickup_instructions' => $this->translateInLocale('Recati in biblioteca durante gli orari di apertura per ritirare il libro.', $recipientLocale), - 'sezione_calendario' => $this->buildCalendarSection((int)$loan['id'], (string)$loan['libro_titolo'], (string)$loan['data_prestito'], (string)$loan['data_scadenza'], $recipientLocale) + 'sezione_calendario' => $this->buildCalendarSection((int)$loan['id'], (string)$loan['libro_titolo'], (string)$loan['data_prestito'], $this->usableDueDate($loan), $recipientLocale) ]; return $this->sendWithRetry($loan['utente_email'], 'loan_approved', $variables); @@ -2266,7 +2304,7 @@ public function sendPickupReadyNotification(int $loanId): bool { 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], 'data_inizio' => $this->formatEmailDate($loan['data_prestito'], false, $recipientLocale), - 'data_fine' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale), + 'data_fine' => $this->formatEmailDate($this->usableDueDate($loan), false, $recipientLocale), 'giorni_prestito' => $days, 'giorni_ritiro' => $this->effectivePickupDays((string) $loan['data_prestito'], $loan['pickup_deadline'] ? (string) $loan['pickup_deadline'] : null, !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), 'scadenza_ritiro' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline'], false, $recipientLocale) : '', @@ -2275,7 +2313,7 @@ public function sendPickupReadyNotification(int $loanId): bool { // resolves as well as the canonical {{scadenza_ritiro}}. 'pickup_deadline' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline'], false, $recipientLocale) : '', 'pickup_instructions' => $this->translateInLocale('Recati in biblioteca durante gli orari di apertura per ritirare il libro.', $recipientLocale), - 'sezione_calendario' => $this->buildCalendarSection((int)$loan['id'], (string)$loan['libro_titolo'], (string)$loan['data_prestito'], (string)$loan['data_scadenza'], $recipientLocale) + 'sezione_calendario' => $this->buildCalendarSection((int)$loan['id'], (string)$loan['libro_titolo'], (string)$loan['data_prestito'], $this->usableDueDate($loan), $recipientLocale) ]; $emailSent = $this->sendWithRetry($loan['utente_email'], 'loan_pickup_ready', $variables); @@ -2635,7 +2673,7 @@ public function sendFineChargedNotification(int $loanId): bool return false; } $stmt = $this->db->prepare(" - SELECT p.utente_id, p.data_scadenza, p.data_restituzione, + SELECT p.utente_id, p.copia_id, p.data_scadenza, p.data_restituzione, l.titolo AS libro_titolo, CONCAT(u.nome, ' ', u.cognome) AS utente_nome, u.email AS utente_email @@ -2654,12 +2692,11 @@ public function sendFineChargedNotification(int $loanId): bool } $email = (string) $loan['utente_email']; $locale = $this->resolveRecipientLocale($email); - $due = (string) $loan['data_scadenza']; $returned = (string) ($loan['data_restituzione'] ?: DateHelper::today()); $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'data_scadenza' => $this->formatEmailDate($due, false, $locale), + 'data_scadenza' => $this->formatEmailDate($this->usableDueDate($loan), false, $locale), 'data_restituzione' => $this->formatEmailDate($returned, false, $locale), 'giorni_ritardo' => (string) $ledger->lateFineDays($loanId), 'multa' => \App\Services\FineParameters::format($fineCents, $locale), diff --git a/app/Views/admin/pending_loans.php b/app/Views/admin/pending_loans.php index c2c07354a..6b5eafb57 100644 --- a/app/Views/admin/pending_loans.php +++ b/app/Views/admin/pending_loans.php @@ -113,7 +113,7 @@ class="w-16 h-22 object-cover rounded-lg shadow-sm"

- +

@@ -358,7 +358,8 @@ class="w-16 h-22 object-cover rounded-lg shadow-sm"
diff(new \DateTimeImmutable($activeDue))->format('%r%a'); $isExpiringSoon = $daysLeft <= 3 && $daysLeft >= 0; ?>
@@ -386,7 +387,7 @@ class="w-16 h-22 object-cover rounded-lg shadow-sm"

- +

diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php index 7f5a246ec..e86fbe1a6 100644 --- a/app/Views/dashboard/index.php +++ b/app/Views/dashboard/index.php @@ -512,7 +512,7 @@ class="w-20 h-28 object-cover rounded-lg shadow-sm" - + @@ -659,21 +659,18 @@ class="w-20 h-28 object-cover rounded-lg shadow-sm" - + diff --git a/app/Views/layout.php b/app/Views/layout.php index 339962606..5bea4706f 100644 --- a/app/Views/layout.php +++ b/app/Views/layout.php @@ -1,5 +1,5 @@ - + eof()) { + echo $content->read(8192); + } + } else { + echo $content; + } + ?> diff --git a/app/Views/libri/scheda_libro.php b/app/Views/libri/scheda_libro.php index 661712d53..0a5444f1b 100644 --- a/app/Views/libri/scheda_libro.php +++ b/app/Views/libri/scheda_libro.php @@ -376,8 +376,11 @@ class="text-gray-900 hover:text-gray-600 hover:underline font-semibold">
- - + +
@@ -1095,7 +1098,7 @@ class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 text-white text-sm $effectiveStatus = 'prenotato'; $effectiveLabel = __('Prenotato'); $effectiveClass = 'bg-purple-100 text-purple-800'; - } elseif ($loanStatus === 'in_ritardo') { + } elseif (!empty($copia['is_overdue'])) { $effectiveStatus = 'in_ritardo'; $effectiveLabel = __('In ritardo'); $effectiveClass = 'bg-red-100 text-red-800'; @@ -1167,12 +1170,10 @@ class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 text-white text-sm - + @@ -1381,11 +1382,11 @@ class="btn-icon btn-danger-outline text-red-600 hover:text-red-900 transition-co - + @@ -1894,9 +1895,12 @@ function confirmRenewal(e){
- +
- +
diff --git a/app/Views/prestiti/dettagli_prestito.php b/app/Views/prestiti/dettagli_prestito.php index 380bca250..e07696c2f 100644 --- a/app/Views/prestiti/dettagli_prestito.php +++ b/app/Views/prestiti/dettagli_prestito.php @@ -82,7 +82,7 @@
- +
@@ -194,8 +194,7 @@ class="px-4 py-2 bg-red-600 text-white hover:bg-red-500 rounded-lg transition-co $isRecallable = $hasUserEmail && (int) ($prestito['attivo'] ?? 0) === 1 && in_array((string) ($prestito['stato'] ?? ''), ['in_corso', 'in_ritardo'], true) - && (string) ($prestito['data_scadenza'] ?? '') !== '' - && (string) $prestito['data_scadenza'] < \App\Support\DateHelper::today(); + && !empty($prestito['is_overdue']); ?> `; diff --git a/app/Views/profile/reservations.php b/app/Views/profile/reservations.php index bf8e3b4dc..c3665935f 100644 --- a/app/Views/profile/reservations.php +++ b/app/Views/profile/reservations.php @@ -334,7 +334,7 @@ // Check for overdue loans $overdueCount = 0; foreach ($activePrestiti as $p) { - if (!empty($p['data_scadenza']) && strtotime($p['data_scadenza']) < time()) { + if (!empty($p['is_overdue'])) { $overdueCount++; } } @@ -426,7 +426,8 @@ $dataPrestito = $p['data_prestito'] ?? ''; $stato = $p['stato'] ?? 'in_corso'; $isScheduled = ($stato === 'prenotato'); - $isOverdue = !$isScheduled && $scadenza && strtotime($scadenza) < time(); + $isOverdue = !empty($p['is_overdue']); + $scadenza = $p['effective_due_at'] ?? $scadenza; $hasReview = !empty($p['has_review']); ?>
diff --git a/app/Views/settings/loans-tab.php b/app/Views/settings/loans-tab.php index c16662847..cc61b4fe5 100644 --- a/app/Views/settings/loans-tab.php +++ b/app/Views/settings/loans-tab.php @@ -171,6 +171,24 @@ class="toggle-checkbox sr-only"> +
+
+ +

+
+ +
diff --git a/app/Views/user_dashboard/index.php b/app/Views/user_dashboard/index.php index 97121880d..6d02fcefd 100644 --- a/app/Views/user_dashboard/index.php +++ b/app/Views/user_dashboard/index.php @@ -443,7 +443,7 @@ 'libro_titolo' => $prestito['titolo_libro'] ?? '', 'autore' => '' ]); - $scadenza = strtotime($prestito['data_scadenza'] ?? ''); + $scadenza = strtotime($prestito['effective_due_at'] ?? $prestito['data_scadenza'] ?? ''); // Mezzanotte di "oggi" nel timezone applicativo: con time() (TZ // processo) il conteggio dei giorni cambiava a mezzanotte UTC, // in disaccordo col cron (updateOverdueLoans usa DateHelper). @@ -454,8 +454,8 @@ $in_scadenza = false; } else { $giorni_rimanenti = (int)ceil(($scadenza - $oggi) / 86400); - $scaduto = $giorni_rimanenti < 0; - $in_scadenza = $giorni_rimanenti >= 0 && $giorni_rimanenti <= 3; + $scaduto = !empty($prestito['is_overdue']); + $in_scadenza = !$scaduto && $giorni_rimanenti >= 0 && $giorni_rimanenti <= 3; } $coverUrl = $prestito['copertina_url'] ?? ''; ?> diff --git a/app/Views/user_dashboard/prenotazioni.php b/app/Views/user_dashboard/prenotazioni.php index affe72805..b224a4eef 100644 --- a/app/Views/user_dashboard/prenotazioni.php +++ b/app/Views/user_dashboard/prenotazioni.php @@ -522,15 +522,10 @@ function resolveCoverUrl(array $item, string $key = 'copertina_url'): string { diff --git a/composer.lock b/composer.lock index 694c6bf01..8525ad00a 100644 --- a/composer.lock +++ b/composer.lock @@ -2843,16 +2843,16 @@ }, { "name": "symfony/html-sanitizer", - "version": "v6.4.41", + "version": "v6.4.50", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "fba29d97d888ca69b496a626c9248fd56a293d8a" + "reference": "fe5105b37dd51916915525cfaf0d4b220a552907" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/fba29d97d888ca69b496a626c9248fd56a293d8a", - "reference": "fba29d97d888ca69b496a626c9248fd56a293d8a", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/fe5105b37dd51916915525cfaf0d4b220a552907", + "reference": "fe5105b37dd51916915525cfaf0d4b220a552907", "shasum": "" }, "require": { @@ -2892,7 +2892,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v6.4.41" + "source": "https://github.com/symfony/html-sanitizer/tree/v6.4.50" }, "funding": [ { @@ -2912,7 +2912,7 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:08:37+00:00" + "time": "2026-11-04T07:45:19+00:00" }, { "name": "symfony/polyfill-ctype", diff --git a/docs/settings.MD b/docs/settings.MD index 518977d02..b5bf13a2d 100644 --- a/docs/settings.MD +++ b/docs/settings.MD @@ -235,6 +235,7 @@ Imposti le regole globali dei prestiti (categoria setting `loans`): | `max_renewals` | Numero massimo di rinnovi (0 = illimitato) | 3 | 0 … 100 | | `max_active_loans_per_user` | Massimo prestiti attivi per utente (0 = illimitato) | 0 | 0 … 1000 | | `allow_multiple_loans_same_book` | Consente allo stesso utente più prestiti attivi dello stesso libro su copie fisiche distinte | 0 (disattivato) | Toggle 0 / 1 | +| `same_day_handover_after_closure` | Quando un prestito scade in un giorno di chiusura, un altro lettore può ricevere il libro già il primo giorno di apertura | 0 (disattivato) | Toggle 0 / 1 | > **Importante**: il server **ricalcola e blocca (clamp)** ogni valore entro i limiti indicati a prescindere dagli attributi `min`/`max` del form (che sono solo indicativi). Un POST manipolato non può quindi impostare durate o rinnovi assurdi. @@ -491,6 +492,7 @@ Categorie effettivamente usate dal `SettingsController` (chiave `category`): ('loans', 'max_renewals', '3'), ('loans', 'max_active_loans_per_user', '0'), -- 0 = illimitato ('loans', 'allow_multiple_loans_same_book', '0'), -- 0 = comportamento standard, 1 = copie distinte +('loans', 'same_day_handover_after_closure', '0'), -- 0 = occupato per il primo giorno di apertura, 1 = consegna quel giorno ('advanced', 'days_before_expiry_warning', '3'), ('advanced', 'session_lifetime', '180'), -- minuti ('advanced', 'force_https', '0'), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 125951b50..37c10a5c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3900,9 +3900,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { diff --git a/locale/da_DK.json b/locale/da_DK.json index 937fdc94f..de0970667 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -7075,6 +7075,8 @@ "Più copie dello stesso titolo": "Flere eksemplarer af samme titel", "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.": "Tillad den samme bruger at have flere aktive lån af samme bog, så længe hvert lån er knyttet til et særskilt fysisk eksemplar.", "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte": "Tillad kun flere lån af samme titel på forskellige fysiske eksemplarer", + "Consegna nello stesso giorno dopo una chiusura": "Udlevering samme dag efter en lukning", + "Quando un prestito scade in un giorno di chiusura, il libro torna il primo giorno di apertura. Se attivo, un altro lettore può riceverlo quello stesso giorno; se disattivo, il libro resta occupato per tutto quel giorno.": "Når et lån forfalder på en lukkedag, kommer bogen tilbage på den første åbningsdag. Når slået til, kan en anden læser få den samme dag; når slået fra, er bogen optaget hele den dag.", "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.": "Hvert lån skal være knyttet til et særskilt fysisk eksemplar; afventende anmodninger og reservationer er fortsat begrænset til én pr. bruger og bog.", "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.": "Afventende anmodninger og reservationer er fortsat unikke pr. bruger og bog. Hvert eksemplar tæller med i grænsen for aktive lån.", "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.": "Tilstanden for flere lån er aktiv: Bogen forbliver valgt; scan det næste eksemplar for at fortsætte.", diff --git a/locale/de_DE.json b/locale/de_DE.json index ef2fa0bc4..8b797c531 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -7075,6 +7075,8 @@ "Più copie dello stesso titolo": "Mehrere Exemplare desselben Titels", "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.": "Erlaube demselben Benutzer mehrere aktive Ausleihen desselben Buches, sofern jede Ausleihe einem anderen physischen Exemplar zugeordnet ist.", "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte": "Mehrere Ausleihen desselben Titels nur für unterschiedliche physische Exemplare zulassen", + "Consegna nello stesso giorno dopo una chiusura": "Übergabe am selben Tag nach einer Schließung", + "Quando un prestito scade in un giorno di chiusura, il libro torna il primo giorno di apertura. Se attivo, un altro lettore può riceverlo quello stesso giorno; se disattivo, il libro resta occupato per tutto quel giorno.": "Wenn eine Ausleihe an einem Schließtag fällig ist, kommt das Buch am ersten Öffnungstag zurück. Wenn aktiv, kann eine andere Person es noch am selben Tag erhalten; wenn inaktiv, bleibt das Buch den ganzen Tag belegt.", "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.": "Jede Ausleihe muss einem anderen physischen Exemplar zugeordnet sein; ausstehende Anfragen und Vormerkungen bleiben auf jeweils eine pro Benutzer und Buch beschränkt.", "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.": "Ausstehende Anfragen und Vormerkungen bleiben pro Benutzer und Buch eindeutig. Jedes Exemplar zählt zum Limit für aktive Ausleihen.", "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.": "Mehrfachausleihmodus aktiv: Das Buch bleibt ausgewählt; scannen Sie das nächste Exemplar, um fortzufahren.", diff --git a/locale/en_US.json b/locale/en_US.json index 7beb03f14..bb556e069 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -7075,6 +7075,8 @@ "Più copie dello stesso titolo": "Multiple copies of the same title", "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.": "Allow the same user to have multiple active loans for the same book, provided each loan is associated with a distinct physical copy.", "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte": "Allow multiple loans of the same title only for distinct physical copies", + "Consegna nello stesso giorno dopo una chiusura": "Same-day handover after a closure", + "Quando un prestito scade in un giorno di chiusura, il libro torna il primo giorno di apertura. Se attivo, un altro lettore può riceverlo quello stesso giorno; se disattivo, il libro resta occupato per tutto quel giorno.": "When a loan is due on a closed day, the book comes back on the first open day. When on, another reader can receive it that same day; when off, the book stays taken for that whole day.", "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.": "Each loan must be associated with a distinct physical copy; pending requests and reservations remain limited to one per user and book.", "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.": "Pending requests and reservations remain unique per user and book. Each copy counts toward the active loan limit.", "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.": "Multiple-loan mode is active: the book remains selected; scan the next copy to continue.", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index d287a2e05..48d5d4e8f 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -7075,6 +7075,8 @@ "Più copie dello stesso titolo": "Plusieurs exemplaires du même titre", "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.": "Autorisez un même utilisateur à avoir plusieurs emprunts actifs du même livre, à condition que chaque emprunt soit associé à un exemplaire physique distinct.", "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte": "Autoriser plusieurs emprunts du même titre uniquement sur des exemplaires physiques distincts", + "Consegna nello stesso giorno dopo una chiusura": "Remise le jour même après une fermeture", + "Quando un prestito scade in un giorno di chiusura, il libro torna il primo giorno di apertura. Se attivo, un altro lettore può riceverlo quello stesso giorno; se disattivo, il libro resta occupato per tutto quel giorno.": "Lorsqu'un prêt arrive à échéance un jour de fermeture, le livre revient le premier jour d'ouverture. Si activé, un autre lecteur peut le recevoir le jour même ; si désactivé, le livre reste indisponible toute cette journée.", "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.": "Chaque emprunt doit être associé à un exemplaire physique distinct ; les demandes en attente et les réservations restent limitées à une par utilisateur et par livre.", "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.": "Les demandes en attente et les réservations restent uniques par utilisateur et par livre. Chaque exemplaire compte dans la limite des emprunts actifs.", "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.": "Mode de prêts multiples actif : le livre reste sélectionné ; scannez l'exemplaire suivant pour continuer.", diff --git a/locale/it_IT.json b/locale/it_IT.json index 0d5bd25be..2adcca19a 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -7075,6 +7075,8 @@ "Più copie dello stesso titolo": "Più copie dello stesso titolo", "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.": "Consenti allo stesso utente di avere più prestiti attivi dello stesso libro, purché ogni prestito sia associato a una copia fisica distinta.", "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte": "Consenti più prestiti dello stesso titolo solo su copie fisiche distinte", + "Consegna nello stesso giorno dopo una chiusura": "Consegna nello stesso giorno dopo una chiusura", + "Quando un prestito scade in un giorno di chiusura, il libro torna il primo giorno di apertura. Se attivo, un altro lettore può riceverlo quello stesso giorno; se disattivo, il libro resta occupato per tutto quel giorno.": "Quando un prestito scade in un giorno di chiusura, il libro torna il primo giorno di apertura. Se attivo, un altro lettore può riceverlo quello stesso giorno; se disattivo, il libro resta occupato per tutto quel giorno.", "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.": "Ogni prestito deve essere associato a una copia fisica distinta; richieste in attesa e prenotazioni restano singole per utente e libro.", "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.": "Richieste in attesa e prenotazioni restano uniche per utente e libro. Ogni copia conta nel limite dei prestiti attivi.", "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.": "Modalità prestiti multipli attiva: il libro resta selezionato; scansiona la copia successiva per continuare.", diff --git a/scripts/ci-check-php-errors.sh b/scripts/ci-check-php-errors.sh new file mode 100644 index 000000000..19a2d057d --- /dev/null +++ b/scripts/ci-check-php-errors.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Browser assertions must not hide a PHP process that died on another request. +set -euo pipefail + +if (( $# == 0 )); then + echo 'Usage: ci-check-php-errors.sh [...]' >&2 + exit 2 +fi + +failed=0 +for log_file in "$@"; do + if [[ ! -r "$log_file" ]]; then + echo "Cannot inspect PHP error log: ${log_file}" >&2 + failed=1 + continue + fi + if LC_ALL=C grep -nE 'PHP (Fatal|Parse) error:|Allowed memory size of [0-9]+ bytes exhausted|Segmentation fault' "$log_file"; then + echo "PHP fatal error detected in ${log_file}" >&2 + failed=1 + fi +done + +if (( failed != 0 )); then + exit 1 +fi +echo 'No PHP fatal errors in the inspected server logs.' diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index d36706427..6e523af33 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -96,7 +96,7 @@ public function myLoans(Request $request, ResponseInterface $response): Response // Active loans (scheduled / to-pickup / in-progress / overdue). // CI-SOFT-DELETE-EXEMPT: an ACTIVE loan on an archived title must stay visible — the user still holds the copy and receives overdue recalls for it (M3). LEFT JOIN + COALESCE keep the row (with its real title, still present on soft-deleted rows) even if the book row ever vanished. $sql = "SELECT pr.id, pr.libro_id, pr.data_prestito, pr.data_scadenza, pr.data_restituzione, - pr.stato, pr.renewals, pr.created_at, + pr.stato, pr.copia_id, pr.renewals, pr.created_at, COALESCE(l.titolo, '') AS titolo, l.copertina_url FROM prestiti pr LEFT JOIN libri l ON l.id = pr.libro_id @@ -979,32 +979,41 @@ public function notifications(Request $request, ResponseInterface $response): Re $dueSoonLimit = date('Y-m-d', strtotime($today . " +{$dueSoonDays} days")); // Loans due soon (active, in-progress, not yet overdue). - $sql = "SELECT pr.id, pr.libro_id, pr.data_scadenza, l.titolo + $sql = "SELECT pr.id, pr.libro_id, pr.copia_id, pr.data_scadenza, l.titolo FROM prestiti pr JOIN libri l ON l.id = pr.libro_id AND l.deleted_at IS NULL - WHERE pr.utente_id = ? AND pr.attivo = 1 AND pr.stato = 'in_corso' - AND pr.data_scadenza >= ? AND pr.data_scadenza <= ? + WHERE pr.utente_id = ? AND pr.attivo = 1 AND pr.stato IN ('in_corso', 'in_ritardo') + AND pr.data_scadenza <= ? ORDER BY pr.data_scadenza ASC"; $stmt = $this->db->prepare($sql); if ($stmt !== false) { - $stmt->bind_param('iss', $userId, $today, $dueSoonLimit); + $stmt->bind_param('is', $userId, $dueSoonLimit); $stmt->execute(); $res = $stmt->get_result(); while ($res !== false && ($r = $res->fetch_assoc()) !== null) { + $copyId = isset($r['copia_id']) ? (int) $r['copia_id'] : null; + if (\App\Support\LibraryCalendar::isPastDue($this->db, (string) $r['data_scadenza'], $copyId, $today)) { + continue; + } + $effectiveDue = \App\Support\LibraryCalendar::dueDate($this->db, (string) $r['data_scadenza'], $copyId); + if ($effectiveDue > $dueSoonLimit) { + continue; + } $items[] = $this->notif( 'loan_due', 'loan:' . (int) $r['id'], __('Prestito in scadenza'), - sprintf(__('Il prestito di "%s" scade il %s.'), (string) $r['titolo'], (string) $r['data_scadenza']), + sprintf(__('Il prestito di "%s" scade il %s.'), (string) $r['titolo'], $effectiveDue), (int) $r['libro_id'], - (string) $r['data_scadenza'] + $effectiveDue ); } $stmt->close(); } - // Overdue loans. - $sql = "SELECT pr.id, pr.libro_id, pr.data_scadenza, l.titolo + // The date filter is only a candidate set: branch closures may + // have made a past stored due date usable on the next open day. + $sql = "SELECT pr.id, pr.libro_id, pr.copia_id, pr.data_scadenza, l.titolo FROM prestiti pr JOIN libri l ON l.id = pr.libro_id AND l.deleted_at IS NULL WHERE pr.utente_id = ? AND pr.attivo = 1 @@ -1016,13 +1025,18 @@ public function notifications(Request $request, ResponseInterface $response): Re $stmt->execute(); $res = $stmt->get_result(); while ($res !== false && ($r = $res->fetch_assoc()) !== null) { + $copyId = isset($r['copia_id']) ? (int) $r['copia_id'] : null; + if (!\App\Support\LibraryCalendar::isPastDue($this->db, (string) $r['data_scadenza'], $copyId, $today)) { + continue; + } + $effectiveDue = \App\Support\LibraryCalendar::dueDate($this->db, (string) $r['data_scadenza'], $copyId); $items[] = $this->notif( 'loan_overdue', 'overdue:' . (int) $r['id'], __('Prestito scaduto'), - sprintf(__('Il prestito di "%s" è scaduto il %s.'), (string) $r['titolo'], (string) $r['data_scadenza']), + sprintf(__('Il prestito di "%s" è scaduto il %s.'), (string) $r['titolo'], $effectiveDue), (int) $r['libro_id'], - (string) $r['data_scadenza'] + $effectiveDue ); } $stmt->close(); @@ -1177,6 +1191,7 @@ private function mapLoan(array $r): array { $status = (string) ($r['stato'] ?? ''); $dueAt = $this->nullableString($r['data_scadenza'] ?? null); + $dates = \App\Support\LibraryCalendar::loanDates($this->db, $r); return [ 'id' => (int) $r['id'], @@ -1196,12 +1211,15 @@ private function mapLoan(array $r): array 'requested_at' => !empty($r['created_at']) ? substr((string) $r['created_at'], 0, 10) : null, 'loaned_at' => $this->nullableString($r['data_prestito'] ?? null), 'due_at' => $dueAt, + // Keep the stored date for history/editing. Clients display the + // usable return day and do not infer lateness from device time. + 'effective_due_at' => $dates['effective_due_at'], + 'is_overdue' => $dates['is_overdue'], // Server-authoritative visibility cue: the Android device may be in a // different timezone from the library, so it must not derive "today" - // from LocalDate.now(). The badge still uses the raw status above. - 'due_attention' => $dueAt !== null - && in_array($status, ['in_corso', 'in_ritardo'], true) - && $dueAt <= DateHelper::today(), + // from LocalDate.now(). Clients can also reconcile the status badge + // with is_overdue when the persisted status has not caught up yet. + 'due_attention' => $dates['due_attention'], 'returned_at' => $this->nullableString($r['data_restituzione'] ?? null), 'renewals' => isset($r['renewals']) ? (int) $r['renewals'] : null, // Additive since 1.4.4: drives the native cancel affordance. The diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index 8c73cb3ff..9b88a0278 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -1445,9 +1445,11 @@ private function loanItemSchema(): array 'requested_at' => ['type' => ['string', 'null'], 'format' => 'date', 'description' => 'Date the loan request was created (since 1.4.3). The honest date for cancelled/expired loans, which never went out.'], 'loaned_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true], 'due_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true], + 'effective_due_at' => ['type' => ['string', 'null'], 'format' => 'date', 'description' => 'Usable return day after the copy branch closures. Display this date; due_at retains the stored deadline.'], + 'is_overdue' => ['type' => 'boolean', 'description' => 'Server-authoritative lateness in the library timezone and branch calendar, even if raw status awaits reconciliation.'], 'due_attention' => [ 'type' => 'boolean', - 'description' => 'True when an active in-progress/overdue loan is due today or earlier in the application timezone.', + 'description' => 'True when the effective return day of an active in-progress/overdue loan is today or earlier in the application timezone.', ], 'returned_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true], 'renewals' => ['type' => 'integer', 'nullable' => true], diff --git a/storage/plugins/mobile-api/src/Push/PushDispatcher.php b/storage/plugins/mobile-api/src/Push/PushDispatcher.php index 617bf7e17..fc3f85770 100644 --- a/storage/plugins/mobile-api/src/Push/PushDispatcher.php +++ b/storage/plugins/mobile-api/src/Push/PushDispatcher.php @@ -100,40 +100,52 @@ private function sweepLoanDue(array &$counters, string $today): int $days = $this->dueSoonDays(); $events = 0; - $sql = "SELECT pr.id, pr.utente_id, pr.libro_id, pr.data_scadenza, l.titolo + // The stored date only picks the candidates, as the notification feed: + // a past due day closed afterwards is due on the next open day. + $sql = "SELECT pr.id, pr.utente_id, pr.libro_id, pr.copia_id, pr.data_scadenza, l.titolo FROM prestiti pr JOIN libri l ON l.id = pr.libro_id AND l.deleted_at IS NULL - WHERE pr.attivo = 1 AND pr.stato = 'in_corso' - AND pr.data_scadenza >= ? + WHERE pr.attivo = 1 AND pr.stato IN ('in_corso', 'in_ritardo') AND pr.data_scadenza <= DATE_ADD(?, INTERVAL ? DAY)"; $stmt = $this->db->prepare($sql); if ($stmt === false) { return 0; } - $stmt->bind_param('ssi', $today, $today, $days); + $limit = (new \DateTimeImmutable($today))->modify('+' . $days . ' day')->format('Y-m-d'); + $stmt->bind_param('si', $today, $days); $stmt->execute(); $res = $stmt->get_result(); $rows = ($res instanceof \mysqli_result) ? $res->fetch_all(MYSQLI_ASSOC) : []; $stmt->close(); foreach ($rows as $r) { + $copyId = isset($r['copia_id']) ? (int) $r['copia_id'] : null; + if (LibraryCalendar::isPastDue($this->db, (string) $r['data_scadenza'], $copyId, $today)) { + continue; + } + $effectiveDue = LibraryCalendar::dueDate($this->db, (string) $r['data_scadenza'], $copyId); + if ($effectiveDue < $today || $effectiveDue > $limit) { + continue; + } $userId = (int) $r['utente_id']; - $key = 'loan_due:' . (int) $r['id'] . ':' . (string) $r['data_scadenza']; + // Keyed by the usable day: a closure that moves it without moving the + // stored date announces the new day instead of being deduplicated. + $key = 'loan_due:' . (int) $r['id'] . ':' . $effectiveDue; // Gate BEFORE claim so a quiet-hours/pref-off pass doesn't burn the dedup. if (!$this->shouldNotify($userId, 'loan_due')) { $counters['skipped']++; continue; } - if (!$this->claimIfDueUnchanged($userId, $key, (int) $r['id'], (string) $r['data_scadenza'])) { + if (!$this->claimIfDueUnchanged($userId, $key, (int) $r['id'], (string) $r['data_scadenza'], $copyId, $effectiveDue)) { continue; } $events++; $payload = new PushPayload( 'loan_due', __('Prestito in scadenza'), - sprintf(__('Il prestito di "%s" scade il %s.'), (string) $r['titolo'], (string) $r['data_scadenza']), + sprintf(__('Il prestito di "%s" scade il %s.'), (string) $r['titolo'], $effectiveDue), (int) $r['libro_id'], - ['loan_id' => (int) $r['id'], 'due_at' => (string) $r['data_scadenza']] + ['loan_id' => (int) $r['id'], 'due_at' => (string) $r['data_scadenza'], 'effective_due_at' => $effectiveDue] ); $outcome = $this->deliver($userId, $payload, $counters); if ($outcome['sent'] === 0 && $outcome['failed'] > 0) { @@ -185,13 +197,14 @@ private function sweepLoanOverdue(array &$counters, string $today): int if (!$this->claimIfPastDue($userId, $key, (int) $r['id'], (string) $r['data_scadenza'], $copyId, $today)) { continue; } + $effectiveDue = LibraryCalendar::dueDate($this->db, (string) $r['data_scadenza'], $copyId); $events++; $payload = new PushPayload( 'loan_overdue', __('Prestito scaduto'), - sprintf(__('Il prestito di "%s" è scaduto il %s.'), (string) $r['titolo'], (string) $r['data_scadenza']), + sprintf(__('Il prestito di "%s" è scaduto il %s.'), (string) $r['titolo'], $effectiveDue), (int) $r['libro_id'], - ['loan_id' => (int) $r['id'], 'due_at' => (string) $r['data_scadenza']] + ['loan_id' => (int) $r['id'], 'due_at' => (string) $r['data_scadenza'], 'effective_due_at' => $effectiveDue] ); $outcome = $this->deliver($userId, $payload, $counters); if ($outcome['sent'] === 0 && $outcome['failed'] > 0) { @@ -591,12 +604,12 @@ private function claimIfPastDue(int $userId, string $eventKey, int $loanId, stri * calendar write holds): the push never quotes a date that was moved. The * moved date gets its own event key on the next sweep. */ - private function claimIfDueUnchanged(int $userId, string $eventKey, int $loanId, string $dueDate): bool + private function claimIfDueUnchanged(int $userId, string $eventKey, int $loanId, string $dueDate, ?int $copyId, string $effectiveDue): bool { - return (bool) $this->underCalendarLock(function () use ($userId, $eventKey, $loanId, $dueDate): bool { + return (bool) $this->underCalendarLock(function () use ($userId, $eventKey, $loanId, $dueDate, $copyId, $effectiveDue): bool { // Same reader too: a loan reassigned since the sweep read it is // not announced to the previous borrower. - $stmt = $this->db->prepare("SELECT 1 FROM prestiti WHERE id = ? AND utente_id = ? AND attivo = 1 AND stato = 'in_corso' AND data_scadenza = ?"); + $stmt = $this->db->prepare("SELECT 1 FROM prestiti WHERE id = ? AND utente_id = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo') AND data_scadenza = ?"); if ($stmt === false) { return false; } @@ -605,7 +618,12 @@ private function claimIfDueUnchanged(int $userId, string $eventKey, int $loanId, $res = $stmt->get_result(); $same = $res instanceof \mysqli_result && $res->num_rows === 1; $stmt->close(); - return $same && $this->claim($userId, $eventKey); + // The calendar is read afresh under the lock: a calendar save since + // the sweep read it may have moved the usable day; the next sweep + // announces the new one. + return $same + && LibraryCalendar::dueDate($this->db, $dueDate, $copyId) === $effectiveDue + && $this->claim($userId, $eventKey); }, false); } diff --git a/storage/plugins/mobile-api/src/Staff090/Controllers/LibrarySettingsController.php b/storage/plugins/mobile-api/src/Staff090/Controllers/LibrarySettingsController.php index 5fa1a97fc..0fd7d47d3 100644 --- a/storage/plugins/mobile-api/src/Staff090/Controllers/LibrarySettingsController.php +++ b/storage/plugins/mobile-api/src/Staff090/Controllers/LibrarySettingsController.php @@ -39,6 +39,7 @@ final class LibrarySettingsController 'recall_max_count' => [1, 50], 'auto_approve_requests' => null, 'allow_multiple_loans_same_book' => null, + 'same_day_handover_after_closure' => null, 'recall_auto_enabled' => null, 'app_timezone' => null, ]; diff --git a/storage/plugins/mobile-api/src/Staff090/Support/LibrarySettingsOpenApi.php b/storage/plugins/mobile-api/src/Staff090/Support/LibrarySettingsOpenApi.php index e25c462e2..cecb1349b 100644 --- a/storage/plugins/mobile-api/src/Staff090/Support/LibrarySettingsOpenApi.php +++ b/storage/plugins/mobile-api/src/Staff090/Support/LibrarySettingsOpenApi.php @@ -122,6 +122,7 @@ public static function settings(array $doc): array 'card_expiry_warning_days' => self::int('0 = off.'), 'max_renewals' => self::int('0 = no renewals.'), 'max_active_loans_per_user' => self::int('0 = unlimited.'), 'max_loan_duration_days' => self::int(), 'auto_approve_requests' => self::bool(), 'allow_multiple_loans_same_book' => self::bool(), + 'same_day_handover_after_closure' => self::bool('A reader can start on the first open day after a closed due day.'), 'recall_auto_enabled' => self::bool(), 'recall_interval_days' => self::int(), 'recall_max_count' => self::int(), 'app_timezone' => self::str('IANA identifier.'), 'daily_fine_cents' => self::cents('Daily fine (admin only).'), diff --git a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php index aa46ecee3..a1ca9e987 100644 --- a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php +++ b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php @@ -191,12 +191,15 @@ public static function approvalError(ResponseInterface $response, int $coreStatu * @param array $row prestiti row (stato, data_prestito, data_scadenza, renewals, attivo) * @return list */ - public static function actionsFor(array $row, int $maxRenewals, string $today): array + public static function actionsFor(array $row, int $maxRenewals, string $today, ?bool $isOverdue = null): array { $status = (string) ($row['stato'] ?? ''); $start = (string) ($row['data_prestito'] ?? ''); $due = (string) ($row['data_scadenza'] ?? ''); $renewals = (int) ($row['renewals'] ?? 0); + if ($isOverdue !== null && in_array($status, ['in_corso', 'in_ritardo'], true)) { + $status = $isOverdue ? 'in_ritardo' : 'in_corso'; + } switch ($status) { case 'pendente': @@ -210,7 +213,7 @@ public static function actionsFor(array $row, int $maxRenewals, string $today): return ['edit', 'receipt']; case 'in_corso': $actions = ['return']; - if ($renewals < $maxRenewals && ($due === '' || $due >= $today)) { + if ($renewals < $maxRenewals && ($isOverdue === false || $due === '' || $due >= $today)) { $actions[] = 'renew'; } return array_merge($actions, ['edit', 'receipt', 'email_receipt']); diff --git a/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php b/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php index e24f0a802..60026cbc8 100644 --- a/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php +++ b/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php @@ -85,13 +85,13 @@ private static function paths(): array return [ '/staff/circulation/summary' => ['get' => O::op($t, 'staffCirculationSummary', 'Desk counters and loan settings (Cache-Control: no-store)', [ - '200' => O::ok('Counters.', O::ref('CirculationSummary')), + '200' => O::ok('Counters; active and overdue split the loans out by is_overdue, not by the raw status.', O::ref('CirculationSummary')), ])], '/staff/loans' => [ 'get' => O::op($t, 'staffLoans', 'Loans by desk bucket', [ '200' => O::ok('Page of loans; meta = {count, limit, next_cursor, has_more, total_count}.', O::listOf('StaffLoan')), ] + O::invalid(), ['parameters' => array_merge([ - O::query('bucket', 'string', 'pending|pickup|scheduled|active|overdue|history|all (default all).'), + O::query('bucket', 'string', 'pending|pickup|scheduled|active|overdue|history|all (default all). active and overdue split the loans out by is_overdue, not by the raw status.'), O::query('q', 'string', 'Title, user name / email / card, or copy code.'), O::query('user_id', 'integer', 'Only this user.'), O::query('book_id', 'integer', 'Only this title.'), @@ -230,6 +230,8 @@ private static function schemas(): array 'user' => $person, 'processed_by' => ['oneOf' => [['type' => 'null'], O::object(['id' => $i, 'name' => $s])]], 'requested_at' => $nd, 'loaned_at' => $nd, 'due_at' => $nd, 'pickup_deadline' => $nd, 'returned_at' => $nd, + 'effective_due_at' => $nd, + 'is_overdue' => ['type' => 'boolean', 'description' => 'Lateness in the library timezone and copy branch calendar; authoritative over a stale raw status.'], 'returned_late' => $b, 'days_overdue' => $i, 'renewals' => $i, 'max_renewals' => $i, 'penalty' => O::nullable('number'), 'notes' => $ns, 'recall_count' => $i, 'last_recall_at' => $ns, 'created_at' => $ns, 'updated_at' => $ns, diff --git a/storage/plugins/mobile-api/src/Support/StaffLoanReader.php b/storage/plugins/mobile-api/src/Support/StaffLoanReader.php index 6f4cf0e1e..8d9a5506f 100644 --- a/storage/plugins/mobile-api/src/Support/StaffLoanReader.php +++ b/storage/plugins/mobile-api/src/Support/StaffLoanReader.php @@ -13,7 +13,9 @@ * * The list queries mirror the five desk lists of * LoanApprovalController::pendingLoans() (pending, ready for pickup, - * scheduled, active, overdue) plus the history; the joined `libri` row always + * scheduled, active, overdue) plus the history, except that active and overdue + * split the loans out by LibraryCalendar::isPastDue(), the verdict each item + * reports as is_overdue, not by the stored stato; the joined `libri` row always * carries `deleted_at IS NULL`. Writes never happen here: every mutation is * delegated to the core controllers, and this class only reloads the row * afterwards so the API answers with the resource as it now is. @@ -22,6 +24,7 @@ final class StaffLoanReader { public const BUCKETS = ['pending', 'pickup', 'scheduled', 'active', 'overdue', 'history', 'all']; public const RESERVATION_STATES = ['attiva', 'completata', 'annullata', 'all']; + private const LOAN_OUT_SQL = "p.attivo = 1 AND p.stato IN ('in_corso', 'in_ritardo')"; private ?\App\Services\CirculationPolicy $circulation = null; @@ -88,11 +91,11 @@ public function loans(string $bucket, array $filters, int $offset, int $limit): $order = 'p.data_prestito ASC, p.id ASC'; break; case 'active': - $where[] = "p.stato = 'in_corso' AND p.attivo = 1"; + $where[] = self::LOAN_OUT_SQL . ' AND NOT (' . \App\Support\LibraryCalendar::overdueLoanCondition($this->db, $today) . ')'; $order = 'p.data_scadenza ASC, p.id ASC'; break; case 'overdue': - $where[] = "p.stato = 'in_ritardo' AND p.attivo = 1"; + $where[] = self::LOAN_OUT_SQL . ' AND (' . \App\Support\LibraryCalendar::overdueLoanCondition($this->db, $today) . ')'; $order = 'p.data_scadenza ASC, p.id ASC'; break; case 'history': @@ -215,19 +218,20 @@ public function summary(): array $settings = new \App\Models\SettingsRepository($this->db); $defaults = $this->circulation()->defaults(); + $late = \App\Support\LibraryCalendar::overdueLoanCondition($this->db, $today); return [ 'pending' => $count("p.stato = 'pendente'"), 'pickup_ready' => $count("p.attivo = 1 AND (p.stato = 'da_ritirare' OR (p.stato = 'prenotato' AND p.data_prestito <= ?))", 's', [$today]), 'scheduled' => $count("p.stato = 'prenotato' AND p.data_prestito > ? AND p.attivo = 1", 's', [$today]), - 'active' => $count("p.stato = 'in_corso' AND p.attivo = 1"), - 'overdue' => $count("p.stato = 'in_ritardo' AND p.attivo = 1"), + 'active' => $count(self::LOAN_OUT_SQL . " AND NOT ({$late})"), + 'overdue' => $count(self::LOAN_OUT_SQL . " AND ({$late})"), 'reservations' => (int) ($this->scalar( "SELECT COUNT(*) FROM prenotazioni r JOIN libri l ON l.id = r.libro_id AND l.deleted_at IS NULL WHERE r.stato = 'attiva'", '', [] ) ?? 0), - 'due_today' => $count("p.attivo = 1 AND p.stato IN ('in_corso','in_ritardo') AND p.data_scadenza = ?", 's', [$today]), + 'due_today' => $this->dueTodayCount($today), 'returned_today' => $count('p.data_restituzione = ?', 's', [$today]), 'today' => $today, 'settings' => [ @@ -240,6 +244,39 @@ public function summary(): array ]; } + /** Count usable return days, including nominal dates blocked from moving. */ + private function dueTodayCount(string $today): int + { + $calendar = \App\Support\LibraryCalendar::forDb($this->db); + if (!$calendar->hasRules()) { + return (int) ($this->scalar( + "SELECT COUNT(*) FROM prestiti p + JOIN libri l ON l.id = p.libro_id AND l.deleted_at IS NULL + WHERE p.attivo = 1 AND p.stato IN ('in_corso','in_ritardo') AND p.data_scadenza = ?", + 's', + [$today] + ) ?? 0); + } + $rows = $this->all( + "SELECT p.data_scadenza, c.sede_id + FROM prestiti p + JOIN libri l ON l.id = p.libro_id AND l.deleted_at IS NULL + LEFT JOIN copie c ON c.id = p.copia_id + WHERE p.attivo = 1 AND p.stato IN ('in_corso','in_ritardo') + AND p.data_scadenza <= ?", + 's', + [$today] + ); + $due = 0; + foreach ($rows as $row) { + $branch = $row['sede_id'] !== null ? (int) $row['sede_id'] : null; + if ($calendar->nextOpenDay((string) $row['data_scadenza'], $branch) === $today) { + $due++; + } + } + return $due; + } + /** The global renewal limit (no patron context). */ public function maxRenewals(): int { @@ -491,10 +528,11 @@ public function mapLoan(array $r): array $today = DateHelper::today(); $status = (string) ($r['stato'] ?? ''); $due = self::date($r['data_scadenza'] ?? null); - $daysOverdue = 0; - if ($due !== null && in_array($status, ['in_corso', 'in_ritardo'], true) && (int) ($r['attivo'] ?? 0) === 1 && $due < $today) { - $daysOverdue = (int) ((new \DateTimeImmutable($due))->diff(new \DateTimeImmutable($today))->days ?: 0); - } + $holding = in_array($status, ['in_corso', 'in_ritardo'], true) && (int) ($r['attivo'] ?? 0) === 1; + $copyId = isset($r['copia_id']) ? (int) $r['copia_id'] : null; + $dates = \App\Support\LibraryCalendar::loanDates($this->db, $r, $today); + $isOverdue = $dates['is_overdue']; + $daysOverdue = $isOverdue ? \App\Support\LibraryCalendar::lateDaysForNotice($this->db, $due, $copyId, $today) : 0; $penalty = $r['sanzione'] ?? null; $maxRenewals = $this->maxRenewalsFor($r); $processedBy = isset($r['processed_by'], $r['pb_nome']) @@ -528,6 +566,8 @@ public function mapLoan(array $r): array 'requested_at' => self::date($r['created_at'] ?? null), 'loaned_at' => self::date($r['data_prestito'] ?? null), 'due_at' => $due, + 'effective_due_at' => $dates['effective_due_at'], + 'is_overdue' => $isOverdue, 'pickup_deadline' => self::date($r['pickup_deadline'] ?? null), 'returned_at' => self::date($r['data_restituzione'] ?? null), 'returned_late' => (int) ($r['restituito_in_ritardo'] ?? 0) === 1, @@ -540,7 +580,7 @@ public function mapLoan(array $r): array 'last_recall_at' => self::str($r['last_recall_at'] ?? null), 'created_at' => self::str($r['created_at'] ?? null), 'updated_at' => self::str($r['updated_at'] ?? null), - 'actions' => CirculationOutcome::actionsFor($r, $maxRenewals, $today), + 'actions' => CirculationOutcome::actionsFor($r, $maxRenewals, $today, $holding ? $isOverdue : null), ]; } diff --git a/storage/plugins/mobile-api/src/Support/UserLoanCalendar.php b/storage/plugins/mobile-api/src/Support/UserLoanCalendar.php index cd2e66010..6ad980376 100644 --- a/storage/plugins/mobile-api/src/Support/UserLoanCalendar.php +++ b/storage/plugins/mobile-api/src/Support/UserLoanCalendar.php @@ -16,10 +16,11 @@ * * - no personal data: the description holds the title and the state only, * never a name or an e-mail address; - * - SEQUENCE = UNIX_TIMESTAMP(updated_at), monotonic per loan, so clients - * replace an event after a renewal or a state change; - * - DTSTAMP is the loan's last modification, not "now", so the body — and - * therefore the ETag — only changes when a loan does (cheap 304 polling). + * - SEQUENCE = UNIX_TIMESTAMP(updated_at), plus the calendar's revision and + * the days the end moved past the stored date, so clients replace an event + * after a renewal, a state change or a calendar change; + * - DTSTAMP is the loan's last modification, not "now", so unchanged loans + * and usable calendar dates produce stable output (cheap 304 polling). * * Lines are CRLF-terminated, TEXT values escaped (\\ ; , newline) and folded at * 75 octets on UTF-8 boundaries. @@ -29,6 +30,8 @@ final class UserLoanCalendar /** Loan states shown in the feed (approved and still relevant). */ public const STATES = ['in_corso', 'da_ritirare', 'prenotato', 'in_ritardo']; + private ?int $calendarRevision = null; + public function __construct(private \mysqli $db) { } @@ -70,7 +73,7 @@ private function loans(int $userId, string $today): array { $in = "'" . implode("','", self::STATES) . "'"; $stmt = $this->db->prepare( - "SELECT p.id, p.stato, p.data_prestito, p.data_scadenza, p.pickup_deadline, + "SELECT p.id, p.stato, p.copia_id, p.data_prestito, p.data_scadenza, p.pickup_deadline, DATE_FORMAT(p.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at, UNIX_TIMESTAMP(p.updated_at) AS seq, l.titolo FROM prestiti p @@ -80,7 +83,7 @@ private function loans(int $userId, string $today): array AND p.stato IN ({$in}) AND ((CASE WHEN p.stato = 'da_ritirare' AND p.pickup_deadline IS NOT NULL THEN p.pickup_deadline ELSE p.data_scadenza END) >= ? - OR p.stato = 'in_ritardo') + OR p.stato IN ('in_corso', 'in_ritardo', 'prenotato')) ORDER BY p.data_prestito ASC, p.id ASC" ); if ($stmt === false) { @@ -95,7 +98,11 @@ private function loans(int $userId, string $today): array } $stmt->close(); - return $rows; + \App\Support\LibraryCalendar::primeCopyBranches($this->db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $rows)); + + // A booked loan ends on its usable day, which a closure can put after the stored one. + return array_values(array_filter($rows, fn (array $r): bool => $r['stato'] !== 'prenotato' + || \App\Support\LibraryCalendar::dueDate($this->db, substr((string) $r['data_scadenza'], 0, 10), $r['copia_id'] !== null ? (int) $r['copia_id'] : null) >= $today)); } /** @@ -134,13 +141,34 @@ private function reservations(int $userId, string $today): array private function loanEvent(array $loan, string $today, \DateTimeZone $tz, bool $withAlarm): string { $state = (string) $loan['stato']; + $dates = \App\Support\LibraryCalendar::loanDates($this->db, $loan, $today); + if (in_array($state, ['in_corso', 'in_ritardo'], true)) { + $state = $dates['is_overdue'] ? 'in_ritardo' : 'in_corso'; + } $end = ($state === 'da_ritirare' && !empty($loan['pickup_deadline'])) ? (string) $loan['pickup_deadline'] - : (string) $loan['data_scadenza']; + : (string) ($dates['effective_due_at'] ?? $loan['data_scadenza']); if ($state === 'in_ritardo') { // Still physically out today: never end the event on the missed date. $end = max($end, $today); } + // A closure can move the end day without touching the row (a move refused + // for capacity), and a late loan's end follows today. The revision moves + // with it, one step per day past the stored date, so a client that + // replaces an event by SEQUENCE takes the new end. + $revisionShift = 0; + $storedDue = substr((string) ($loan['data_scadenza'] ?? ''), 0, 10); + if (in_array($state, ['in_corso', 'in_ritardo', 'prenotato'], true) && $storedDue !== '' && $end > $storedDue) { + $revisionShift = (int) (new \DateTimeImmutable($storedDue))->diff(new \DateTimeImmutable($end))->days; + } + $updatedAt = (string) ($loan['updated_at'] ?? ''); + // Removing a closure lowers the shift: the calendar's revision rose by + // more, so SEQUENCE and DTSTAMP do not go back. + $this->calendarRevision ??= \App\Support\LibraryCalendar::revision($this->db); + $revisionShift += $this->calendarRevision; + if ($revisionShift > 0 && $updatedAt !== '') { + $updatedAt = (new \DateTimeImmutable($updatedAt))->modify("+{$revisionShift} seconds")->format('Y-m-d H:i:s'); + } $title = (string) $loan['titolo']; $summary = match ($state) { @@ -169,8 +197,8 @@ private function loanEvent(array $loan, string $today, \DateTimeZone $tz, bool $ $end, $summary, $description, - (string) ($loan['updated_at'] ?? ''), - (int) ($loan['seq'] ?? 0), + $updatedAt, + (int) ($loan['seq'] ?? 0) + $revisionShift, $tz, $alarm ); diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index be5bcbaca..e5b22c945 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -2814,17 +2814,15 @@ private function extendLoan( $failureReason = 'identity_changed'; return null; } - if ((int) $loan['attivo'] !== 1 || $loan['stato'] !== 'in_corso') { + if ((int) $loan['attivo'] !== 1 || !in_array($loan['stato'], ['in_corso', 'in_ritardo'], true)) { $this->db->rollback(); $failureReason = 'ineligible_state'; return null; } - // Parità con PrestitiController::renew() (#366 residual): un prestito - // in_corso la cui data_scadenza è già passata è scaduto anche se il - // sweep non l'ha ancora marcato in_ritardo. Rinnovarlo calcolerebbe - // la nuova scadenza da una data passata E riarmerebbe i flag di - // notifica, duplicando l'email di ritardo. Stesso rifiuto. - if ((string) $loan['data_scadenza'] < \App\Support\DateHelper::today()) { + // Same rule as the desk: a due day closed afterwards remains + // usable on its next open day, even with an obsolete late flag. + $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; + if (\App\Support\LibraryCalendar::isPastDue($this->db, (string) $loan['data_scadenza'], $copyId, \App\Support\DateHelper::today())) { $this->db->rollback(); $failureReason = 'overdue'; return null; @@ -2854,7 +2852,8 @@ private function extendLoan( } $renewDays = $renewPolicy->renewalDays; $currentDue = (string) $loan['data_scadenza']; - $newDueDate = \App\Services\CirculationPolicy::dueDateFor($currentDue, $renewDays, $this->db, $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null); + $usableDue = \App\Support\LibraryCalendar::dueDate($this->db, $currentDue, $copyId); + $newDueDate = \App\Services\CirculationPolicy::dueDateFor($usableDue, $renewDays, $this->db, $copyId); // #336 parity con PrestitiController::renew/bulkExtend: il giorno di // scadenza corrente è già detenuto da QUESTO prestito, quindi la @@ -2862,7 +2861,6 @@ 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)) { @@ -2870,7 +2868,7 @@ private function extendLoan( $this->db->rollback(); $failureReason = 'no_capacity'; // Same figures the web renew() redirect carries (LoanWindow). - $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked, null, \App\Support\LibraryCalendar::forDb($this->db), \App\Support\LibraryCalendar::branchOfCopy($this->db, $copyId)); + $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked, $usableDue, \App\Support\LibraryCalendar::forDb($this->db), \App\Support\LibraryCalendar::branchOfCopy($this->db, $copyId)); return null; } @@ -2896,7 +2894,7 @@ private function extendLoan( $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, null, \App\Support\LibraryCalendar::forDb($this->db), \App\Support\LibraryCalendar::branchOfCopy($this->db, $copyId)); + $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked, $usableDue, \App\Support\LibraryCalendar::forDb($this->db), \App\Support\LibraryCalendar::branchOfCopy($this->db, $copyId)); return null; } } @@ -2906,7 +2904,7 @@ private function extendLoan( // la nuova scadenza riparte con il proprio ciclo di solleciti. $update = $this->db->prepare( 'UPDATE prestiti - SET data_scadenza = ?, renewals = ?, pickup_deadline = NULL, + SET data_scadenza = ?, renewals = ?, stato = \'in_corso\', pickup_deadline = NULL, warning_sent = 0, overdue_notification_sent = 0, recall_count = 0, last_recall_at = NULL, updated_at = NOW() WHERE id = ?' diff --git a/tests/atomic-book-create-356.spec.js b/tests/atomic-book-create-356.spec.js index 8a6bde518..b70d698a6 100644 --- a/tests/atomic-book-create-356.spec.js +++ b/tests/atomic-book-create-356.spec.js @@ -109,6 +109,36 @@ const copieCount = (id) => Number(dbQuery(`SELECT COUNT(*) FROM copie WHERE libr const copieCodes = (id) => dbQuery(`SELECT numero_inventario FROM copie WHERE libro_id=${id} ORDER BY id`) .split('\n').filter(Boolean); +/** Leave room for the web middleware inside the default 128 MiB budget. */ +function renderDetailWithinMemoryBudget(id) { + return JSON.parse(execFileSync('php', ['-d', 'memory_limit=64M', '-r', String.raw` + require getcwd() . '/vendor/autoload.php'; + Dotenv\Dotenv::createImmutable(getcwd())->safeLoad(); + $db = new mysqli( + getenv('E2E_DB_HOST') ?: ($_ENV['DB_HOST'] ?? 'localhost'), + getenv('E2E_DB_USER'), getenv('E2E_DB_PASS') ?: '', getenv('E2E_DB_NAME'), + (int) (getenv('E2E_DB_PORT') ?: ($_ENV['DB_PORT'] ?? 3306)), + getenv('E2E_DB_SOCKET') ?: null + ); + $db->set_charset('utf8mb4'); + $email = getenv('E2E_ADMIN_EMAIL'); + $stmt = $db->prepare('SELECT id, nome, cognome, email, tipo_utente, stato, email_verificata FROM utenti WHERE email = ?'); + $stmt->bind_param('s', $email); $stmt->execute(); + $_SESSION = ['user' => $stmt->get_result()->fetch_assoc()]; + $id = (int) $argv[1]; + $base = getenv('E2E_BASE_URL') ?: 'http://localhost:8081'; + $_SERVER['HTTP_HOST'] = parse_url($base, PHP_URL_HOST); + $_SERVER['REQUEST_URI'] = '/admin/books/' . $id; + $request = (new Slim\Psr7\Factory\ServerRequestFactory())->createServerRequest('GET', $base . $_SERVER['REQUEST_URI']); + $response = App\Support\ConfigStore::usingConnection($db, static fn () => + (new App\Controllers\LibriController())->show($request, new Slim\Psr7\Response(), $db, $id) + ); + $body = $response->getBody(); $size = $body->getSize(); + $body->seek(max(0, $size - 100)); + echo json_encode(['status' => $response->getStatusCode(), 'complete' => str_ends_with(rtrim($body->getContents()), '')]); + `, String(id)], { encoding: 'utf8', timeout: 60000, env: process.env })); +} + async function loginAsAdmin(page) { for (let attempt = 0; attempt < 2; attempt++) { await page.goto(`${BASE}/accedi`); @@ -291,6 +321,15 @@ test.describe.serial('atomic book+copies create (#356)', () => { expect(id).toBeGreaterThan(0); expect(copieCount(id)).toBe(9999); expect(Number(dbQuery(`SELECT copie_totali FROM libri WHERE id=${id}`))).toBe(9999); + expect(renderDetailWithinMemoryBudget(id)).toEqual({ status: 200, complete: true }); + // A successful INSERT/redirect is insufficient: rendering all copies used + // to exhaust PHP's default 128 MiB limit in the redirected detail page. + const detail = await page.request.get(`${BASE}/admin/books/${id}`, { timeout: 60000 }); + expect(detail.status()).toBe(200); + const html = await detail.text(); + expect(html).toContain(TITLE_CLAMP); + expect(html).toContain(dbQuery(`SELECT numero_inventario FROM copie WHERE libro_id=${id} ORDER BY id DESC LIMIT 1`)); + expect(html.trimEnd().endsWith('')).toBe(true); }); test('8) book.save.after fires post-commit: book-club reconciles by ISBN without corrupting the create', async ({ page }) => { diff --git a/tests/calendar-due-date-lock-conflict.unit.php b/tests/calendar-due-date-lock-conflict.unit.php index d4fa36602..4bde302a8 100644 --- a/tests/calendar-due-date-lock-conflict.unit.php +++ b/tests/calendar-due-date-lock-conflict.unit.php @@ -88,6 +88,109 @@ $complete = LibraryCalendar::extendDueDatesAfterSave($db); $check($complete === true, 'with the lock gone the next run completes'); $check($dueOf() === $nextDay, 'and moves the due date to the next open day'); + + // Bringing back a late flag and moving a pickup day also take the book row + // first, as desk operations do, so they wait for it instead of inverting the order. + $yesterday = (new DateTimeImmutable($today))->modify('-1 day')->format('Y-m-d'); + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, sede_id, attiva) VALUES ('{$yesterday}', '{$yesterday}', 'Lock conflict {$run} b', {$ids['sede']}, 1)"); + $lateClosure = (int) $db->insert_id; + $db->query("INSERT INTO copie (libro_id, numero_inventario, stato, sede_id, created_at) VALUES ({$ids['book']}, 'LCK-{$run}-b', 'prestato', {$ids['sede']}, NOW())"); + $lateCopy = (int) $db->insert_id; + $start = (new DateTimeImmutable($today))->modify('-10 day')->format('Y-m-d'); + $db->query("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES ({$ids['book']}, {$lateCopy}, {$ids['user']}, '{$start}', '{$yesterday}', 'in_ritardo', 'diretto', 1)"); + $lateLoan = (int) $db->insert_id; + $db->query("INSERT INTO copie (libro_id, numero_inventario, stato, sede_id, created_at) VALUES ({$ids['book']}, 'LCK-{$run}-c', 'prenotato', {$ids['sede']}, NOW())"); + $pickupCopy = (int) $db->insert_id; + $pickupEnd = (new DateTimeImmutable($today))->modify('+20 day')->format('Y-m-d'); + $db->query("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, pickup_deadline, stato, origine, attivo) VALUES ({$ids['book']}, {$pickupCopy}, {$ids['user']}, '{$today}', '{$pickupEnd}', '{$due}', 'da_ritirare', 'diretto', 1)"); + $pickupLoan = (int) $db->insert_id; + LibraryCalendar::reset(); + $stateOf = static fn (): string => (string) $db->query("SELECT stato FROM prestiti WHERE id={$lateLoan}")->fetch_row()[0]; + $pickupOf = static fn (): string => (string) $db->query("SELECT pickup_deadline FROM prestiti WHERE id={$pickupLoan}")->fetch_row()[0]; + $other->begin_transaction(); + $other->query("SELECT id FROM libri WHERE id={$ids['book']} FOR UPDATE")->fetch_all(); + $check(LibraryCalendar::extendDueDatesAfterSave($db) === false, 'bringing back a late flag waits for a book row another transaction holds'); + $check($stateOf() === 'in_ritardo', 'and leaves the loan as it was'); + try { + LibraryCalendar::extendPickupDeadlines($db); + $pickupError = 0; + } catch (\mysqli_sql_exception $e) { + $pickupError = $e->getCode(); + } + $check($pickupError === 1205 && substr($pickupOf(), 0, 10) === $due, 'moving a pickup day waits for it too (' . $pickupError . ')'); + $other->rollback(); + $check(LibraryCalendar::extendDueDatesAfterSave($db) === true && $stateOf() === 'in_corso', 'with the lock gone the late flag comes back'); + LibraryCalendar::extendPickupDeadlines($db); + $check(substr($pickupOf(), 0, 10) === $nextDay, 'and the pickup day moves'); + $db->query("DELETE FROM prestiti WHERE id IN ({$lateLoan}, {$pickupLoan})"); + $db->query("DELETE FROM copie WHERE id IN ({$lateCopy}, {$pickupCopy})"); + $db->query("DELETE FROM calendario_chiusure WHERE id={$lateClosure}"); + LibraryCalendar::reset(); + + // A calendar writer's count of a patron's loans of one material reads the + // loans as they are now, and leaves the book rows of those loans alone: a + // desk operation holding such a book must not make it wait. + $policy = new \App\Services\CirculationPolicy($db); + $db->query("UPDATE libri SET tipo_media='libro' WHERE id={$ids['book']}"); + $other->begin_transaction(); + $other->query("SELECT id FROM libri WHERE id={$ids['book']} FOR UPDATE")->fetch_all(); + try { + $held = $policy->countActiveLoansForMedia($ids['user'], 'libro', null, true); + } catch (\mysqli_sql_exception $e) { + $held = 'lock error ' . $e->getCode(); + } + $other->rollback(); + $check($held === 1, 'the locking count does not wait for a book row another transaction holds (' . $held . ')'); + $check($policy->countActiveLoansForMedia($ids['user'], 'dvd', null, true) === 0, 'the count still tells materials apart'); + $check($policy->countActiveLoansForMedia($ids['user'], 'libro', $ids['loan'], true) === 0, 'and still leaves out the excluded loan'); + + $db->query("UPDATE libri SET deleted_at=NOW() WHERE id={$ids['book']}"); + $check($policy->countActiveLoansForMedia($ids['user'], 'libro', null, true) === 1, 'a loan on an archived title still counts'); + $db->query("UPDATE libri SET deleted_at=NULL WHERE id={$ids['book']}"); + + // A return committed after the writer's snapshot lowers the locking count; + // a plain read in that transaction keeps seeing the snapshot. + $db->begin_transaction(); + $snapshot = $policy->countActiveLoansForMedia($ids['user'], 'libro'); + $other->query("UPDATE prestiti SET attivo=0, stato='restituito', data_restituzione='{$today}' WHERE id={$ids['loan']}"); + $stale = $policy->countActiveLoansForMedia($ids['user'], 'libro'); + try { + $current = $policy->countActiveLoansForMedia($ids['user'], 'libro', null, true); + } catch (\mysqli_sql_exception $e) { + // MariaDB with innodb_snapshot_isolation refuses a locking read of a row + // changed since the snapshot (1020) and rolls the transaction back, so no + // writer can act on the old count; elsewhere the locking read returns the + // current one. + $current = $e->getCode() === 1020 ? 0 : throw $e; + } + $db->rollback(); + $check($snapshot === 1 && $stale === 1 && $current === 0, 'a return committed after the snapshot is seen by the locking count only'); + + // MariaDB with snapshot isolation aborts the whole transaction on a row changed + // since the snapshot. Inside a calendar save that must stop the run: going on + // would commit moves for a closure the abort has just removed. + $isolation = $db->query("SHOW VARIABLES LIKE 'innodb_snapshot_isolation'")->fetch_row(); + if ($isolation !== null && strcasecmp((string) $isolation[1], 'ON') === 0) { + $db->query("UPDATE prestiti SET attivo=1, stato='in_corso', data_restituzione=NULL WHERE id={$ids['loan']}"); + $currentDue = $dueOf(); + $db->query("UPDATE calendario_chiusure SET data_inizio='{$currentDue}', data_fine='{$currentDue}' WHERE id={$ids['closure']}"); + LibraryCalendar::reset(); + $db->begin_transaction(); + $db->query("SELECT COUNT(*) FROM prestiti WHERE id={$ids['loan']}")->fetch_all(); + $other->query("UPDATE prestiti SET data_prestito = DATE_SUB(data_prestito, INTERVAL 1 DAY) WHERE id={$ids['loan']}"); + $raised = null; + try { + LibraryCalendar::extendDueDates($db); + } catch (\Throwable $e) { + $raised = $e->getCode(); + } + $db->rollback(); + LibraryCalendar::reset(); + $check($raised === 1020, 'a snapshot conflict inside a calendar save stops the run (' . var_export($raised, true) . ')'); + $check($dueOf() === $currentDue, 'and the loan keeps its date'); + } else { + echo " -- snapshot conflict case applies to MariaDB with innodb_snapshot_isolation only\n"; + } } finally { try { $other->rollback(); diff --git a/tests/circulation-hardening-0780.unit.php b/tests/circulation-hardening-0780.unit.php index f272ff934..243c65907 100644 --- a/tests/circulation-hardening-0780.unit.php +++ b/tests/circulation-hardening-0780.unit.php @@ -49,7 +49,7 @@ $capacity = file_get_contents($root . '/app/Services/CapacityService.php'); $check(str_contains((string) $capacity, "JOIN utenti u ON u.id = r.utente_id") - && str_contains((string) $capacity, "eligibleUserWhere('u', \$this->db)"), + && str_contains((string) $capacity, "eligibleUserWhere('u', \$this->db, \$this->lockingReads)"), 'ineligible reservations are paused instead of consuming capacity'); // 0.9.0: the limit is the patron's (CirculationPolicy::totalLoanLimitSql()). $check(str_contains((string) $capacity, 'totalLoanLimitSql') && str_contains((string) $capacity, 'FROM prestiti cap'), diff --git a/tests/circulation-type-concurrency.unit.php b/tests/circulation-type-concurrency.unit.php new file mode 100644 index 000000000..4bab5d059 --- /dev/null +++ b/tests/circulation-type-concurrency.unit.php @@ -0,0 +1,206 @@ +beforeUserWrite !== null + && (str_starts_with($query, 'INSERT INTO utenti (') || str_starts_with($query, "UPDATE utenti SET\n"))) { + $action = $this->beforeUserWrite; + $this->beforeUserWrite = null; + $action(); + } + return parent::prepare($query); + } + + public function begin_transaction(int $flags = 0, ?string $name = null): bool + { + $action = $this->beforeBegin; + $this->beforeBegin = null; + if ($action !== null) { + $action(); + } + return parent::begin_transaction($flags, $name); + } +} + +$env = Dotenv\Dotenv::parse((string) file_get_contents(dirname(__DIR__) . '/.env')); +$args = [ + getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? 'localhost'), + getenv('E2E_DB_USER') ?: $env['DB_USER'], + getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ''), + getenv('E2E_DB_NAME') ?: $env['DB_NAME'], + (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306)), + getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null), +]; +$db = new PatronTypeInterleavingDb(...$args); +$other = new mysqli(...$args); +$db->set_charset('utf8mb4'); +$other->set_charset('utf8mb4'); +$sessionBefore = $_SESSION ?? []; +$shared = new ReflectionProperty(ConfigStore::class, 'sharedConnection'); +$connectionBefore = $shared->getValue(); +$shared->setValue(null, $db); +ConfigStore::clearCache(); +$defaultsBefore = $db->query('SELECT id FROM tipologie_lettore WHERE predefinita = 1')->fetch_all(MYSQLI_NUM); +$types = []; +$users = []; +$run = bin2hex(random_bytes(5)); +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label) use (&$passed, &$failed): void { + echo ($ok ? 'PASS ' : 'FAIL ') . $label . PHP_EOL; + $ok ? $passed++ : $failed++; +}; +$request = static fn (array $body = []) => (new ServerRequestFactory())->createServerRequest('POST', '/admin/settings/circulation')->withParsedBody($body); +$controller = new CirculationSettingsController(); +$createType = static function (string $suffix) use ($db, $run, &$types): int { + $code = "zztype-{$run}-{$suffix}"; + $db->query("INSERT INTO tipologie_lettore (codice,nome,attiva,predefinita) VALUES ('{$code}','{$code}',1,0)"); + $types[] = (int) $db->insert_id; + return (int) $db->insert_id; +}; +$makeUser = static function (string $suffix, string $kind = 'standard', ?int $type = null) use ($db, $run, &$users): int { + $stmt = $db->prepare("INSERT INTO utenti (nome,cognome,email,password,codice_tessera,tipo_utente,stato,tipologia_id) VALUES ('Type','Review',?,'x',?,?,'attivo',?)"); + $mail = "type-{$run}-{$suffix}@example.invalid"; + $card = "TC{$run}{$suffix}"; + $stmt->bind_param('sssi', $mail, $card, $kind, $type); + $stmt->execute(); + $users[] = (int) $db->insert_id; + $stmt->close(); + return (int) $db->insert_id; +}; +$resetMessages = static function (): void { unset($_SESSION['error_message'], $_SESSION['success_message']); }; + +try { + $operator = $makeUser('op', 'admin'); + $_SESSION = ['user' => ['id' => $operator, 'tipo_utente' => 'admin', 'stato' => 'attivo']]; + $baseline = $createType('base'); + $controller->setDefault($request(), new Response(), $db, $baseline); + + // The first delete read says "not default"; the other administrator makes + // it default before the deletion transaction starts. + $target = $createType('protected'); + $reader = $makeUser('reader', type: $target); + $db->query("INSERT INTO regole_prestito (tipologia_id,tipo_media,durata_prestito_giorni) VALUES ({$target},'libro',7)"); + $db->beforeBegin = static function () use ($controller, $request, $other, $target): void { + $controller->setDefault($request(), new Response(), $other, $target); + unset($_SESSION['success_message'], $_SESSION['error_message']); + }; + $controller->deleteType($request(), new Response(), $db, $target); + $check((int) $db->query("SELECT tipologia_id FROM utenti WHERE id={$reader}")->fetch_row()[0] === $target, 'a concurrently selected default keeps its readers'); + $check((int) ($db->query("SELECT durata_prestito_giorni FROM regole_prestito WHERE tipologia_id={$target} AND tipo_media='libro'")->fetch_row()[0] ?? 0) === 7, 'a concurrently selected default keeps its material rules'); + $check(isset($_SESSION['error_message']) && !isset($_SESSION['success_message']), 'deleting a newly selected default reports refusal'); + + // The target of set-default disappears after its initial read. Clearing + // the old default first must never leave the library with no default. + $missing = $createType('missing'); + $db->beforeBegin = static function () use ($controller, $request, $other, $missing): void { + $controller->deleteType($request(), new Response(), $other, $missing); + unset($_SESSION['success_message'], $_SESSION['error_message']); + }; + $controller->setDefault($request(), new Response(), $db, $missing); + $check((int) $db->query('SELECT COUNT(*) FROM tipologie_lettore WHERE predefinita=1')->fetch_row()[0] === 1, 'a deleted set-default target does not clear the existing default'); + $check((int) $db->query("SELECT predefinita FROM tipologie_lettore WHERE id={$target}")->fetch_row()[0] === 1, 'the previous default is retained after the refused change'); + $check(isset($_SESSION['error_message']) && !isset($_SESSION['success_message']), 'setting a vanished type as default reports failure'); + + $edited = $createType('edited'); + $db->beforeBegin = static function () use ($controller, $request, $other, $edited): void { + $controller->setDefault($request(), new Response(), $other, $edited); + }; + $resetMessages(); + $form = ['nome' => 'Edited type', 'codice' => "zztype-{$run}-edited", 'attiva' => '0', 'prenotazioni_abilitate' => '1']; + $controller->updateType($request($form), new Response(), $db, $edited); + $check($db->query("SELECT CONCAT(predefinita, '|', attiva) FROM tipologie_lettore WHERE id={$edited}")->fetch_row()[0] === '1|1', 'editing a newly selected default cannot deactivate it'); + + $vanished = $createType('vanished'); + $db->beforeBegin = static function () use ($controller, $request, $other, $vanished): void { + $controller->deleteType($request(), new Response(), $other, $vanished); + unset($_SESSION['success_message'], $_SESSION['error_message']); + }; + $resetMessages(); + $controller->updateType($request(['nome' => 'Vanished', 'codice' => "zztype-{$run}-vanished"]), new Response(), $db, $vanished); + $check(isset($_SESSION['error_message']) && !isset($_SESSION['success_message']), 'editing a vanished type never claims success'); + + $controller->setDefault($request(), new Response(), $db, $baseline); + $resetMessages(); + $controller->deleteType($request(), new Response(), $db, $target); + $check($db->query("SELECT tipologia_id FROM utenti WHERE id={$reader}")->fetch_row()[0] === null, 'ordinary deletion moves the reader to the default type'); + $check((int) $db->query("SELECT COUNT(*) FROM regole_prestito WHERE tipologia_id={$target}")->fetch_row()[0] === 0, 'ordinary deletion removes the deleted type material rules'); + $check((int) $db->query('SELECT COUNT(*) FROM tipologie_lettore WHERE predefinita=1 AND attiva=1')->fetch_row()[0] === 1, 'ordinary changes retain exactly one active default'); + + // Assigning the type is part of saving the account. A type removed between + // validation and writing must not leave a half-created/half-updated reader. + $creatingType = $createType('create-user'); + $db->beforeUserWrite = static function () use ($controller, $request, $other, $creatingType): void { + $controller->deleteType($request(), new Response(), $other, $creatingType); + }; + $mail = "type-{$run}-created@example.invalid"; + $form = ['nome' => 'Created', 'cognome' => 'Reader', 'email' => $mail, + 'telefono' => '000', 'indirizzo' => 'Via Test 1', 'password' => 'ReviewType2026!', + 'tipo_utente' => 'standard', 'tipologia_id' => (string) $creatingType, 'codice_tessera' => "TN{$run}"]; + $response = (new \App\Controllers\UsersController())->store($request($form), new Response(), $db); + $createdId = (int) ($db->query("SELECT id FROM utenti WHERE email='{$mail}'")->fetch_row()[0] ?? 0); + if ($createdId > 0) { $users[] = $createdId; } + $check(str_contains($response->getHeaderLine('Location'), 'error='), 'a vanished type makes account creation report failure'); + $check($createdId === 0, 'a failed patron-type assignment leaves no half-created account'); + + $editedReader = $makeUser('atomic', type: $baseline); + $updatingType = $createType('update-user'); + $db->beforeUserWrite = static function () use ($controller, $request, $other, $updatingType): void { + $controller->deleteType($request(), new Response(), $other, $updatingType); + }; + $form['email'] = "type-{$run}-atomic@example.invalid"; + $form['nome'] = 'Changed'; + $form['codice_tessera'] = "TC{$run}atomic"; + $form['tipologia_id'] = (string) $updatingType; + unset($form['password']); + $response = (new \App\Controllers\UsersController())->update($request($form), new Response(), $db, $editedReader); + $row = $db->query("SELECT nome,tipologia_id FROM utenti WHERE id={$editedReader}")->fetch_assoc(); + $check(str_contains($response->getHeaderLine('Location'), 'error='), 'a vanished type makes account editing report failure'); + $check($row['nome'] === 'Type' && (int) $row['tipologia_id'] === $baseline, 'a failed type change preserves the original account and type together'); +} finally { + $db->beforeBegin = null; + $db->beforeUserWrite = null; + $db->rollback(); + $db->query('UPDATE tipologie_lettore SET predefinita=0 WHERE predefinita=1'); + foreach ($defaultsBefore as [$id]) { $db->query('UPDATE tipologie_lettore SET predefinita=1 WHERE id=' . (int) $id); } + if ($users !== []) { $db->query('DELETE FROM utenti WHERE id IN (' . implode(',', $users) . ')'); } + if ($types !== []) { + $list = implode(',', $types); + $db->query("DELETE FROM regole_prestito WHERE tipologia_id IN ({$list})"); + $db->query("DELETE FROM tipologie_lettore WHERE id IN ({$list})"); + } + $_SESSION = $sessionBefore; + $shared->setValue(null, $connectionBefore); + ConfigStore::clearCache(); + $other->close(); + $db->close(); +} +echo "Patron-type concurrency: {$passed} passed, {$failed} failed\n"; +exit($failed > 0 ? 1 : 0); diff --git a/tests/closures-calendar-existing-loans.spec.js b/tests/closures-calendar-existing-loans.spec.js index 8c7da373e..d0c680dba 100644 --- a/tests/closures-calendar-existing-loans.spec.js +++ b/tests/closures-calendar-existing-loans.spec.js @@ -414,6 +414,26 @@ test.describe.serial('Closures added after the loan', () => { expect(loanRow(loanId).split('|')[0]).toBe('in_corso'); // The notice already sent stays recorded: no second overdue e-mail later. expect(db(`SELECT overdue_notification_sent FROM prestiti WHERE id=${loanId}`)).toBe('1'); + const bookId = F.dbInt(`SELECT libro_id FROM prestiti WHERE id=${loanId}`); + await page.goto(`${F.BASE}/admin/books/${bookId}`); + await expect(page.locator(`form[action$="/admin/loans/renew/${loanId}"] button[type="submit"]`)).toBeVisible(); + await expect(page.getByText('Non rinnovabile: prestito in ritardo', { exact: true })).toHaveCount(0); + + // The renewal counts from the usable return day (today), not from the + // closed stored date: it ends where a loan due today on an open day ends. + const control = pastLoan(title('reconcile-control'), today); + const renewInUi = async (id) => { + const book = F.dbInt(`SELECT libro_id FROM prestiti WHERE id=${id}`); + await page.goto(`${F.BASE}/admin/books/${book}`); + await page.locator(`form[action$="/admin/loans/renew/${id}"] button[type="submit"]`).click(); + await page.locator('.swal2-confirm').click(); + await page.waitForLoadState('networkidle'); + expect(db(`SELECT renewals FROM prestiti WHERE id=${id}`)).toBe('1'); + return loanRow(id).split('|')[1]; + }; + const controlDue = await renewInUi(control); + expect(controlDue > today).toBe(true); + expect(await renewInUi(loanId)).toBe(controlDue); }); test('Past 2: the overdue sweep waits for the first open day after a due day closed afterwards', async () => { diff --git a/tests/force-https-redirect.unit.php b/tests/force-https-redirect.unit.php index b0605c9cc..ab1dfd2ce 100644 --- a/tests/force-https-redirect.unit.php +++ b/tests/force-https-redirect.unit.php @@ -28,6 +28,13 @@ // ── Child mode: apply one scenario, print the resolved target (or "NULL") ───── if (($argv[1] ?? '') === '--child') { + // Fresh processes still inherit the developer/CI environment. A scenario + // with no canonical host must not silently reuse the parent's configured + // host instead of exercising the no-configuration branch. + foreach (['APP_CANONICAL_URL', 'APP_TRUSTED_HOSTS'] as $key) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } require $root . '/vendor/autoload.php'; /** @var array{server?:array,env?:array} $s */ $s = json_decode((string) getenv('RT_SCENARIO'), true) ?: []; diff --git a/tests/helpers/circulation-fixtures.js b/tests/helpers/circulation-fixtures.js index 1ccb14325..404bbbb92 100644 --- a/tests/helpers/circulation-fixtures.js +++ b/tests/helpers/circulation-fixtures.js @@ -112,7 +112,14 @@ function deleteTypes(prefix) { async function loginAs(page, email, password) { for (let attempt = 0; attempt < 2; attempt++) { clearRateLimits(); + // A plugin-setup helper may already have signed this context in. /accedi + // then redirects to the dashboard and has no form to fill. Start a fresh + // session for the requested identity (also when switching reader/admin). + await page.context().clearCookies(); await page.goto(`${BASE}/accedi`); + // A fresh phone's consent banner can cover the login submit button. + const rejectCookies = page.locator('#silktide-wrapper .reject-all'); + await rejectCookies.click({ timeout: 1500 }).catch(() => {}); await page.fill('input[name="email"]', email); await page.fill('input[name="password"]', password); await page.locator('button[type="submit"]').click(); diff --git a/tests/helpers/minor-features-090.js b/tests/helpers/minor-features-090.js index 0ab26f80d..168b38781 100644 --- a/tests/helpers/minor-features-090.js +++ b/tests/helpers/minor-features-090.js @@ -42,6 +42,8 @@ async function loginAsAdmin(page) { } await page.fill('input[name="email"]', ADMIN_EMAIL); await page.fill('input[name="password"]', ADMIN_PASS); + const rejectCookies = page.locator('#silktide-wrapper .reject-all'); + if (await rejectCookies.isVisible()) await rejectCookies.click(); await page.locator('button[type="submit"]').first().click(); // The redirect target varies (dashboard, intended page): wait for the // password field to go away rather than for one specific navigation. diff --git a/tests/label-pdf-content.spec.js b/tests/label-pdf-content.spec.js index 3de884f31..d7d1b941b 100644 --- a/tests/label-pdf-content.spec.js +++ b/tests/label-pdf-content.spec.js @@ -63,7 +63,8 @@ async function inspectLabel(page, urlPath) { expect(resp.headers()['content-type']).toContain('application/pdf'); const file = path.join(os.tmpdir(), `label-${RUN}-${Math.random().toString(36).slice(2)}.pdf`); fs.writeFileSync(file, await resp.body()); - const text = execFileSync('pdftotext', ['-layout', file, '-'], { encoding: 'utf-8' }); + // A visible subtitle may wrap to another line within a valid label size. + const text = execFileSync('pdftotext', ['-layout', file, '-'], { encoding: 'utf-8' }).replace(/\s+/g, ' ').trim(); const info = execFileSync('pdfinfo', [file], { encoding: 'utf-8' }); fs.unlinkSync(file); const m = info.match(/Page size:\s*([\d.]+)\s*x\s*([\d.]+)\s*pts/i); @@ -162,7 +163,9 @@ test.describe.serial('Copy-label PDF content (#238)', () => { await page.goto(`${BASE}/admin/books/${bookId}`); const copyRows = page.locator('a[href*="copy-labels-pdf?copy_id="]').locator('xpath=ancestor::tr'); await expect(copyRows).toHaveCount(2); - const inventoryCodes = await copyRows.locator('td:first-child').allTextContents(); + // The responsive table also contains a real "Inventario" field label. + // Compare the code itself, preserving the C2-before-C10 ordering check. + const inventoryCodes = await copyRows.locator('td:first-child > div.text-sm').allTextContents(); expect(inventoryCodes.map((code) => code.trim())).toEqual([CODE_A, CODE_B]); // Single copy A. diff --git a/tests/library-calendar-loans.unit.php b/tests/library-calendar-loans.unit.php index b42323ce3..61b81ff33 100644 --- a/tests/library-calendar-loans.unit.php +++ b/tests/library-calendar-loans.unit.php @@ -622,5 +622,98 @@ public function query(string $query, int $result_mode = MYSQLI_STORE_RESULT): my $clearClosures(); $_SESSION = []; +// ── E-mails quote the usable return day ─────────────────────────────────── +// A stored due date on a closed day, as a move the book could not take leaves +// it: every message about the loan names the next open day, the row stays as +// it is, and with the closure gone the text is the stored date again. +$mailDue = $openFrom($shift($today, 45), 1); +$mailNext = $shift($mailDue, 1); +$db->query("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente) VALUES ('{$tag}-mail', 'Mail', 'Reader', '{$tag}@example.invalid', 'x', 'attivo', 'standard')"); +$mailUser = (int) $db->insert_id; +$db->query("INSERT INTO libri (titolo, copie_totali, copie_disponibili, stato) VALUES ('{$tag} mail', 1, 0, 'prestato')"); +$mailBook = (int) $db->insert_id; +$db->query("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES ({$mailBook}, '{$tag}-mail', 'prestato')"); +$mailCopy = (int) $db->insert_id; +$db->query("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES ({$mailBook}, {$mailCopy}, {$mailUser}, '{$today}', '{$mailDue}', 'in_corso', 'diretto', 1)"); +$mailLoan = (int) $db->insert_id; +$mailbox = new class extends \App\Support\EmailService { + /** @var list}> */ + public array $sent = []; + public function __construct() + { + } + public function sendTemplate(string $to, string $templateName, array $variables = [], ?string $locale = null, array $attachments = []): bool + { + $this->sent[] = ['template' => $templateName, 'variables' => $variables]; + return true; + } +}; +$notifications = new \App\Support\NotificationService($db); +(new ReflectionProperty($notifications, 'emailService'))->setValue($notifications, $mailbox); +$reachable = new ReflectionProperty(\App\Support\Mailer::class, 'smtpReachable'); +$reachableBefore = $reachable->getValue(); +$reachable->setValue(null, true); +$pdfFields = new class ($db) extends \App\Support\LoanPdfGenerator { + /** @var array */ + public array $fields = []; + protected function renderSection(\TCPDF $pdf, string $title, array $fields): void + { + $this->fields += $fields; + } +}; +$sentDate = static function (string $template, string $variable) use ($mailbox): ?string { + foreach (array_reverse($mailbox->sent) as $mail) { + if ($mail['template'] === $template) { + return (string) ($mail['variables'][$variable] ?? ''); + } + } + return null; +}; +$mailDay = static fn (string $ymd): string => $notifications->formatEmailDate($ymd, false, $notifications->resolveRecipientLocale("{$tag}@example.invalid")); +$sendAll = static function () use ($notifications, $mailLoan): void { + $notifications->sendLoanReceiptEmail($mailLoan); + $notifications->sendLoanApprovedNotification($mailLoan); + $notifications->sendLoanPickedUpNotification($mailLoan); +}; +try { + $addClosure($mailDue, $mailDue); + foreach (['prenotato', 'da_ritirare'] as $scheduledState) { + $scheduled = \App\Support\LibraryCalendar::loanDates($db, ['data_scadenza' => $mailDue, 'stato' => $scheduledState, 'attivo' => 1, 'copia_id' => null]); + $check($scheduled['effective_due_at'] === $mailNext, "a {$scheduledState} loan on a closed day shows the next open day"); + $check($scheduled['is_overdue'] === false && $scheduled['due_attention'] === false, "a {$scheduledState} loan is neither late nor due by that day"); + } + $sendAll(); + foreach ([['loan_receipt_email', 'data_scadenza'], ['loan_approved', 'data_fine'], ['loan_picked_up', 'data_scadenza']] as [$template, $variable]) { + $check($sentDate($template, $variable) === $mailDay($mailNext), "e-mail {$template}: {$variable} is the next open day"); + } + $pdfFields->generate($mailLoan); + $check(($pdfFields->fields[__('Data Scadenza:')] ?? null) === format_date($mailNext, false, '/'), 'the receipt PDF attached to that e-mail shows the same day'); + $check((string) $db->query("SELECT data_scadenza FROM prestiti WHERE id = {$mailLoan}")->fetch_row()[0] === $mailDue, 'quoting the usable day leaves the stored due date alone'); + // A reader moved to another copy hears the same day. + $db->query("UPDATE prestiti SET stato = 'da_ritirare' WHERE id = {$mailLoan}"); + $reassignment = new \App\Services\ReservationReassignmentService($db); + (new ReflectionProperty($reassignment, 'notificationService'))->setValue($reassignment, $notifications); + (new ReflectionMethod($reassignment, 'notifyUserCopyAvailable'))->invoke($reassignment, $mailLoan); + $check($sentDate('reservation_awaiting_approval', 'data_fine') === $mailDay($mailNext), 'e-mail reservation_awaiting_approval: data_fine is the next open day'); + $notifications->sendPickupReadyNotification($mailLoan); + $check($sentDate('loan_pickup_ready', 'data_fine') === $mailDay($mailNext), 'e-mail loan_pickup_ready: data_fine is the next open day'); + $db->query("UPDATE prestiti SET stato = 'in_corso' WHERE id = {$mailLoan}"); + + $clearClosures(); + $mailbox->sent = []; + $sendAll(); + foreach ([['loan_receipt_email', 'data_scadenza'], ['loan_approved', 'data_fine'], ['loan_picked_up', 'data_scadenza']] as [$template, $variable]) { + $check($sentDate($template, $variable) === $mailDay($mailDue), "e-mail {$template}: with no closure {$variable} is the stored date"); + } +} finally { + $reachable->setValue(null, $reachableBefore); + $clearClosures(); + $db->query("DELETE FROM email_delivery_outbox WHERE recipient_email = '{$tag}@example.invalid'"); + $db->query("DELETE FROM prestiti WHERE id = {$mailLoan}"); + $db->query("DELETE FROM copie WHERE id = {$mailCopy}"); + $db->query("DELETE FROM libri WHERE id = {$mailBook}"); + $db->query("DELETE FROM utenti WHERE id = {$mailUser}"); +} + echo ($failed === 0 ? 'SUCCESS' : 'FAILURE') . " {$passed} passed, {$failed} failed\n"; exit($failed === 0 ? 0 : 1); diff --git a/tests/loan-calendar-width.spec.js b/tests/loan-calendar-width.spec.js index 21a457848..91e21b1d7 100644 --- a/tests/loan-calendar-width.spec.js +++ b/tests/loan-calendar-width.spec.js @@ -43,7 +43,7 @@ function freeBookId() { async function openLoanPopup(page, bookId) { await page.goto(`${BASE}/accedi`); const rejectCookies = page.locator('#silktide-wrapper .reject-all'); - if (await rejectCookies.isVisible()) await rejectCookies.click(); + await rejectCookies.click({ timeout: 1500 }).catch(() => {}); await page.fill('input[name="email"]', ADMIN_EMAIL); await page.fill('input[name="password"]', ADMIN_PASS); await page.locator('button[type="submit"]').click(); diff --git a/tests/loan-due-visibility-email.unit.php b/tests/loan-due-visibility-email.unit.php index da3507202..514cba45b 100644 --- a/tests/loan-due-visibility-email.unit.php +++ b/tests/loan-due-visibility-email.unit.php @@ -22,9 +22,9 @@ $dashboardView = (string)file_get_contents($root . '/app/Views/dashboard/index.php'); $check( - str_contains($notifications, 'p.data_scadenza BETWEEN ? AND DATE_ADD(?, INTERVAL ? DAY)') - && str_contains($notifications, "bind_param('sssi', \$today, \$today, \$today, \$daysBeforeWarning)"), - 'expiration warnings include due-today loans through the configured horizon' + str_contains($notifications, 'p.data_scadenza <= DATE_ADD(?, INTERVAL ? DAY)') + && str_contains($notifications, '$effectiveDue < $today || $effectiveDue > $warningLimit'), + 'expiration warnings select broadly and enforce the usable-date warning horizon' ); $check( str_contains($notifications, 'scade oggi') && str_contains($notifications, '$daysRemaining === 0'), @@ -39,9 +39,10 @@ ); $check( str_contains($loansView, 'DateHelper::today()') - && str_contains($loansView, 'data <= applicationToday') + && str_contains($loansView, 'row.due_attention === true') + && str_contains($api, 'LibraryCalendar::loanDates($db, $r)') && !str_contains($loansView, "strtotime(date('Y-m-d'))"), - 'loan list highlighting uses the application date in PHP and JavaScript' + 'loan list highlighting consumes the server calendar instead of a browser date comparison' ); $check( str_contains($dashboardView, 'DateHelper::today()') diff --git a/tests/loan-recall-360-behavior.unit.php b/tests/loan-recall-360-behavior.unit.php index 5a5ad960c..46e7c409c 100644 --- a/tests/loan-recall-360-behavior.unit.php +++ b/tests/loan-recall-360-behavior.unit.php @@ -388,6 +388,124 @@ $sent = $claimRecall->invoke($service, $rowOf($noLongerDue), 1, 0, static fn (): int|false => false); $check($sent === false && $outcomeOf() === 'not_due' && $recallCountOf($noLongerDue)['count'] === 0, 'C3 loan no longer late by the calendar at claim time → "not_due", no UPDATE, counter untouched'); +// ── D. The late e-mails quote the usable return day ───────────────────────── +// A past due day closed afterwards is never moved: the row keeps it, while the +// overdue notice, the recall and the staff alert name the first open day after +// it, the day their late-day count starts from. The stored date stays the claim +// guard, so the claims below go through and the row does not change. +$featureRow = $db->query("SELECT setting_value FROM system_settings WHERE category='features' AND setting_key='closures_calendar'")->fetch_row(); +$weekdaysRow = $db->query("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'")->fetch_row(); +$db->query("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('features','closures_calendar','1') ON DUPLICATE KEY UPDATE setting_value='1'"); +$db->query("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans','closed_weekdays','') ON DUPLICATE KEY UPDATE setting_value=''"); +$closedLoan = $makeLoan(daysOverdue: 20, overdueSent: 0); +$closedUser = (int) $db->query('SELECT utente_id FROM prestiti WHERE id = ' . (int) $closedLoan)->fetch_row()[0]; +$closedEmail = (string) $db->query("SELECT email FROM utenti WHERE id = {$closedUser}")->fetch_row()[0]; +$storedDue = (string) $db->query('SELECT data_scadenza FROM prestiti WHERE id = ' . (int) $closedLoan)->fetch_row()[0]; +$usableDue = (new DateTimeImmutable($storedDue))->modify('+2 days')->format('Y-m-d'); +$closureTag = $titlePrefix . '_closure'; +$closureEnd = (new DateTimeImmutable($storedDue))->modify('+1 day')->format('Y-m-d'); +$adminCard = $cardPrefix . 'A'; +$adminEmail = 'admin' . $run . $emailDomain; +$db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione) VALUES ('{$storedDue}', '{$closureEnd}', '{$closureTag}')"); +$db->query("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato) VALUES ('{$adminCard}', 'Recall', 'Admin', '{$adminEmail}', '', 'admin', 'attivo')"); +\App\Support\LibraryCalendar::reset(); +$mailbox = new class extends \App\Support\EmailService { + /** @var list}> */ + public array $sent = []; + public function __construct() + { + } + public function sendTemplate(string $to, string $templateName, array $variables = [], ?string $locale = null, array $attachments = []): bool + { + $this->sent[] = ['to' => $to, 'template' => $templateName, 'variables' => $variables]; + return true; + } +}; +$mailService = new NotificationService($db); +(new ReflectionProperty($mailService, 'emailService'))->setValue($mailService, $mailbox); +$reachable = new ReflectionProperty(\App\Support\Mailer::class, 'smtpReachable'); +$reachableBefore = $reachable->getValue(); +$reachable->setValue(null, true); +$mailTo = static function (string $to, string $template) use ($mailbox): ?array { + foreach ($mailbox->sent as $mail) { + if ($mail['to'] === $to && $mail['template'] === $template) { + return $mail['variables']; + } + } + return null; +}; +$dayFor = static fn (string $to, string $ymd): string => $mailService->formatEmailDate($ymd, false, $mailService->resolveRecipientLocale($to)); +// The sweep walks every overdue loan in the database, not only this file's fixtures. +$sweptBefore = $db->query("SELECT id, stato, overdue_notification_sent FROM prestiti WHERE attivo = 1 AND stato IN ('in_corso','in_ritardo')")->fetch_all(MYSQLI_NUM); +try { + $mailService->sendOverdueLoanNotifications(); + $overdueMail = $mailTo($closedEmail, 'loan_overdue_notification'); + $check(($overdueMail['data_scadenza'] ?? null) === $dayFor($closedEmail, $usableDue), 'D1 the overdue e-mail names the first open day after the closed due day'); + $check((int) ($overdueMail['giorni_ritardo'] ?? 0) === \App\Support\LibraryCalendar::forDb($db)->openDaysBetween($usableDue, \App\Support\DateHelper::today()), 'D2 and counts the open days from that day'); + $adminMail = $mailTo($adminEmail, 'loan_overdue_admin'); + $check(($adminMail['data_scadenza'] ?? null) === $dayFor($adminEmail, $usableDue), 'D3 the staff alert names the same day'); + $claimedRow = $db->query('SELECT overdue_notification_sent, data_scadenza FROM prestiti WHERE id = ' . (int) $closedLoan)->fetch_row(); + $check((int) $claimedRow[0] === 1 && (string) $claimedRow[1] === $storedDue, 'D4 the claim matched the stored date, which stays as it was'); + + $db->query('UPDATE prestiti SET last_recall_at = NULL, recall_count = 0 WHERE id = ' . (int) $closedLoan); + $recalled = $mailService->sendManualRecall($closedLoan, 1, 0); + $recallMail = $mailTo($closedEmail, 'loan_recall_notification'); + $check($recalled['success'] === true && ($recallMail['data_scadenza'] ?? null) === $dayFor($closedEmail, $usableDue), 'D5 the recall names the first open day after the closed due day'); + $claimedRow = $db->query('SELECT recall_count, data_scadenza FROM prestiti WHERE id = ' . (int) $closedLoan)->fetch_row(); + $check((int) $claimedRow[0] === 1 && (string) $claimedRow[1] === $storedDue, 'D6 the recall claim matched the stored date, which stays as it was'); + + // The e-mail for a reservation whose book became available quotes the usable end day. + $reservationBook = $makeBook(); + $reservationStart = (new DateTimeImmutable($storedDue))->modify('-10 days')->format('Y-m-d'); + $db->query("INSERT INTO prenotazioni (libro_id, utente_id, data_inizio_richiesta, data_fine_richiesta, stato, notifica_inviata) VALUES ({$reservationBook}, {$closedUser}, '{$reservationStart}', '{$storedDue}', 'attiva', 0)"); + $reservationId = (int) $db->insert_id; + $reservationManager = new class ($db, $mailService) extends \App\Controllers\ReservationManager { + public function __construct(mysqli $db, private \App\Support\NotificationService $fake) + { + parent::__construct($db); + } + protected function notificationService(): \App\Support\NotificationService + { + return $this->fake; + } + }; + (new ReflectionMethod(\App\Controllers\ReservationManager::class, 'sendReservationNotification'))->invoke($reservationManager, [ + 'id' => $reservationId, 'libro_id' => $reservationBook, 'email' => $closedEmail, 'nome' => 'Recall', 'cognome' => 'Reader', + 'data_inizio_richiesta' => $reservationStart, 'data_fine_richiesta' => $storedDue, + ]); + $awaitingMail = $mailTo($closedEmail, 'reservation_awaiting_approval'); + $db->query("DELETE FROM prenotazioni WHERE id = {$reservationId}"); + $check(($awaitingMail['data_fine'] ?? null) === $dayFor($closedEmail, $usableDue), 'D5b the reservation e-mail names the first open day after a closed end day'); + + // No closure on the day: the same e-mail quotes the stored date. + $plainLoan = $makeLoan(daysOverdue: 20 + 5); + $plainEmail = (string) $db->query('SELECT u.email FROM prestiti p JOIN utenti u ON u.id = p.utente_id WHERE p.id = ' . (int) $plainLoan)->fetch_row()[0]; + $plainDue = (string) $db->query('SELECT data_scadenza FROM prestiti WHERE id = ' . (int) $plainLoan)->fetch_row()[0]; + $mailService->sendManualRecall($plainLoan, 1, 0); + $check(($mailTo($plainEmail, 'loan_recall_notification')['data_scadenza'] ?? null) === $dayFor($plainEmail, $plainDue), 'D7 a due day that stayed open is quoted as stored'); +} finally { + $reachable->setValue(null, $reachableBefore); + $restoreSwept = $db->prepare('UPDATE prestiti SET stato = ?, overdue_notification_sent = ? WHERE id = ? AND id NOT IN (?, ?)'); + foreach ($sweptBefore as [$sweptId, $sweptState, $sweptSent]) { + $restoreSwept->bind_param('siiii', $sweptState, $sweptSent, $sweptId, $closedLoan, $closedLoan); + $restoreSwept->execute(); + } + $restoreSwept->close(); + $db->query("DELETE FROM calendario_chiusure WHERE descrizione = '{$closureTag}'"); + $db->query("DELETE FROM email_delivery_outbox WHERE recipient_email LIKE '%{$emailDomain}'"); + if ($featureRow === null) { + $db->query("DELETE FROM system_settings WHERE category='features' AND setting_key='closures_calendar'"); + } else { + $db->query("UPDATE system_settings SET setting_value='" . $db->real_escape_string((string) $featureRow[0]) . "' WHERE category='features' AND setting_key='closures_calendar'"); + } + if ($weekdaysRow === null) { + $db->query("DELETE FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + } else { + $db->query("UPDATE system_settings SET setting_value='" . $db->real_escape_string((string) $weekdaysRow[0]) . "' WHERE category='loans' AND setting_key='closed_weekdays'"); + } + \App\Support\LibraryCalendar::reset(); +} + // ── E. sendLoanReceiptEmail() guards ──────────────────────────────────────── $e = $service->sendLoanReceiptEmail(2000000001); $check($e['success'] === false, 'E1 receipt email for an unknown loan → failure'); diff --git a/tests/loan-reservation-complete.spec.js b/tests/loan-reservation-complete.spec.js index 55b71741a..11bf156be 100644 --- a/tests/loan-reservation-complete.spec.js +++ b/tests/loan-reservation-complete.spec.js @@ -1530,19 +1530,26 @@ test.describe.serial('Loan & Reservation Complete Suite (26 tests)', () => { expect(renewalsAfter).toBe(renewalsBefore + 1); expect(dueDateAfter > dueDate25).toBe(true); - // Also verify that renewing an in_ritardo (overdue) loan is blocked - // by setting stato to in_ritardo then calling renew - dbQuery(`UPDATE prestiti SET stato='in_ritardo' WHERE id=${loanId25}`); + // Also verify that renewing an overdue loan is blocked. Lateness is the + // calendar's (LibraryCalendar::isPastDue()), not the raw status alone: a + // due day closed afterwards stays renewable, so the loan is made really + // late, due yesterday on an open day, not only flagged 'in_ritardo'. + // Dates in the application timezone, as renew() reads "today". + dbQuery(`UPDATE prestiti SET stato='in_ritardo', data_prestito='${dateISO(-15)}', data_scadenza='${dateISO(-1)}' WHERE id=${loanId25}`); await adminPage.goto(`${BASE}/admin/prestiti/${loanId25}`); await adminPage.waitForLoadState('domcontentloaded'); const csrf25b = await getCsrf(adminPage); - const renewRes2 = await adminPage.request.post(`${BASE}/admin/prestiti/rinnova/${loanId25}`, { + // The canonical route (the Italian path answers 308 to it), read without + // following the redirect so the refusal reason is visible. + const renewRes2 = await adminPage.request.post(`${BASE}/admin/loans/renew/${loanId25}`, { form: { csrf_token: csrf25b }, + maxRedirects: 0, }); - // The app redirects to an error URL on failure (returns 302 to ?error=...) - // OR returns a non-success status. Either way, renewals should NOT increase. + // Refused for the right reason: the loan is late, not another error. + expect(renewRes2.status()).toBe(302); + expect(renewRes2.headers()['location'] || '').toContain('error=loan_overdue'); const renewalsOverdue = parseInt(dbQuery(`SELECT renewals FROM prestiti WHERE id=${loanId25}`), 10); expect(renewalsOverdue).toBe(renewalsAfter); // unchanged diff --git a/tests/loan-reservation-consistency.unit.php b/tests/loan-reservation-consistency.unit.php index 6bf95e7e7..96f44e761 100644 --- a/tests/loan-reservation-consistency.unit.php +++ b/tests/loan-reservation-consistency.unit.php @@ -119,7 +119,7 @@ function assertNotContainsText(string $needle, string $haystack, string $message 'canonical next-availability intervals must retain legacy NULL-start reservations' ); assertContainsText("AND p.attivo = 1", $notificationService, 'Automatic loan notifications must ignore closed rows'); -assertContainsText("AND attivo = 1 AND stato = 'in_corso'", $notificationService, 'Expiration-warning claim must revalidate the loan lifecycle'); +assertContainsText("AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')", $notificationService, 'Expiration-warning claim must revalidate the outstanding-loan lifecycle'); $reservationManager = readFileOrFail($root . '/app/Controllers/ReservationManager.php'); assertContainsText( diff --git a/tests/mobile-due-push-alignment.unit.php b/tests/mobile-due-push-alignment.unit.php index 92a6304c7..ab3cb91d9 100644 --- a/tests/mobile-due-push-alignment.unit.php +++ b/tests/mobile-due-push-alignment.unit.php @@ -40,7 +40,9 @@ 'push date boundaries use the application timezone' ); $check( - str_contains($dispatcher, "bind_param('ssi', \$today, \$today, \$days)") + // Due-soon candidates end at the application date + the horizon (the + // calendar then drops the late ones); overdue ones start before it. + str_contains($dispatcher, "bind_param('si', \$today, \$days)") && str_contains($dispatcher, "bind_param('s', \$today)"), 'due and overdue push queries bind the authoritative application date' ); diff --git a/tests/mobile-push-closed-due-date.unit.php b/tests/mobile-push-closed-due-date.unit.php index f781cc326..048c64030 100644 --- a/tests/mobile-push-closed-due-date.unit.php +++ b/tests/mobile-push-closed-due-date.unit.php @@ -21,6 +21,9 @@ use App\Plugins\MobileApi\Push\NullProvider; use App\Plugins\MobileApi\Push\PushDispatcher; +use App\Plugins\MobileApi\Push\PushPayload; +use App\Plugins\MobileApi\Push\PushProvider; +use App\Plugins\MobileApi\Push\PushResult; use App\Support\DateHelper; use App\Support\LibraryCalendar; @@ -70,7 +73,8 @@ // Two loans due yesterday: one on a copy of the branch closed yesterday // (marked after the loan was made), one on a copy of no branch. // A third one, already flipped to 'in_ritardo' before the closure was saved. - foreach (['closed' => $ids['sede'], 'open' => null, 'late' => $ids['sede']] as $kind => $sede) { + // Two more are returned below: one of the closed branch, one of no branch. + foreach (['closed' => $ids['sede'], 'open' => null, 'late' => $ids['sede'], 'back' => $ids['sede'], 'backopen' => null] as $kind => $sede) { $db->query("INSERT INTO libri (titolo, copie_totali, copie_disponibili, stato, created_at, updated_at) VALUES ('Push {$kind} {$run}', 1, 0, 'prestato', NOW(), NOW())"); $bookId = (int) $db->insert_id; $ids['books'][] = $bookId; @@ -89,13 +93,58 @@ return $r instanceof mysqli_result && (int) $r->fetch_row()[0] === 1; }; - (new PushDispatcher($db, new NullProvider()))->dispatch(); + // Exercise delivery too: a dedup claim alone cannot prove which date the + // reader receives. This provider never makes a network request. + $db->query("INSERT INTO mobile_push_subscriptions (user_id, provider, endpoint) VALUES ({$ids['user']}, 'unifiedpush', 'https://push.example.invalid/{$run}')"); + $provider = new class implements PushProvider { + /** @var list */ + public array $payloads = []; + public function name(): string { return 'unifiedpush'; } + public function send(array $subscription, PushPayload $payload): PushResult + { + $this->payloads[] = $payload; + return PushResult::ok(); + } + }; + (new PushDispatcher($db, $provider))->dispatch(); $check(LibraryCalendar::isPastDue($db, $yesterday, null, $today), 'a due date of yesterday on an open day is past due'); $check(!$claimed($ids['loans']['closed']), 'no overdue push for a loan whose due day its branch marked closed afterwards'); $check($claimed($ids['loans']['open']), 'the same loan on an open branch gets the overdue push'); $check(!$claimed($ids['loans']['late']), 'no overdue push either for a loan already marked late before its due day was closed'); + // The due-soon push follows the same calendar as the feed: the loan whose + // due day was closed is due today (first open day) and is announced so, + // the loan that is really late is not "due soon". + // Keyed by the usable day (today), the day the push names. + $dueClaimed = static function (int $loanId, string $day) use ($db, $ids): bool { + $key = $db->real_escape_string("loan_due:{$loanId}:{$day}"); + $r = $db->query("SELECT COUNT(*) FROM mobile_push_log WHERE user_id={$ids['user']} AND event_key='{$key}'"); + return $r instanceof mysqli_result && (int) $r->fetch_row()[0] === 1; + }; + $check($dueClaimed($ids['loans']['closed'], $today) && !$dueClaimed($ids['loans']['closed'], $yesterday), 'a due-soon push for the loan whose due day was closed afterwards, keyed and dated by the usable day (today)'); + $check(!$dueClaimed($ids['loans']['open'], $today) && !$dueClaimed($ids['loans']['open'], $yesterday), 'no due-soon push for a loan already past due'); + $closedPayloads = array_values(array_filter($provider->payloads, static fn (PushPayload $p): bool => + $p->type === 'loan_due' && ($p->data['loan_id'] ?? null) === $ids['loans']['closed'])); + $check(count($closedPayloads) === 1, 'the closed-day loan actually reaches the push provider exactly once'); + $payload = $closedPayloads[0] ?? null; + $check($payload !== null && ($payload->data['effective_due_at'] ?? null) === $today + && ($payload->data['due_at'] ?? null) === $yesterday, 'the delivered push carries the usable return day and retains the stored due date'); + $check($payload !== null && str_contains($payload->body, $today) && !str_contains($payload->body, $yesterday), 'the delivered push text names the first open day, not the closed stored date'); + // Under the calendar lock the usable day is read again: a sweep that saw an + // older calendar (another usable day) does not claim, the next sweep does. + $claimDue = new ReflectionMethod(PushDispatcher::class, 'claimIfDueUnchanged'); + $lateCopy = (int) $db->query("SELECT copia_id FROM prestiti WHERE id={$ids['loans']['late']}")->fetch_row()[0]; + $check($claimDue->invoke(new PushDispatcher($db, new NullProvider()), $ids['user'], "loan_due:{$ids['loans']['late']}:stale", $ids['loans']['late'], $yesterday, $lateCopy, $yesterday) === false, 'no due-soon claim when the usable day changed since the sweep read the calendar'); + $check($claimDue->invoke(new PushDispatcher($db, new NullProvider()), $ids['user'], "loan_due:{$ids['loans']['late']}:current", $ids['loans']['late'], $yesterday, $lateCopy, $today) === true, 'the same claim with the usable day the calendar gives goes through'); + + // A return on the first open day after a closed due day is on time, as the + // fine counts it; the same return with every day open is late. + $repo = new \App\Models\LoanRepository($db); + $flagOf = static fn (int $loanId): string => (string) $db->query("SELECT CONCAT(stato, '|', restituito_in_ritardo) FROM prestiti WHERE id={$loanId}")->fetch_row()[0]; + $check($repo->close($ids['loans']['back']) && $flagOf($ids['loans']['back']) === 'restituito|0', 'returned on the first open day after a closed due day: not flagged late'); + $check($repo->close($ids['loans']['backopen']) && $flagOf($ids['loans']['backopen']) === 'restituito|1', 'the same return with every day open is flagged late'); + // The claim re-reads the loan under the lock: a loan renewed or returned // after the sweep read it gets no overdue push with the stale data. $dispatcher = new PushDispatcher($db, new NullProvider()); @@ -117,6 +166,13 @@ $check(LibraryCalendar::openDaysLate($db, $yesterday, $closedCopy, $today) === 0, 'returned on the first open day after a closed due day: 0 days late'); $check(LibraryCalendar::lateDaysForNotice($db, $yesterday, $closedCopy, $today) === 0, 'a loan not late yet has no days late in a notice'); + // A loan list reads the branches of its page in one query: the primed + // answers are the ones a lookup per copy gives, a missing copy included. + LibraryCalendar::reset(); + LibraryCalendar::primeCopyBranches($db, [$closedCopy, $openCopy, $closedCopy, 2147483000]); + $check(LibraryCalendar::branchOfCopy($db, $closedCopy) === $ids['sede'] && LibraryCalendar::branchOfCopy($db, $openCopy) === null && LibraryCalendar::branchOfCopy($db, 2147483000) === null, 'primed copy branches match the per-copy lookup'); + $check(LibraryCalendar::isPastDue($db, $twoDaysAgo, $closedCopy, $today) && LibraryCalendar::openDaysLate($db, $twoDaysAgo, $closedCopy, $today) === 1, 'the calendar reads the primed branch (the closed day is still skipped)'); + // Today closed for everyone, the loan due yesterday (open): late today, // with no open day counted yet. A notice or a recall says 1 day, not 0. $weekdaysRow = $db->query("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); @@ -154,6 +210,7 @@ $db->query("DELETE FROM sedi WHERE id={$ids['sede']}"); } if ($ids['user'] > 0) { + $db->query("DELETE FROM mobile_push_subscriptions WHERE user_id={$ids['user']}"); $db->query("DELETE FROM utenti WHERE id={$ids['user']}"); } if ($featureBefore === null) { diff --git a/tests/patron-photo.spec.js b/tests/patron-photo.spec.js index 140ce063d..aef3bcb8e 100644 --- a/tests/patron-photo.spec.js +++ b/tests/patron-photo.spec.js @@ -54,6 +54,8 @@ async function login(page, email = ADMIN_EMAIL, password = ADMIN_PASS) { await page.goto(`${BASE}/accedi`); await page.fill('input[name="email"]', email); await page.fill('input[name="password"]', password); + const rejectCookies = page.locator('#silktide-wrapper .reject-all'); + await rejectCookies.click({ timeout: 1500 }).catch(() => {}); await page.locator('button[type="submit"]').click(); await page.waitForURL((u) => u.toString().includes('/admin'), { timeout: 15000 }); } @@ -337,7 +339,9 @@ test.describe.serial('Patron photo (phase 7)', () => { const images = list.trim().split('\n').slice(2).filter((l) => /\bimage\b/.test(l)); expect(images.length).toBeGreaterThanOrEqual(1); expect(text).toContain(code); - expect(text).not.toContain('FW'); // initials placeholder replaced by the photo + // Initials placeholder replaced by the photo. The surname and the card code carry + // this run's random id, which can contain the same two letters. + expect(text.split(RUN).join('')).not.toContain('FW'); }); test('"Carica foto" replaces the photo with a picked file and refuses a non-image', async ({ page }) => { diff --git a/tests/settings-pr-independent-25.unit.php b/tests/settings-pr-independent-25.unit.php new file mode 100644 index 000000000..2f95d92c5 --- /dev/null +++ b/tests/settings-pr-independent-25.unit.php @@ -0,0 +1,1095 @@ + str_starts_with($query, 'SELECT id FROM libri WHERE id = ? FOR UPDATE'), + 'beforeCalendarLock' => str_contains($query, 'GET_LOCK('), + ] as $property => $matches) { + if ($matches && $this->$property !== null) { + $work = $this->$property; + $this->$property = null; + $work(); + } + } + return parent::prepare($query); + } +} + +final class SettingsReview25 +{ + public Review25Db $db; + public mysqli $other; + public mysqli $admin; + public string $sandbox = ''; + public bool $created = false; + public string $today; + public int $operator; + public int $reader; + public int $secondReader; + public int $branchA; + public int $branchB; + private int $serial = 0; + + public function __construct() + { + $root = dirname(__DIR__); + $env = Dotenv\Dotenv::parse((string) @file_get_contents($root . '/.env')); + $source = getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''); + $user = getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''); + if ($source === '' || $user === '') { + throw new RuntimeException('Configure E2E_DB_NAME and E2E_DB_USER (or .env); this suite never silently skips.'); + } + $host = getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? 'localhost'); + $port = (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306)); + $socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? ''); + $pass = getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ''); + $this->admin = new mysqli($host, $user, $pass, '', $port, $socket ?: null); + $prefix = substr(preg_replace('/[^a-zA-Z0-9_]/', '_', $source), 0, 38); + $this->sandbox = $prefix . '_review25_' . bin2hex(random_bytes(5)); + try { + $this->admin->query("CREATE DATABASE `{$this->sandbox}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $this->created = true; + $command = ['mysql', '--host=' . $host, '--port=' . $port, '--user=' . $user]; + if ($socket !== '') { + $command[] = '--socket=' . $socket; + } + $command[] = $this->sandbox; + foreach (['schema.sql', 'data_it_IT.sql', 'triggers.sql'] as $file) { + $process = proc_open($command, [ + 0 => ['file', $root . '/installer/database/' . $file, 'r'], + 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], + ], $pipes, $root, array_merge(getenv(), ['MYSQL_PWD' => $pass])); + if (!is_resource($process)) { + throw new RuntimeException('Cannot start schema import: ' . $file); + } + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + if (proc_close($process) !== 0) { + throw new RuntimeException('Schema import failed: ' . $file . ': ' . $output . $error); + } + } + $args = [$host, $user, $pass, $this->sandbox, $port, $socket ?: null]; + $this->db = new Review25Db(...$args); + $this->other = new mysqli(...$args); + foreach ([$this->db, $this->other] as $connection) { + $connection->set_charset('utf8mb4'); + $connection->query("SET time_zone = '+00:00'"); + $connection->query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $connection->query('SET SESSION innodb_lock_wait_timeout = 1'); + } + $this->today = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d'); + } catch (Throwable $e) { + $this->close(); + throw $e; + } + } + + public function close(): void + { + foreach (['other', 'db'] as $key) { + if (isset($this->$key)) { + try { $this->$key->rollback(); $this->$key->close(); } catch (Throwable) { /* preserve original failure */ } + } + } + if ($this->created) { + $this->admin->query("DROP DATABASE `{$this->sandbox}`"); + $this->created = false; + } + if (isset($this->admin)) { $this->admin->close(); } + } + + public function reset(): void + { + $this->db->beforeBookLock = $this->db->beforeCalendarLock = null; + foreach ([$this->db, $this->other] as $connection) { + $connection->rollback(); + $connection->query('DO RELEASE_ALL_LOCKS()'); + } + $this->db->query('DROP TRIGGER IF EXISTS review25_refuse_due_move'); + $this->db->query('SET FOREIGN_KEY_CHECKS=0'); + try { + foreach (['log_modifiche', 'admin_notifications', 'movimenti_lettore', 'prestiti', 'prenotazioni', 'copie', 'libri', + 'utenti', 'calendario_chiusure', 'festivita_personalizzate', 'regole_prestito', 'system_settings', 'sedi'] as $table) { + $this->db->query("DELETE FROM `{$table}`"); + } + $this->db->query('DELETE FROM tipologie_lettore WHERE predefinita = 0'); + } finally { + $this->db->query('SET FOREIGN_KEY_CHECKS=1'); + } + $this->serial = 0; + $this->operator = $this->patron('admin'); + $this->reader = $this->patron(); + $this->secondReader = $this->patron(); + $this->asAdmin(); + $this->db->query("INSERT INTO sedi (nome) VALUES ('Review branch A')"); + $this->branchA = (int) $this->db->insert_id; + $this->db->query("INSERT INTO sedi (nome) VALUES ('Review branch B')"); + $this->branchB = (int) $this->db->insert_id; + $this->set('app', 'timezone', 'UTC'); + $this->set('system', 'catalogue_mode', '0'); + $this->set('loans', 'closed_weekdays', ''); + // An unreachable local transport prevents accidental external mail delivery. + foreach (['type' => 'smtp', 'smtp_host' => '127.0.0.1', 'smtp_port' => '9', 'smtp_security' => ''] as $key => $value) { + $this->set('email', $key, $value); + } + $this->saveLoans(); + } + + public static function eq(mixed $actual, mixed $expected, string $why): void + { + if ($actual !== $expected) { + throw new RuntimeException($why . '; expected ' . json_encode($expected) . ', got ' . json_encode($actual)); + } + } + + public static function check(bool $condition, string $why): void + { + if (!$condition) { throw new RuntimeException($why); } + } + + public function day(int $offset, ?string $from = null): string + { + return (new DateTimeImmutable($from ?? $this->today, new DateTimeZone('UTC')))->modify(sprintf('%+d days', $offset))->format('Y-m-d'); + } + + public function scalar(string $sql): mixed + { + return $this->db->query($sql)->fetch_row()[0] ?? null; + } + + public function request(string $path, array $body = []): Psr\Http\Message\ServerRequestInterface + { + return (new ServerRequestFactory())->createServerRequest('POST', $path)->withParsedBody($body); + } + + public function set(string $category, string $key, string $value): void + { + (new SettingsRepository($this->db))->set($category, $key, $value); + ConfigStore::clearCache(); LibraryCalendar::reset(); + } + + public function saveLoans(array $extra = []): Response + { + $body = array_replace([ + 'loan_duration_days' => '14', 'pickup_expiry_days' => '3', 'max_renewals' => '3', + 'max_loan_duration_days' => '3650', 'max_active_loans_per_user' => '0', + 'feature_circulation_rules' => '1', 'feature_closures_calendar' => '1', + 'feature_fines' => '1', 'feature_automatic_fines' => '1', 'feature_debt_block' => '1', + 'feature_loss_penalties' => '1', 'feature_consultation_only' => '1', + 'daily_fine' => '0.50', 'fine_grace_days' => '0', 'fine_grace_mode' => 'threshold', + 'debt_block_threshold' => '', 'fine_max_per_loan' => '', 'app_timezone' => 'UTC', + ], $extra); + return (new SettingsController())->updateLoansSettings($this->request('/admin/settings/loans', $body), new Response(), $this->db, false); + } + + public function asAdmin(): void + { + $_SESSION = ['user' => ['id' => $this->operator, 'tipo_utente' => 'admin', 'stato' => 'attivo']]; + } + + public function patron(string $role = 'standard', ?int $type = null): int + { + $n = ++$this->serial; + $this->db->query("INSERT INTO utenti (codice_tessera,nome,cognome,email,password,stato,tipo_utente,email_verificata,privacy_accettata,tipologia_id) + VALUES ('RV25-{$n}','Review','Reader {$n}','review25-{$n}@example.invalid','x','attivo','{$role}',1,1," . ($type ?? 'NULL') . ')'); + return (int) $this->db->insert_id; + } + + public function book(?int $branch = null, string $media = 'libro', int $copies = 1): array + { + $n = ++$this->serial; + $this->db->query("INSERT INTO libri (titolo,tipo_media,copie_totali,copie_disponibili,stato) VALUES ('Review25 book {$n}','{$media}',{$copies},{$copies},'disponibile')"); + $id = (int) $this->db->insert_id; + $ids = []; $codes = []; + for ($i = 0; $i < $copies; $i++) { + $code = "RV25-{$n}-{$i}"; + $this->db->query("INSERT INTO copie (libro_id,numero_inventario,stato,sede_id) VALUES ({$id},'{$code}','disponibile'," . ($branch ?? 'NULL') . ')'); + $ids[] = (int) $this->db->insert_id; $codes[] = $code; + } + return ['id' => $id, 'copies' => $ids, 'codes' => $codes]; + } + + public function loan(array $book, string $due, array $options = []): int + { + $reader = $options['reader'] ?? $this->reader; + $copy = $options['copy'] ?? $book['copies'][0]; + $start = $options['start'] ?? $this->day(-30); + $status = $options['status'] ?? 'in_corso'; + $active = $status === 'restituito' ? 0 : 1; + $returned = isset($options['returned']) ? "'{$options['returned']}'" : 'NULL'; + $this->db->query("INSERT INTO prestiti (libro_id,copia_id,utente_id,data_prestito,data_scadenza,stato,origine,attivo,data_restituzione) + VALUES ({$book['id']},{$copy},{$reader},'{$start}','{$due}','{$status}','diretto',{$active},{$returned})"); + $id = (int) $this->db->insert_id; + if (in_array($status, ['in_corso', 'in_ritardo', 'da_ritirare'], true)) { + $copyState = $status === 'da_ritirare' ? 'prenotato' : 'prestato'; + $this->db->query("UPDATE copie SET stato='{$copyState}' WHERE id={$copy}"); + $this->db->query("UPDATE libri SET stato='prestato', copie_disponibili=(SELECT COUNT(*) FROM copie WHERE libro_id={$book['id']} AND stato='disponibile') WHERE id={$book['id']}"); + } + return $id; + } + + public function closure(string $start, ?string $end = null, ?int $branch = null): int + { + unset($_SESSION['error_message'], $_SESSION['success_message']); + $controller = new LibraryCalendarController(); + $controller->store($this->request('/admin/calendar/closures', [ + 'data_inizio' => $start, 'data_fine' => $end ?? $start, + 'descrizione' => 'Review25 closure', 'sede_id' => $branch === null ? '' : (string) $branch, + ]), new Response(), $this->db); + return $controller->createdId() ?? 0; + } + + public function due(int $id): string + { + return (string) $this->scalar("SELECT data_scadenza FROM prestiti WHERE id={$id}"); + } + + public function events(int $book): int + { + return (int) $this->scalar("SELECT COUNT(*) FROM log_modifiche WHERE tabella='libri' AND record_id={$book} + AND JSON_UNQUOTE(JSON_EXTRACT(dati_nuovi,'$._activity.event'))='loan.due_moved'"); + } + + public function renew(int $id): Response + { + return (new PrestitiController())->renew($this->request('/admin/loans/renew/' . $id), new Response(), $this->db, $id); + } + + public function feed(): array + { + $request = $this->request('/api/v1/me/notifications')->withAttribute('mobile_user', ['id' => $this->reader]); + $response = (new ActionsController($this->db))->notifications($request, new Response()); + self::eq($response->getStatusCode(), 200, 'Notification feed must answer normally'); + return json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)['data']; + } + + public function rules(array $cells): void + { + (new CirculationSettingsController())->saveRules($this->request('/admin/settings/circulation/rules', ['rules' => $cells]), new Response(), $this->db); + } + + public function type(int $duration, int $renewalDays = 14): int + { + $n = ++$this->serial; + $controller = new CirculationSettingsController(); + $controller->createType($this->request('/admin/settings/circulation/types', [ + 'nome' => "Review type {$n}", 'codice' => "rv25-{$n}", 'durata_prestito_giorni' => (string) $duration, + 'giorni_rinnovo' => (string) $renewalDays, 'prenotazioni_abilitate' => '1', + ]), new Response(), $this->db); + $id = $controller->createdTypeId(); + self::check($id !== null, 'Patron type must be created before exercising it'); + return $id; + } + + public function lend(array $book, int $reader): Response + { + return (new PrestitiController())->store($this->request('/admin/loans/store', [ + 'utente_id' => (string) $reader, 'libro_id' => (string) $book['id'], 'copy_code' => $book['codes'][0], + 'data_prestito' => $this->today, 'data_scadenza' => '', 'consegna_immediata' => '1', + 'loan_submission_token' => OneTimeFormToken::issue('loan.create'), + ]), new Response(), $this->db); + } +} + +$cases = [ + '01 Calendar save cannot consume capacity just promised by another operator' => static function (SettingsReview25 $t): void { + $b = $t->book($t->branchA); $due = $t->day(5); $next = $t->day(6); + $loan = $t->loan($b, $due); + $competingWrite = false; + $t->db->beforeBookLock = static function () use ($t, $b, $next, &$competingWrite): void { + $t->other->begin_transaction(); + $t->other->query("SELECT id FROM libri WHERE id={$b['id']} FOR UPDATE")->fetch_all(); + $t->other->query("INSERT INTO prenotazioni (libro_id,utente_id,data_inizio_richiesta,data_fine_richiesta,data_scadenza_prenotazione,stato) + VALUES ({$b['id']},{$t->secondReader},'{$next}','{$next}','{$next} 23:59:59','attiva')"); + $t->other->commit(); + $competingWrite = true; + }; + SettingsReview25::check($t->closure($due, branch: $t->branchA) > 0, 'Calendar save must finish'); + SettingsReview25::check($competingWrite, 'Competing reservation must commit during the calendar save'); + SettingsReview25::eq($t->due($loan), $due, 'The only copy is already promised on the added day'); + + // A material cap pauses a promotable reservation while its reader has + // another loan of that material. A return after the calendar's first + // read makes that reservation eligible again, just like a total cap. + $materialBook = $t->book($t->branchB); + $materialLoan = $t->loan($materialBook, $due); + $otherBook = $t->book(); + $otherLoan = $t->loan($otherBook, $t->day(10), ['reader' => $t->secondReader]); + $t->rules([0 => ['libro' => ['max' => '1']]]); + $t->db->query("INSERT INTO prenotazioni (libro_id,utente_id,data_inizio_richiesta,data_fine_richiesta,data_scadenza_prenotazione,stato) + VALUES ({$materialBook['id']},{$t->secondReader},'{$t->today}','{$next}','{$next} 23:59:59','attiva')"); + $returned = false; + $t->db->beforeBookLock = static function () use ($t, $otherLoan, &$returned): void { + $returned = (new \App\Models\LoanRepository($t->other))->close($otherLoan); + }; + SettingsReview25::check($t->closure($due, branch: $t->branchB) > 0 && $returned, 'Other material loan must return during the calendar save'); + SettingsReview25::eq($t->due($materialLoan), $due, 'A newly eligible material-limited reservation must still hold the only copy'); + + // A payment can similarly make a debt-paused reservation eligible + // after the calendar scan opened its snapshot. + $t->rules([]); + $t->set('loans', 'debt_block_threshold', '1.00'); + $debtBook = $t->book($t->branchA); + $debtLoan = $t->loan($debtBook, $due); + PatronLedger::forDb($t->db)->manualCharge($t->secondReader, 200, 'Review debt', $t->operator); + $t->db->query("INSERT INTO prenotazioni (libro_id,utente_id,data_inizio_richiesta,data_fine_richiesta,data_scadenza_prenotazione,stato) + VALUES ({$debtBook['id']},{$t->secondReader},'{$t->today}','{$next}','{$next} 23:59:59','attiva')"); + $paid = false; + $t->db->beforeBookLock = static function () use ($t, &$paid): void { + PatronLedger::forDb($t->other)->recordPayment($t->secondReader, 200, 'contanti', null, $t->operator); + $paid = PatronLedger::forDb($t->other)->balanceCents($t->secondReader) === 0; + }; + SettingsReview25::check($t->closure($due, $next, $t->branchA) > 0 && $paid, 'Payment must commit during the calendar save'); + SettingsReview25::eq($t->due($debtLoan), $due, 'A reservation re-enabled by a concurrent payment still holds capacity'); + }, + '02 First open day after a retroactively closed due day still permits renewal' => static function (SettingsReview25 $t): void { + $b = $t->book($t->branchA); $loan = $t->loan($b, $t->day(-1)); + SettingsReview25::check($t->closure($t->day(-1), branch: $t->branchA) > 0, 'Closure must save'); + $result = $t->renew($loan); + SettingsReview25::check(!str_contains($result->getHeaderLine('Location'), 'error='), 'An on-time reader must be allowed to renew: ' . $result->getHeaderLine('Location')); + // Counted from the usable return day (today), as for a loan whose due + // date the calendar save moved: the order of the two changes does not + // matter, and a closure longer than the renewal cannot swallow it. + SettingsReview25::eq($t->due($loan), $t->day(14), 'Fourteen-day renewal starts from the first open day after the closed due day'); + + // NCIP is another renewal writer, not a delegate to the web action. + // Both a not-yet-swept loan and an old late flag use the usable day. + $ncip = new \App\Plugins\NcipServer\NcipServerPlugin($t->db, new \App\Support\HookManager($t->db)); + $extendNcip = new ReflectionMethod($ncip, 'extendLoan'); + foreach (['in_corso', 'in_ritardo'] as $state) { + $ncipBook = $t->book($t->branchA); + $ncipLoan = $t->loan($ncipBook, $t->day(-1), ['status' => $state]); + $t->db->query("UPDATE prestiti SET origine='ncip' WHERE id={$ncipLoan}"); + $reason = null; + $date = $extendNcip->invokeArgs($ncip, [$ncipLoan, $ncipBook['id'], $t->reader, &$reason]); + SettingsReview25::eq($date, $t->day(14), "NCIP renewal of {$state} starts from the same first open day as the desk (reason: {$reason})"); + SettingsReview25::eq($t->scalar("SELECT stato FROM prestiti WHERE id={$ncipLoan}"), 'in_corso', 'A successful NCIP renewal clears an obsolete late state'); + } + + // The desk renewal clears an old late flag too: a loan stored as late on + // its first usable return day ends up on time with a future due date, and + // no later job would correct it. + $staleBook = $t->book($t->branchA); + $staleLoan = $t->loan($staleBook, $t->day(-1), ['status' => 'in_ritardo']); + $staleResult = $t->renew($staleLoan); + SettingsReview25::check(!str_contains($staleResult->getHeaderLine('Location'), 'error='), 'A stale late flag does not refuse the renewal: ' . $staleResult->getHeaderLine('Location')); + SettingsReview25::eq([$t->due($staleLoan), $t->scalar("SELECT stato FROM prestiti WHERE id={$staleLoan}")], [$t->day(14), 'in_corso'], 'A desk renewal of a not-yet-late loan clears the late flag'); + + // Refused because another reader's reservation starts tomorrow: the + // "renew until D" offer counts from the same usable day, so it never + // proposes today, a date the renewal bound would then refuse. + $held = $t->book($t->branchA); $heldLoan = $t->loan($held, $t->day(-1)); + $tomorrow = $t->day(1); + $t->db->query("INSERT INTO prenotazioni (libro_id,utente_id,data_inizio_richiesta,data_fine_richiesta,data_scadenza_prenotazione,stato) + VALUES ({$held['id']},{$t->secondReader},'{$tomorrow}','{$t->day(8)}','{$tomorrow} 23:59:59','attiva')"); + $refused = $t->renew($heldLoan)->getHeaderLine('Location'); + SettingsReview25::check(str_contains($refused, 'error=extension_conflicts'), 'A reservation starting tomorrow refuses the renewal: ' . $refused); + parse_str((string) parse_url($refused, PHP_URL_QUERY), $offer); + SettingsReview25::check((int) ($offer['max_extension_days'] ?? 0) === 0, 'No "renew until" offer from the closed stored date: ' . $refused); + SettingsReview25::eq($t->due($heldLoan), $t->day(-1), 'The refused renewal leaves the loan as it was'); + $t->db->query("UPDATE prestiti SET origine='ncip' WHERE id={$heldLoan}"); + $reason = null; + SettingsReview25::eq($extendNcip->invokeArgs($ncip, [$heldLoan, $held['id'], $t->reader, &$reason]), null, 'NCIP also refuses the conflicting renewal'); + $ncipCap = (new ReflectionProperty($ncip, 'renewalCap'))->getValue($ncip); + SettingsReview25::eq($reason, 'no_capacity', 'NCIP must reach the capacity guard rather than incorrectly refuse an on-time reader as overdue'); + SettingsReview25::eq($ncipCap['max_extension_days'] ?? null, 0, 'NCIP never offers renewal until the usable day the reader already has'); + + // A bulk extension counts from the same usable day: a stored due date on + // a closed day whose move did not happen (written straight to the table, + // as a blocked move leaves it) extends from the next open day. + $bulk = $t->book($t->branchA); $bulkLoan = $t->loan($bulk, $t->day(5)); + $t->db->query("INSERT INTO calendario_chiusure (data_inizio,data_fine,descrizione,sede_id,attiva) VALUES ('{$t->day(5)}','{$t->day(5)}','Review25 blocked move',{$t->branchA},1)"); + \App\Support\LibraryCalendar::reset(); + (new PrestitiController())->bulkExtend($t->request('/admin/loans/bulk-extend', ['ids' => [(string) $bulkLoan], 'days' => '7']), new Response(), $t->db); + SettingsReview25::eq($t->due($bulkLoan), $t->day(13), 'Bulk extension of 7 days counts from the first open day after the closed stored date'); + // Its capacity window still starts the day after the STORED date: a + // commitment on a day closed after that date blocks the extension even + // though the extension itself counts from a later open day. + $wide = $t->book($t->branchA); $wideLoan = $t->loan($wide, $t->day(5)); + $t->db->query("INSERT INTO calendario_chiusure (data_inizio,data_fine,descrizione,sede_id,attiva) VALUES ('{$t->day(6)}','{$t->day(6)}','Review25 blocked move 2',{$t->branchA},1)"); + $t->db->query("INSERT INTO prenotazioni (libro_id,utente_id,data_inizio_richiesta,data_fine_richiesta,data_scadenza_prenotazione,stato) + VALUES ({$wide['id']},{$t->secondReader},'{$t->day(6)}','{$t->day(6)}','{$t->day(6)} 23:59:59','attiva')"); + \App\Support\LibraryCalendar::reset(); + $wideResult = (new PrestitiController())->bulkExtend($t->request('/admin/loans/bulk-extend', ['ids' => [(string) $wideLoan], 'days' => '7']), new Response(), $t->db); + SettingsReview25::check(str_contains($wideResult->getHeaderLine('Location'), 'error=bulk_extend_conflict'), 'A commitment inside the closed days after the stored date blocks the bulk extension: ' . $wideResult->getHeaderLine('Location')); + SettingsReview25::eq($t->due($wideLoan), $t->day(5), 'The blocked bulk extension leaves the stored date'); + + // Both controller return paths record the return on the first open day + // after the closed due day as on time; on a branch with no closure the + // same return is late. + $flag = static fn (int $id): string => (string) $t->db->query("SELECT CONCAT(stato,'|',restituito_in_ritardo) FROM prestiti WHERE id={$id}")->fetch_row()[0]; + foreach (['return form' => 'form', 'quick return' => 'quick'] as $label => $path) { + foreach ([[$t->branchA, 'restituito|0', 'on time'], [null, 'restituito|1', 'late']] as [$branch, $expected, $what]) { + $back = $t->loan($t->book($branch), $t->day(-1)); + if ($path === 'form') { + (new PrestitiController())->processReturn($t->request('/admin/loans/return/' . $back, ['stato' => 'restituito']), new Response(), $t->db, $back); + } else { + (new \App\Controllers\LoanApprovalController())->returnLoan($t->request('/admin/loans/return-quick', ['loan_id' => (string) $back]), new Response(), $t->db); + } + SettingsReview25::eq($flag($back), $expected, "The {$label} records a return the day after a due day " . ($branch === null ? 'with every day open' : 'closed afterwards') . " as {$what}"); + } + } + }, + '03 Mobile feed must not call that on-time reader overdue' => static function (SettingsReview25 $t): void { + $loan = $t->loan($t->book($t->branchA), $t->day(-1), ['status' => 'in_ritardo']); + $t->closure($t->day(-1), branch: $t->branchA); + SettingsReview25::eq($t->scalar("SELECT stato FROM prestiti WHERE id={$loan}"), 'in_corso', 'Calendar save reconciles the old late state'); + $overdue = array_values(array_filter($t->feed(), static fn (array $n): bool => $n['type'] === 'loan_overdue')); + SettingsReview25::eq($overdue, [], 'No overdue message on the first usable return day'); + $request = $t->request('/api/v1/me/loans')->withAttribute('mobile_user', ['id' => $t->reader]); + $response = (new ActionsController($t->db))->myLoans($request, new Response()); + $active = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)['data']['active'][0]; + SettingsReview25::eq([$active['due_at'], $active['effective_due_at'], $active['is_overdue']], [$t->day(-1), $t->today, false], 'Android receives stored and usable dates plus authoritative lateness'); + $staff = (new \App\Plugins\MobileApi\Support\StaffLoanReader($t->db))->loan($loan); + SettingsReview25::eq([$staff['effective_due_at'], $staff['is_overdue'], $staff['days_overdue']], [$t->today, false, 0], 'Desk agrees with the reader and the fine calendar'); + SettingsReview25::check(in_array('renew', $staff['actions'], true) && !in_array('recall', $staff['actions'], true), 'Native desk offers renewal, not recall, to an on-time reader'); + SettingsReview25::eq((new \App\Plugins\MobileApi\Support\StaffLoanReader($t->db))->summary()['due_today'], 1, 'The staff dashboard counts the loan due on its first usable return day'); + $dueNotices = array_values(array_filter($t->feed(), static fn (array $n): bool => $n['type'] === 'loan_due')); + SettingsReview25::eq(array_column($dueNotices, 'date'), [$t->today], 'Due-soon notice names the usable day'); + $list = (new \App\Models\LoanRepository($t->db))->listRecent(); + SettingsReview25::eq([$list[0]['effective_due_at'], $list[0]['is_overdue']], [$t->today, false], 'Web-rendered loan list agrees with the mobile presentation'); + $tableResponse = (new \App\Controllers\PrestitiApiController())->list($t->request('/api/prestiti'), new Response(), $t->db); + $tableRow = json_decode((string) $tableResponse->getBody(), true, flags: JSON_THROW_ON_ERROR)['data'][0]; + SettingsReview25::eq([$tableRow['effective_due_at'], $tableRow['is_overdue'], $tableRow['due_attention']], [$t->today, false, true], 'Paged web table uses the same server calendar flags'); + $readerLoan = (new \App\Models\LoanRepository($t->db))->getById($loan); + foreach (['profile/reservations.php', 'user_dashboard/prenotazioni.php'] as $view) { + $html = (static function (string $view, array $activePrestiti): string { + $items = $pastPrestiti = $myReviews = []; + ob_start(); + try { + require dirname(__DIR__) . '/app/Views/' . $view; + return (string) ob_get_contents(); + } finally { + ob_end_clean(); + } + })($view, [$readerLoan]); + SettingsReview25::check(!str_contains($html, '
['id' => $t->reader, 'tipo_utente' => 'standard', 'stato' => 'attivo']]; + $home = (string) (new \App\Controllers\UserDashboardController())->index($t->request('/user/dashboard'), new Response(), $t->db)->getBody(); + $t->asAdmin(); + SettingsReview25::check(!str_contains($home, 'availability-badge expired') && str_contains($home, 'availability-badge expiring'), 'The reader home calls the loan due, not expired, on its first usable return day'); + SettingsReview25::check(str_contains($home, __('Scadenza:') . ' ' . format_date($t->today, false, '/')), 'The reader home shows the usable return day'); + + $closedToday = $t->book($t->branchB); + $closedTodayLoan = $t->loan($closedToday, $t->today); + $t->db->query("INSERT INTO prenotazioni (libro_id,utente_id,data_inizio_richiesta,data_fine_richiesta,data_scadenza_prenotazione,stato) + VALUES ({$closedToday['id']},{$t->secondReader},'{$t->day(1)}','{$t->day(1)}','{$t->day(1)} 23:59:59','attiva')"); + $t->closure($t->today, $t->day(3), $t->branchB); + SettingsReview25::eq($t->due($closedTodayLoan), $t->today, 'The nominally due-today date is genuinely blocked from moving'); + SettingsReview25::eq((new \App\Plugins\MobileApi\Support\StaffLoanReader($t->db))->summary()['due_today'], 1, 'A loan nominally due today on a closed branch is not counted before its usable day'); + + // The e-mail warning follows the same usable day and horizon. A + // future blocked move must not announce the closed nominal date early. + $laterBook = $t->book($t->branchB); + $laterLoan = $t->loan($laterBook, $t->day(1)); + $t->db->query("INSERT INTO prenotazioni (libro_id,utente_id,data_inizio_richiesta,data_fine_richiesta,data_scadenza_prenotazione,stato) + VALUES ({$laterBook['id']},{$t->secondReader},'{$t->day(2)}','{$t->day(2)}','{$t->day(2)} 23:59:59','attiva')"); + $t->closure($t->day(1), $t->day(10), $t->branchB); + SettingsReview25::eq($t->due($laterLoan), $t->day(1), 'The future stored date is genuinely blocked by another reservation'); + $t->set('advanced', 'days_before_expiry_warning', '3'); + $fakeEmail = new class extends \App\Support\EmailService { + public array $sent = []; + public function __construct() {} + public function sendTemplate(string $to, string $templateName, array $variables = [], ?string $locale = null, array $attachments = []): bool + { + $this->sent[] = ['template' => $templateName, 'variables' => $variables]; + return true; + } + }; + $notifications = new \App\Support\NotificationService($t->db); + (new ReflectionProperty($notifications, 'emailService'))->setValue($notifications, $fakeEmail); + $reachable = new ReflectionProperty(\App\Support\Mailer::class, 'smtpReachable'); + $reachableBefore = $reachable->getValue(); + $reachable->setValue(null, true); + try { + $notifications->sendLoanExpirationWarnings(); + $notifications->sendLoanExpirationWarnings(); + } finally { + $reachable->setValue(null, $reachableBefore); + } + $warnings = array_values(array_filter($fakeEmail->sent, static fn (array $mail): bool => $mail['template'] === 'loan_expiring_warning')); + SettingsReview25::eq(count($warnings), 1, 'Exactly the on-time loan due on the first open day gets one expiry e-mail'); + SettingsReview25::eq($warnings[0]['variables']['data_scadenza'] ?? null, (new DateTimeImmutable($t->today))->format('d-m-Y'), 'The expiry e-mail names the first open day'); + SettingsReview25::eq($warnings[0]['variables']['giorni_rimasti'] ?? null, 'oggi', 'The expiry e-mail calls the usable due day today'); + SettingsReview25::eq((int) $t->scalar("SELECT warning_sent FROM prestiti WHERE id={$loan}"), 1, 'The usable-day warning is acknowledged once'); + SettingsReview25::eq((int) $t->scalar("SELECT warning_sent FROM prestiti WHERE id={$laterLoan}"), 0, 'An effective due date outside the warning horizon is not claimed'); + + $eventOf = static function (string $ics, int $id): string { + foreach (explode("BEGIN:VEVENT\r\n", $ics) as $event) { + if (str_contains($event, "UID:loan-{$id}@pinakes\r\n")) { return explode("END:VEVENT\r\n", $event)[0]; } + } + return ''; + }; + $expectedEnd = 'DTEND;VALUE=DATE:' . (new DateTimeImmutable($t->day(1)))->format('Ymd'); + $generator = new \App\Support\IcsGenerator($t->db); + foreach (['single loan' => $generator->generateForLoan($loan) ?? '', + 'library feed' => $generator->generate(), + 'personal feed' => (new \App\Plugins\MobileApi\Support\UserLoanCalendar($t->db))->generate($t->reader, 'Review')] as $surface => $ics) { + $event = $eventOf($ics, $loan); + SettingsReview25::check($event !== '' && str_contains($event, $expectedEnd), "The {$surface} includes the loan through its usable return day"); + SettingsReview25::check(!str_contains($event, '⚠️'), "The {$surface} does not call an on-time loan overdue"); + } + // The closure moved the event's end without touching the row, so a client + // that replaces an event by its SEQUENCE must see a new revision. + [$rowSeq, $rowDue] = $t->db->query("SELECT UNIX_TIMESTAMP(updated_at), data_scadenza FROM prestiti WHERE id={$loan}")->fetch_row(); + $daysMoved = (int) (new DateTimeImmutable((string) $rowDue))->diff(new DateTimeImmutable($t->today))->format('%r%a'); + $personal = $eventOf((new \App\Plugins\MobileApi\Support\UserLoanCalendar($t->db))->generate($t->reader, 'Review'), $loan); + SettingsReview25::check($daysMoved > 0 && str_contains($personal, 'SEQUENCE:' . ((int) $rowSeq + LibraryCalendar::revision($t->db) + $daysMoved) . "\r\n"), 'The personal feed gives the moved event a new revision'); + // A booked loan whose stored end day passed inside the closure still has a usable day. + $bookedLoan = $t->loan($t->book($t->branchA), $t->day(-1), ['status' => 'prenotato']); + $expiredLoan = $t->loan($t->book($t->branchA), $t->day(-5), ['status' => 'prenotato']); + \App\Support\LibraryCalendar::reset(); + $feed = (new \App\Plugins\MobileApi\Support\UserLoanCalendar($t->db))->generate($t->reader, 'Review'); + SettingsReview25::check($eventOf($feed, $bookedLoan) !== '', 'The personal feed keeps a booked loan until its usable return day'); + SettingsReview25::check($eventOf($feed, $expiredLoan) === '', 'The personal feed drops a booked loan whose usable return day has gone by'); + $libraryFeed = (new \App\Support\IcsGenerator($t->db))->generate(); + SettingsReview25::check(str_contains($eventOf($libraryFeed, $bookedLoan), $expectedEnd), 'The library feed keeps a booked loan through its usable return day'); + SettingsReview25::check($eventOf($libraryFeed, $expiredLoan) === '', 'The library feed drops a booked loan whose usable return day has gone by'); + + // A loan whose stored day is closed holds the book through its usable day. + $closedBook = $t->book($t->branchA); + $closureId = $t->closure($t->day(5), $t->day(6), $t->branchA); + \App\Support\LibraryCalendar::reset(); + $heldLoan = $t->loan($closedBook, $t->day(5), ['start' => $t->day(-2)]); + $capacity = new \App\Services\CapacityService($t->db); + SettingsReview25::eq([ + $capacity->occupiedCount($closedBook['id'], $t->day(6), $t->day(6)), + $capacity->occupiedCount($closedBook['id'], $t->day(7), $t->day(7)), + $capacity->occupiedCount($closedBook['id'], $t->day(8), $t->day(8)), + ], [1, 1, 0], 'Book capacity holds a loan through the usable day the reader is told, and frees it after'); + $dayIsFree = static function (string $day) use ($t, $closedBook): bool { + $days = (new \App\Controllers\ReservationsController($t->db))->getBookAvailabilityData($closedBook['id'], $t->today, 30); + return (bool) ($days['by_date'][$day]['available'] ?? false); + }; + SettingsReview25::eq([$dayIsFree($t->day(7)), $dayIsFree($t->day(8))], [false, true], 'The request calendar shows the same days as book capacity'); + $closedOffered = static function (array $book) use ($t): bool { + $data = (new \App\Controllers\ReservationsController($t->db))->getBookAvailabilityData($book['id'], $t->today, 30); + return in_array($t->day(5), (array) ($data['closed_dates'] ?? []), true); + }; + SettingsReview25::check($closedOffered($closedBook), 'The request calendar marks a closed day of the branch holding the book\'s copies'); + SettingsReview25::check(!$closedOffered($t->book()), 'A closed day of one branch stays open for copies with no branch'); + $t->saveLoans(['same_day_handover_after_closure' => '1']); + SettingsReview25::check((new SettingsRepository($t->db))->allowsSameDayHandover(), 'The loans form saves the same-day handover choice'); + $handover = new \App\Services\CapacityService($t->db); + SettingsReview25::eq([ + $handover->occupiedCount($closedBook['id'], $t->day(5), $t->day(5)), + $handover->occupiedCount($closedBook['id'], $t->day(6), $t->day(6)), + $handover->occupiedCount($closedBook['id'], $t->day(7), $t->day(7)), + ], [1, 1, 0], 'With same-day handover on, a reader can start on that open day itself'); + SettingsReview25::check($dayIsFree($t->day(7)), 'The request calendar offers that open day too'); + $t->saveLoans(); + SettingsReview25::check(!(new SettingsRepository($t->db))->allowsSameDayHandover(), 'The loans form turns it off again'); + \App\Support\LibraryCalendar::reset(); + + // A request starts on an open day of the branch that holds the book's copies. + $branchBook = $t->book($t->branchA); + $noBranchBook = $t->book(); + $_SESSION['user'] = ['id' => $t->reader]; + $reservation = (new \App\Controllers\ReservationsController($t->db))->createReservation( + $t->request('/api/libro/' . $branchBook['id'] . '/reservation', ['start_date' => $t->day(5)]), + new Response(), + ['id' => $branchBook['id']] + ); + SettingsReview25::eq( + (string) $t->scalar("SELECT data_prestito FROM prestiti WHERE libro_id={$branchBook['id']} ORDER BY id DESC LIMIT 1"), + $t->day(7), + 'A request on a closed day of the only copy\'s branch starts on its next open day (' . $reservation->getStatusCode() . ')' + ); + SettingsReview25::eq(LibraryCalendar::requestStartDay($t->db, $t->day(5), $noBranchBook['id']), $t->day(5), 'Copies with no branch follow the library as a whole'); + // The return day the page offers past that branch's closure is the one stored. + $endBook = $t->book($t->branchA); + (new \App\Controllers\ReservationsController($t->db))->createReservation( + $t->request('/api/libro/' . $endBook['id'] . '/reservation', ['start_date' => $t->day(1), 'end_date' => $t->day(5)]), + new Response(), + ['id' => $endBook['id']] + ); + SettingsReview25::eq((string) $t->scalar("SELECT data_scadenza FROM prestiti WHERE libro_id={$endBook['id']} ORDER BY id DESC LIMIT 1"), $t->day(7), 'A request ending on a closed day of the copies\' branch ends on its next open day'); + $movedBook = $t->book($t->branchA); + $t->db->query("INSERT INTO prenotazioni (libro_id,utente_id,data_inizio_richiesta,data_fine_richiesta,stato) VALUES ({$movedBook['id']},{$t->reader},'{$t->day(9)}','{$t->day(12)}','attiva')"); + $movedId = (int) $t->db->insert_id; + (new \App\Controllers\UserActionsController())->changeReservationDate($t->request('/reservations/change', ['reservation_id' => (string) $movedId, 'desired_date' => $t->day(5)]), new Response(), $t->db); + SettingsReview25::eq((string) $t->scalar("SELECT data_inizio_richiesta FROM prenotazioni WHERE id={$movedId}"), $t->day(7), 'Moving a reservation onto a closed day of its branch starts it on the next open day'); + + // A reservation turned into a loan is checked on the day that loan ends: + // its requested end falls in the closure, and another reader's begins on the usable day. + $promoBook = $t->book($t->branchA); + $t->db->query("INSERT INTO prenotazioni (libro_id,utente_id,data_inizio_richiesta,data_fine_richiesta,stato,queue_position) VALUES ({$promoBook['id']},{$t->secondReader},'{$t->day(7)}','{$t->day(10)}','attiva',1)"); + $t->db->query("INSERT INTO prenotazioni (libro_id,utente_id,data_inizio_richiesta,data_fine_richiesta,stato,queue_position) VALUES ({$promoBook['id']},{$t->reader},'{$t->today}','{$t->day(5)}','attiva',2)"); + (new \App\Controllers\ReservationManager($t->db))->processBookAvailability($promoBook['id']); + SettingsReview25::eq((int) $t->scalar("SELECT COUNT(*) FROM prestiti WHERE libro_id={$promoBook['id']}"), 0, 'A reservation whose loan would run into the next reader\'s days is not turned into a loan'); + + // Taking the closure away brings the end back, and SEQUENCE still rises. + $t->asAdmin(); + $sequenceOf = static fn (string $event): int => preg_match('/^SEQUENCE:(\d+)/m', $event, $m) === 1 ? (int) $m[1] : 0; + $before = $eventOf((new \App\Plugins\MobileApi\Support\UserLoanCalendar($t->db))->generate($t->reader, 'Review'), $heldLoan); + (new LibraryCalendarController())->delete($t->request('/admin/calendar/closures/' . $closureId), new Response(), $t->db, $closureId); + \App\Support\LibraryCalendar::reset(); + $after = $eventOf((new \App\Plugins\MobileApi\Support\UserLoanCalendar($t->db))->generate($t->reader, 'Review'), $heldLoan); + SettingsReview25::check(str_contains($before, 'DTEND;VALUE=DATE:' . (new DateTimeImmutable($t->day(8)))->format('Ymd')) && str_contains($after, 'DTEND;VALUE=DATE:' . (new DateTimeImmutable($t->day(6)))->format('Ymd')), 'The personal feed\'s end follows the closure both ways'); + SettingsReview25::check($sequenceOf($before) > 0 && $sequenceOf($after) > $sequenceOf($before), 'SEQUENCE rises when removing a closure brings the end back'); + }, + '04 A reader who also missed the first open day really is overdue in the feed' => static function (SettingsReview25 $t): void { + $loan = $t->loan($t->book($t->branchA), $t->day(-3)); + $t->closure($t->day(-3), $t->day(-2), $t->branchA); + (new MaintenanceService($t->db))->updateOverdueLoans(); + $overdue = array_values(array_filter($t->feed(), static fn (array $n): bool => $n['type'] === 'loan_overdue')); + SettingsReview25::eq(array_column($overdue, 'id'), ['overdue:' . $loan], 'Missing yesterday as well must produce exactly one overdue notice'); + SettingsReview25::eq(array_column($overdue, 'date'), [$t->day(-1)], 'Late notice names the first usable return date rather than the closed original day'); + $staff = (new \App\Plugins\MobileApi\Support\StaffLoanReader($t->db))->loan($loan); + SettingsReview25::eq([$staff['is_overdue'], $staff['days_overdue']], [true, 1], 'Desk counts the one open late day, excluding the retroactive closure'); + SettingsReview25::check(in_array('recall', $staff['actions'], true) && !in_array('renew', $staff['actions'], true), 'Genuinely overdue native loans offer recall and refuse renewal'); + + // Staff tiles, buckets and the web filter split the loans out as each + // loan's own is_overdue does: an old late flag on the first usable return + // day is active and due today, a loan past due before the sweep is overdue. + $t->db->query("INSERT INTO calendario_chiusure (data_inizio,data_fine,descrizione,sede_id,attiva) VALUES ('{$t->day(-1)}','{$t->day(-1)}','Review25 retroactive',{$t->branchB},1)"); + LibraryCalendar::reset(); + $stale = $t->loan($t->book($t->branchB), $t->day(-1), ['status' => 'in_ritardo']); + $unswept = $t->loan($t->book(), $t->day(-2)); + // The desk's late list and the status it shows read the same verdict. + $deskPage = (string) (new \App\Controllers\LoanApprovalController())->pendingLoans($t->request('/admin/loans/pending'), new Response(), $t->db)->getBody(); + SettingsReview25::eq(substr_count($deskPage, 'giorni di ritardo'), 2, 'The desk late list holds the two loans is_overdue calls late, not the old late flag on its usable day'); + foreach (['late' => $loan, 'stale flag' => $stale, 'unswept' => $unswept] as $label => $listed) { + SettingsReview25::eq(substr_count($deskPage, "/admin/loans/edit/{$listed}\""), 1, "The desk page lists the {$label} loan exactly once"); + } + $reader = new \App\Plugins\MobileApi\Support\StaffLoanReader($t->db); + $tiles = $reader->summary(); + SettingsReview25::eq([$tiles['active'], $tiles['overdue'], $tiles['due_today']], [1, 2, 1], 'One loan out sits in exactly one of active and overdue, and overdue excludes due today'); + $bucket = static fn (string $name): array => $reader->loans($name, [], 0, 50)['items']; + SettingsReview25::eq(array_map(static fn (array $i): array => [$i['id'], $i['is_overdue']], $bucket('active')), [[$stale, false]], 'The active bucket lists the on-time loan with the old late flag'); + $late = array_map(static fn (array $i): array => [$i['id'], $i['is_overdue']], $bucket('overdue')); + sort($late); + $expectedLate = [[$loan, true], [$unswept, true]]; + sort($expectedLate); + SettingsReview25::eq($late, $expectedLate, 'The overdue bucket lists the loans past due, swept or not'); + $tableIds = static function (string $state) use ($t): array { + $request = $t->request('/api/prestiti')->withQueryParams(['stato_specifico' => $state, 'length' => '50']); + $body = (string) (new \App\Controllers\PrestitiApiController())->list($request, new Response(), $t->db)->getBody(); + $ids = array_column(json_decode($body, true, flags: JSON_THROW_ON_ERROR)['data'], 'id'); + sort($ids); + return $ids; + }; + $expectedIds = [$loan, $unswept]; + sort($expectedIds); + SettingsReview25::eq($tableIds('in_ritardo'), $expectedIds, 'The web late filter agrees with the row flags'); + SettingsReview25::eq($tableIds('in_corso'), [$stale], 'The web in-progress filter agrees with the row flags'); + SettingsReview25::eq($tableIds('prenotato'), [], 'Any other state still filters on the stored value'); + + // The desk page counts open days from the usable day, as the notices do. + $desk = (string) (new \App\Controllers\LoanApprovalController())->pendingLoans($t->request('/admin/loans/pending'), new Response(), $t->db)->getBody(); + SettingsReview25::check(!str_contains($desk, sprintf(__('%d giorni di ritardo'), 3)), 'The desk does not count the closed days as late'); + SettingsReview25::check(str_contains($desk, sprintf(__('%d giorni di ritardo'), 1)), 'The desk says one day late, as the fine counts it, and never zero for a stored late flag'); + SettingsReview25::check(str_contains($desk, __('Scaduto:') . ' ' . format_date($t->day(-1), false, '/')), 'The desk names the first usable return day as the missed one'); + + // A branch closed for a stretch that started before today: the desk and + // the dashboard show the usable day and no "act now" cue before it. + $t->db->query("INSERT INTO sedi (nome) VALUES ('Review branch C')"); + $branchC = (int) $t->db->insert_id; + $t->db->query("INSERT INTO calendario_chiusure (data_inizio,data_fine,descrizione,sede_id,attiva) VALUES ('{$t->day(-1)}','{$t->day(2)}','Review25 stretch',{$branchC},1)"); + LibraryCalendar::reset(); + $closedBook = $t->book($branchC); + $t->loan($closedBook, $t->day(-1)); + $desk = (string) (new \App\Controllers\LoanApprovalController())->pendingLoans($t->request('/admin/loans/pending'), new Response(), $t->db)->getBody(); + SettingsReview25::check(str_contains($desk, sprintf(__('Scade tra %d giorni'), 3)), 'The desk warns three days before the usable return day'); + SettingsReview25::check(str_contains($desk, __('Scadenza:') . ' ' . format_date($t->day(3), false, '/')), 'The desk card shows the usable return day'); + $dashboardRows = (new \App\Models\DashboardStats($t->db))->activeLoans(); + $closedRow = array_values(array_filter($dashboardRows, static fn (array $r): bool => (int) $r['libro_id'] === $closedBook['id']))[0] ?? []; + SettingsReview25::eq([$closedRow['effective_due_at'] ?? null, $closedRow['due_attention'] ?? null, $closedRow['data_scadenza'] ?? null], [$t->day(3), false, $t->day(-1)], 'The dashboard row carries the usable day, no cue, and the stored date'); + $dashboard = (string) (new \App\Controllers\DashboardController())->index($t->request('/admin/dashboard'), new Response(), $t->db)->getBody(); + $closedTitle = (string) $t->scalar("SELECT titolo FROM libri WHERE id={$closedBook['id']}"); + $dueCell = static fn (string $class, string $ymd): string => '/text-sm ' . $class . '">\s*' . preg_quote(format_date($ymd), '/') . '\s*<\/td>/'; + SettingsReview25::check(preg_match($dueCell('text-gray-500', $t->day(3)), $dashboard) === 1 && preg_match($dueCell('text-red-600 font-semibold', $t->day(3)), $dashboard) === 0, 'The dashboard table shows the usable day without the red cue'); + $event = array_values(array_filter((new \App\Models\DashboardStats($t->db))->calendarEvents(), static fn (array $e): bool => $e['title'] === $closedTitle))[0] ?? []; + SettingsReview25::eq([$event['end'] ?? null, $event['status'] ?? null], [$t->day(3), 'in_corso'], 'The dashboard calendar ends the loan on its usable day, on time'); + + // The dashboard's overdue list follows the calendar, not the stored flag. + $overdueIds = static fn (): array => array_map('intval', array_column((new \App\Models\DashboardStats($t->db))->overdueLoans(), 'id')); + $staleLoan = $t->loan($t->book(), $t->day(0)); + $t->db->query("UPDATE prestiti SET stato='in_ritardo' WHERE id={$staleLoan}"); + $unsweptLoan = $t->loan($t->book(), $t->day(-5)); + SettingsReview25::check(!in_array($staleLoan, $overdueIds(), true), 'A loan flagged late but on time is not in the dashboard overdue list'); + SettingsReview25::check(in_array($unsweptLoan, $overdueIds(), true), 'A late loan the sweep has not flagged yet is in the dashboard overdue list'); + $t->db->query("UPDATE prestiti SET attivo=0, stato='restituito', data_restituzione='{$t->day(0)}' WHERE id IN ({$staleLoan}, {$unsweptLoan})"); + + // The book page: an on-time loan is neither red nor flagged in the copies + // table on its due day, a cancelled loan is not red in the history, and a + // loan really past due still is, in both. + $bookPage = static function (array $book) use ($t): string { + return (string) (new \App\Controllers\LibriController())->show($t->request('/admin/books/' . $book['id']), new Response(), $t->db, $book['id'])->getBody(); + }; + $redDate = static fn (string $ymd): string => '/text-red-600 font-semibold">\s*(db->query("UPDATE prestiti SET stato='in_corso' WHERE id={$staleBookLoan}"); + $unsweptBook = $t->book(); + $unsweptBookLoan = $t->loan($unsweptBook, $t->day(-5)); + SettingsReview25::check(preg_match($lateBadge, $bookPage($unsweptBook)) === 1, 'The copy badge calls a late loan late before the sweep flags it'); + $t->db->query("UPDATE prestiti SET attivo=0, stato='restituito', data_restituzione='{$t->day(0)}' WHERE id IN ({$staleBookLoan}, {$unsweptBookLoan})"); + $onTimeBook = $t->book(); + $t->loan($onTimeBook, $t->today); + $t->db->query("INSERT INTO prestiti (libro_id,copia_id,utente_id,data_prestito,data_scadenza,stato,origine,attivo) VALUES ({$onTimeBook['id']},{$onTimeBook['copies'][0]},{$t->secondReader},'{$t->day(-60)}','{$t->day(-40)}','annullato','diretto',0)"); + $page = $bookPage($onTimeBook); + SettingsReview25::check(preg_match($redDate($t->today), $page) === 0, 'A loan due today is not red in the copies table or the history'); + SettingsReview25::check(preg_match($redDate($t->day(-40)), $page) === 0, 'A cancelled loan is not red in the history'); + SettingsReview25::eq(array_map(static fn (array $c): array => [$c['effective_due_at'], $c['is_overdue']], (new \App\Models\CopyRepository($t->db))->getByBookId($onTimeBook['id'])), [[$t->today, false]], 'The copy row carries the calendar facts of its loan'); + $page = $bookPage($closedBook); + SettingsReview25::check(str_contains($page, format_date($t->day(3), false, '/')) && preg_match($redDate($t->day(-1)), $page) === 0, 'A closed due day shows the usable day and no red'); + $lateBook = $t->book(); + $t->loan($lateBook, $t->day(-5)); + SettingsReview25::eq(preg_match_all($redDate($t->day(-5)), $bookPage($lateBook)), 3, 'A loan really past due is red in the panel, the copies table and the history'); + + // With the module off the same split is the plain date comparison. + $t->saveLoans(['feature_closures_calendar' => '0']); + $plain = (new \App\Plugins\MobileApi\Support\StaffLoanReader($t->db))->summary(); + SettingsReview25::eq([$plain['active'], $plain['overdue'], $plain['due_today']], [1, 5, 1], 'No calendar: overdue means a due date before today, whatever the stored flag'); + }, + '05 A branch closure shifts its loan while a second branch keeps its date' => static function (SettingsReview25 $t): void { + $due = $t->day(8); + $a = $t->loan($t->book($t->branchA), $due); $b = $t->loan($t->book($t->branchB), $due); + $t->closure($due, $t->day(9), $t->branchA); + SettingsReview25::eq([$t->due($a), $t->due($b)], [$t->day(10), $due], 'Two-day branch closure must not leak to another branch'); + + // A scheduled loan can move to a replacement copy after the calendar + // scan, before its book lock. Its new branch is still open that day. + $replacement = $t->book($t->branchA, copies: 2); + [$oldCopy, $newCopy] = $replacement['copies']; + $t->db->query("UPDATE copie SET sede_id={$t->branchB} WHERE id={$newCopy}"); + $scheduled = $t->loan($replacement, $t->day(15), ['start' => $t->day(12), 'status' => 'prenotato']); + $t->db->beforeBookLock = static function () use ($t, $oldCopy): void { + $t->other->query("UPDATE copie SET stato='perso' WHERE id={$oldCopy}"); + (new \App\Services\ReservationReassignmentService($t->other))->reassignOnCopyLost($oldCopy); + }; + SettingsReview25::check($t->closure($t->day(15), branch: $t->branchA) > 0, 'Closure must save after the replacement'); + SettingsReview25::eq((int) $t->scalar("SELECT copia_id FROM prestiti WHERE id={$scheduled}"), $newCopy, 'Lost copy must actually be replaced by the open branch copy'); + SettingsReview25::eq($t->due($scheduled), $t->day(15), 'A calendar move must use the replacement copy branch, not the stale scan branch'); + }, + '06 A booked physical copy cannot be extended just because another copy is free' => static function (SettingsReview25 $t): void { + $b = $t->book($t->branchA, copies: 2); $due = $t->day(6); + $loan = $t->loan($b, $due); + $t->loan($b, $t->day(10), ['start' => $t->day(7), 'reader' => $t->secondReader, 'status' => 'prenotato']); + SettingsReview25::check($t->closure($due, branch: $t->branchA) > 0, 'Conflicting extension must not discard the closure'); + SettingsReview25::eq([$t->due($loan), $t->events($b['id'])], [$due, 0], 'Per-copy promise wins even when title-wide capacity is two'); + + // Saving the edit form changes nothing about a date the calendar left in + // place: the form submits the stored date back, closed day or not. + $edit = static function (int $id, string $dueDate) use ($t): string { + $row = $t->db->query("SELECT utente_id, data_prestito FROM prestiti WHERE id={$id}")->fetch_assoc(); + return (new PrestitiController())->update($t->request('/admin/loans/update/' . $id, [ + 'utente_id' => (string) $row['utente_id'], 'data_prestito' => (string) $row['data_prestito'], 'data_scadenza' => $dueDate, + ]), new Response(), $t->db, $id)->getHeaderLine('Location'); + }; + $t->db->query("UPDATE prestiti SET warning_sent=1, recall_count=2 WHERE id={$loan}"); + $sentState = "SELECT CONCAT(warning_sent,'|',recall_count) FROM prestiti WHERE id={$loan}"; + $saved = $edit($loan, $due); + SettingsReview25::check(!str_contains($saved, 'error='), 'An unchanged date on a blocked loan saves: ' . $saved); + SettingsReview25::eq([$t->due($loan), $t->scalar($sentState)], [$due, '1|2'], 'The date and the reminder state stay as they were'); + + // An overdue loan whose past due day was closed afterwards: its other + // fields stay editable, and its date does not move. + $pastBook = $t->book($t->branchA); + $pastLoan = $t->loan($pastBook, $t->day(-3), ['status' => 'in_ritardo']); + $t->db->query("INSERT INTO calendario_chiusure (data_inizio,data_fine,descrizione,sede_id,attiva) VALUES ('{$t->day(-3)}','{$t->day(-3)}','Review25 past',{$t->branchA},1)"); + LibraryCalendar::reset(); + $saved = $edit($pastLoan, $t->day(-3)); + SettingsReview25::check(!str_contains($saved, 'error='), 'An unchanged past date on a closed day saves: ' . $saved); + SettingsReview25::eq($t->due($pastLoan), $t->day(-3), 'The past date stays as stored'); + + // A date actually typed still follows the calendar, and a stale form + // submitting the closed day the calendar already moved past is no change. + $freeLoan = $t->loan($t->book($t->branchA), $t->day(20)); + $t->db->query("INSERT INTO calendario_chiusure (data_inizio,data_fine,descrizione,sede_id,attiva) VALUES ('{$t->day(25)}','{$t->day(25)}','Review25 typed',{$t->branchA},1)"); + LibraryCalendar::reset(); + $saved = $edit($freeLoan, $t->day(25)); + SettingsReview25::check(!str_contains($saved, 'error='), 'A typed closed day saves: ' . $saved); + SettingsReview25::eq($t->due($freeLoan), $t->day(26), 'A typed closed day moves to the next open day'); + $t->db->query("UPDATE prestiti SET warning_sent=1 WHERE id={$freeLoan}"); + $edit($freeLoan, $t->day(25)); + SettingsReview25::eq([$t->due($freeLoan), (int) $t->scalar("SELECT warning_sent FROM prestiti WHERE id={$freeLoan}")], [$t->day(26), 1], 'A stale form naming that closed day changes nothing'); + }, + '07 Capacity promised only on the final added day still blocks the whole extension' => static function (SettingsReview25 $t): void { + $b = $t->book($t->branchA); $due = $t->day(5); $last = $t->day(8); + $loan = $t->loan($b, $due); + $t->db->query("INSERT INTO prenotazioni (libro_id,utente_id,data_inizio_richiesta,data_fine_richiesta,data_scadenza_prenotazione,stato) + VALUES ({$b['id']},{$t->secondReader},'{$last}','{$last}','{$last} 23:59:59','attiva')"); + $t->closure($due, $t->day(7), $t->branchA); + SettingsReview25::eq($t->due($loan), $due, 'Inclusive last-day occupancy must be checked, not just the first added day'); + }, + '08 Adjacent closures extend twice but never add duplicate history on a replay' => static function (SettingsReview25 $t): void { + $b = $t->book($t->branchA); $loan = $t->loan($b, $t->day(4)); + $t->closure($t->day(4), branch: $t->branchA); + $t->closure($t->day(5), $t->day(6), $t->branchA); + $t->closure($t->day(5), $t->day(6), $t->branchA); + SettingsReview25::eq([$t->due($loan), $t->events($b['id'])], [$t->day(7), 2], 'Each actual due-date change gets one history entry'); + }, + '09 Weekly closure plus library holiday plus branch closure form one continuous gap' => static function (SettingsReview25 $t): void { + $sunday = (new DateTimeImmutable($t->day(7), new DateTimeZone('UTC')))->modify('next Sunday')->format('Y-m-d'); + $monday = $t->day(1, $sunday); $tuesday = $t->day(2, $sunday); + (new LibraryCalendarController())->saveWeekdays($t->request('/admin/calendar/weekdays', ['closed_weekdays' => ['0']]), new Response(), $t->db); + (new LibraryCalendarController())->storeCustom($t->request('/admin/calendar/custom', [ + 'nome' => 'Review Monday', 'tipo' => 'fixed', 'fixed_mese' => substr($monday, 5, 2), + 'fixed_giorno' => substr($monday, 8, 2), 'durata_giorni' => '1', 'attiva' => '1', + ]), new Response(), $t->db); + $a = $t->loan($t->book($t->branchA), $sunday); $b = $t->loan($t->book($t->branchB), $sunday); + $t->closure($tuesday, branch: $t->branchA); + SettingsReview25::eq([$t->due($a), $t->due($b)], [$t->day(3, $sunday), $tuesday], 'Sources combine and branch-only Tuesday remains scoped'); + }, + '10 Re-enabling the calendar updates checked-out, ready and scheduled loans together' => static function (SettingsReview25 $t): void { + $t->saveLoans(['feature_closures_calendar' => '0']); $due = $t->day(9); $loans = []; + foreach (['in_corso', 'da_ritirare', 'prenotato'] as $status) { + $loans[] = $t->loan($t->book($t->branchA), $due, ['status' => $status]); + } + $t->closure($due, $t->day(10), $t->branchA); + SettingsReview25::eq(array_map($t->due(...), $loans), [$due, $due, $due], 'Disabled module must not move existing commitments'); + $t->saveLoans(); + SettingsReview25::eq(array_map($t->due(...), $loans), [$t->day(11), $t->day(11), $t->day(11)], 'Activation applies the saved policy to every eligible state'); + }, + '11 Removing a closure never shortens a date already promised to the reader' => static function (SettingsReview25 $t): void { + $b = $t->book($t->branchA); $loan = $t->loan($b, $t->day(4)); + $closure = $t->closure($t->day(4), $t->day(5), $t->branchA); + (new LibraryCalendarController())->delete($t->request('/admin/calendar/closures/' . $closure), new Response(), $t->db, $closure); + $t->saveLoans(['feature_closures_calendar' => '0']); $t->saveLoans(); + SettingsReview25::eq([$t->due($loan), $t->events($b['id'])], [$t->day(6), 1], 'Reopening and toggling must not retract a granted deadline'); + }, + '12 Undoing late status preserves notice counters and never fabricates another recall' => static function (SettingsReview25 $t): void { + $loan = $t->loan($t->book($t->branchA), $t->day(-1), ['status' => 'in_ritardo']); + $t->db->query("UPDATE prestiti SET overdue_notification_sent=1, recall_count=2, last_recall_at='{$t->day(-2)} 12:00:00' WHERE id={$loan}"); + $t->closure($t->day(-1), branch: $t->branchA); + $row = $t->db->query("SELECT stato,overdue_notification_sent,recall_count,last_recall_at FROM prestiti WHERE id={$loan}")->fetch_assoc(); + $row['overdue_notification_sent'] = (int) $row['overdue_notification_sent']; + $row['recall_count'] = (int) $row['recall_count']; + SettingsReview25::eq($row, ['stato' => 'in_corso', 'overdue_notification_sent' => 1, 'recall_count' => 2, 'last_recall_at' => $t->day(-2) . ' 12:00:00'], 'A calendar reconciliation changes only lateness, not sent history'); + }, + '13 A non-overlap database refusal rolls back closure, due date and history together' => static function (SettingsReview25 $t): void { + $b = $t->book($t->branchA); $due = $t->day(5); $loan = $t->loan($b, $due); + $t->db->query("CREATE TRIGGER review25_refuse_due_move BEFORE UPDATE ON prestiti FOR EACH ROW + BEGIN IF NEW.id={$loan} AND NEW.data_scadenza<>OLD.data_scadenza THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='review25 storage refusal'; END IF; END"); + SettingsReview25::eq($t->closure($due, branch: $t->branchA), 0, 'A genuine storage refusal cannot be treated as an expected overlap'); + SettingsReview25::eq([(int) $t->scalar('SELECT COUNT(*) FROM calendario_chiusure'), $t->due($loan), $t->events($b['id'])], [0, $due, 0], 'Failed save is atomic'); + SettingsReview25::check(isset($_SESSION['error_message']), 'Operator must see that the calendar was not saved'); + }, + '14 A book-lock timeout aborts the calendar save and a retry succeeds exactly once' => static function (SettingsReview25 $t): void { + $b = $t->book($t->branchA); $due = $t->day(5); $loan = $t->loan($b, $due); + $t->other->begin_transaction(); $t->other->query("SELECT id FROM libri WHERE id={$b['id']} FOR UPDATE")->fetch_all(); + try { + SettingsReview25::eq($t->closure($due, branch: $t->branchA), 0, 'Blocked save must report failure'); + SettingsReview25::eq([(int) $t->scalar('SELECT COUNT(*) FROM calendario_chiusure'), $t->due($loan)], [0, $due], 'No half-saved closure after timeout'); + } finally { $t->other->rollback(); } + SettingsReview25::check($t->closure($due, branch: $t->branchA) > 0, 'Retry after contention must save'); + SettingsReview25::eq([$t->due($loan), $t->events($b['id'])], [$t->day(6), 1], 'One successful retry moves and records the date once'); + }, + '15 Material override and a branch closure both affect a real desk loan' => static function (SettingsReview25 $t): void { + $type = $t->type(7); $reader = $t->patron(type: $type); $b = $t->book($t->branchA, 'dvd'); + $t->rules([$type => ['dvd' => ['duration' => '9']]]); + $t->closure($t->day(9), $t->day(10), $t->branchA); + $response = $t->lend($b, $reader); + SettingsReview25::check(!str_contains($response->getHeaderLine('Location'), 'error='), 'Desk loan must succeed: ' . $response->getHeaderLine('Location')); + SettingsReview25::eq($t->scalar("SELECT data_scadenza FROM prestiti WHERE libro_id={$b['id']}"), $t->day(11), 'Nine-day material rule crosses the two-day closure'); + }, + '16 Clearing a material duration changes the next real loan back to its patron type' => static function (SettingsReview25 $t): void { + $type = $t->type(7); $reader = $t->patron(type: $type); + $t->rules([$type => ['dvd' => ['duration' => '9']]]); + $t->rules([$type => ['dvd' => ['duration' => '']]]); + $b = $t->book($t->branchA, 'dvd'); $response = $t->lend($b, $reader); + SettingsReview25::check(!str_contains($response->getHeaderLine('Location'), 'error='), 'Inherited desk loan must succeed'); + SettingsReview25::eq($t->scalar("SELECT data_scadenza FROM prestiti WHERE libro_id={$b['id']}"), $t->day(7), 'Empty means inherit seven days, not zero or the old nine'); + }, + '17 Moving a reader to another type changes the renewal of an already open loan' => static function (SettingsReview25 $t): void { + $first = $t->type(7, 4); $second = $t->type(9, 6); $reader = $t->patron(type: $first); + $loan = $t->loan($t->book($t->branchA), $t->day(3), ['reader' => $reader]); + $t->db->query("UPDATE utenti SET tipologia_id={$second} WHERE id={$reader}"); + $response = $t->renew($loan); + SettingsReview25::check(!str_contains($response->getHeaderLine('Location'), 'error='), 'Renewal must use the reader current policy'); + SettingsReview25::eq($t->due($loan), $t->day(9), 'Three remaining days plus the new type six-day renewal'); + }, + '18 Lowering and restoring the renewal ceiling never resets the used renewal count' => static function (SettingsReview25 $t): void { + $loan = $t->loan($t->book($t->branchA), $t->day(3)); + $t->db->query("UPDATE prestiti SET renewals=2 WHERE id={$loan}"); + $t->saveLoans(['max_renewals' => '2']); + SettingsReview25::check(str_contains($t->renew($loan)->getHeaderLine('Location'), 'error=max_renewals'), 'Ceiling equal to usage blocks another renewal'); + SettingsReview25::eq($t->due($loan), $t->day(3), 'Refused renewal must leave the date untouched'); + $t->saveLoans(['max_renewals' => '3']); $t->renew($loan); + SettingsReview25::eq([$t->due($loan), (int) $t->scalar("SELECT renewals FROM prestiti WHERE id={$loan}")], [$t->day(17), 3], 'Restored ceiling permits precisely one further fourteen-day renewal'); + }, + '19 One invalid matrix cell must not partially change another valid material cell' => static function (SettingsReview25 $t): void { + $type = $t->type(7); $reader = $t->patron(type: $type); + $t->rules([$type => ['dvd' => ['duration' => '9'], 'libro' => ['duration' => '8']]]); + $t->rules([$type => ['dvd' => ['duration' => '5'], 'libro' => ['duration' => '-1']]]); + $b = $t->book($t->branchA, 'dvd'); $t->lend($b, $reader); + SettingsReview25::eq($t->scalar("SELECT data_scadenza FROM prestiti WHERE libro_id={$b['id']}"), $t->day(9), 'Rejected matrix save must preserve the original nine-day DVD rule'); + }, + '20 Decimal-comma fine, deducted grace and closed days agree at the actual return' => static function (SettingsReview25 $t): void { + $t->saveLoans(['daily_fine' => '0,75', 'fine_grace_days' => '2', 'fine_grace_mode' => 'deduct']); + $b = $t->book($t->branchA); + $loan = $t->loan($b, '2026-01-10', ['start' => '2026-01-01', 'status' => 'restituito', 'returned' => '2026-01-16']); + $t->closure('2026-01-12', '2026-01-13', $t->branchA); + $ledger = PatronLedger::forDb($t->db); + // Six days after Jan 10, two closed days, two deducted grace days: 2 × 75 cents. + SettingsReview25::eq($ledger->chargeLateReturn($loan, $t->operator), 150, 'Persisted return must charge two billable open days'); + SettingsReview25::eq($ledger->balanceCents($t->reader), 150, 'The reader ledger must contain the same amount'); + + // The fine e-mail states the day the count starts from: due on a day closed + // afterwards, returned five open days after the first open one. + $t->set('loans', \App\Services\FineParameters::SETTING_FINE_EMAIL, '1'); + $finedLoan = $t->loan($t->book($t->branchB), '2026-01-10', ['start' => '2026-01-01', 'status' => 'restituito', 'returned' => '2026-01-16']); + $t->closure('2026-01-10', branch: $t->branchB); + SettingsReview25::check(PatronLedger::forDb($t->db)->chargeLateReturn($finedLoan, $t->operator) > 0, 'The late return is charged'); + $fakeEmail = new class extends \App\Support\EmailService { + public array $sent = []; + public function __construct() {} + public function sendTemplate(string $to, string $templateName, array $variables = [], ?string $locale = null, array $attachments = []): bool + { + $this->sent[] = ['template' => $templateName, 'variables' => $variables]; + return true; + } + }; + $notifications = new \App\Support\NotificationService($t->db); + (new ReflectionProperty($notifications, 'emailService'))->setValue($notifications, $fakeEmail); + $reachable = new ReflectionProperty(\App\Support\Mailer::class, 'smtpReachable'); + $reachableBefore = $reachable->getValue(); + $reachable->setValue(null, true); + try { + SettingsReview25::check($notifications->sendFineChargedNotification($finedLoan), 'The fine e-mail goes out'); + } finally { + $reachable->setValue(null, $reachableBefore); + } + $fineVars = array_values(array_filter($fakeEmail->sent, static fn (array $m): bool => $m['template'] === 'fine_charged'))[0]['variables'] ?? []; + SettingsReview25::eq([$fineVars['data_scadenza'] ?? null, $fineVars['giorni_ritardo'] ?? null], ['11-01-2026', '5'], 'The fine e-mail names the first open day beside the open days counted from it'); + SettingsReview25::eq($t->due($finedLoan), '2026-01-10', 'The stored date of a returned loan does not change'); + }, + '21 An excessive fine rejects the whole loan-settings form before changing durations' => static function (SettingsReview25 $t): void { + // A syntactically valid amount just above the form's 9,999.99 limit. + $t->saveLoans(['loan_duration_days' => '27', 'daily_fine' => '10000.00']); + $b = $t->book($t->branchA); $t->lend($b, $t->reader); + SettingsReview25::eq($t->scalar("SELECT data_scadenza FROM prestiti WHERE libro_id={$b['id']}"), $t->day(14), 'Invalid money must not partially persist the requested 27-day duration'); + SettingsReview25::eq($t->scalar("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='daily_fine'"), '0.50', 'Original daily fine stays saved'); + }, + '22 Capped late fine remains single after retry and after calendar off-on' => static function (SettingsReview25 $t): void { + $t->saveLoans(['daily_fine' => '0.75', 'fine_max_per_loan' => '1.25']); + $loan = $t->loan($t->book($t->branchA), '2026-01-10', ['start' => '2026-01-01', 'status' => 'restituito', 'returned' => '2026-01-16']); + $ledger = PatronLedger::forDb($t->db); + SettingsReview25::eq($ledger->chargeLateReturn($loan, $t->operator), 125, 'Cap limits six late days to 125 cents'); + $t->saveLoans(['daily_fine' => '0.75', 'fine_max_per_loan' => '1.25', 'feature_closures_calendar' => '0']); + $t->saveLoans(['daily_fine' => '0.75', 'fine_max_per_loan' => '1.25']); + SettingsReview25::eq(PatronLedger::forDb($t->db)->chargeLateReturn($loan, $t->operator), 0, 'Retry cannot charge the same returned loan again'); + SettingsReview25::eq($ledger->balanceCents($t->reader), 125, 'Calendar toggles never duplicate a settled charge'); + }, + '23 Debt equal to the threshold is allowed but one cent above is blocked' => static function (SettingsReview25 $t): void { + $t->saveLoans(['debt_block_threshold' => '1.00', 'feature_automatic_fines' => '0']); + $ledger = PatronLedger::forDb($t->db); + $ledger->manualCharge($t->reader, 100, 'Exactly at threshold', $t->operator); + SettingsReview25::eq(PatronLedger::isDebtBlocked($t->db, $t->reader), false, 'Equal debt is not above the configured threshold'); + $ledger->manualCharge($t->reader, 1, 'One more cent', $t->operator); + SettingsReview25::eq(PatronLedger::isDebtBlocked($t->db, $t->reader), true, 'One cent above blocks even with automatic fines off'); + $t->saveLoans(['debt_block_threshold' => '1.00', 'feature_automatic_fines' => '0', 'feature_debt_block' => '0']); + SettingsReview25::eq(PatronLedger::isDebtBlocked($t->db, $t->reader), false, 'Disabling this control releases the block without erasing the debt'); + SettingsReview25::eq($ledger->balanceCents($t->reader), 101, 'The manual ledger remains intact'); + }, + '24 Mixed staff settings POST saves permitted duration but cannot alter money or calendar policy' => static function (SettingsReview25 $t): void { + $staff = $t->patron('staff'); $_SESSION = ['user' => ['id' => $staff, 'tipo_utente' => 'staff', 'stato' => 'attivo']]; + $t->saveLoans(['loan_duration_days' => '19', 'daily_fine' => '7.25', 'feature_closures_calendar' => '0', 'feature_fines' => '0']); + $t->asAdmin(); + $b = $t->book($t->branchA); $t->closure($t->day(19), branch: $t->branchA); $t->lend($b, $t->reader); + SettingsReview25::eq($t->scalar("SELECT data_scadenza FROM prestiti WHERE libro_id={$b['id']}"), $t->day(20), 'Permitted duration changes, protected calendar remains effective'); + SettingsReview25::eq($t->scalar("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='daily_fine'"), '0.50', 'A mixed POST must not bypass money permissions'); + }, + '25 Two operators toggling one holiday leave it in its original state' => static function (SettingsReview25 $t): void { + $date = $t->day(15); $controller = new LibraryCalendarController(); + $controller->storeCustom($t->request('/admin/calendar/custom', [ + 'nome' => 'Review toggle race', 'tipo' => 'fixed', 'fixed_mese' => substr($date, 5, 2), + 'fixed_giorno' => substr($date, 8, 2), 'durata_giorni' => '1', 'attiva' => '1', + ]), new Response(), $t->db); + $id = $controller->createdId(); SettingsReview25::check($id !== null, 'Holiday fixture must be saved'); + $competingToggle = false; + $t->db->beforeCalendarLock = static function () use ($t, $id, &$competingToggle): void { + ConfigStore::usingConnection($t->other, static function () use ($t, $id): void { + (new LibraryCalendarController())->toggleCustom($t->request('/admin/calendar/custom/toggle'), new Response(), $t->other, $id); + }); + $competingToggle = true; + }; + $controller->toggleCustom($t->request('/admin/calendar/custom/toggle'), new Response(), $t->db, $id); + SettingsReview25::check($competingToggle, 'Second operator must really toggle while the first is waiting'); + SettingsReview25::eq((int) $t->scalar("SELECT attiva FROM festivita_personalizzate WHERE id={$id}"), 1, 'Two distinct toggles must not collapse into one stale read'); + $loan = $t->loan($t->book($t->branchA), $date); + $t->closure($t->day(40), branch: $t->branchA); + SettingsReview25::eq($t->due($loan), $t->day(16), 'The holiday must still affect due dates after both toggles'); + }, +]; + +$suite = null; $passed = 0; $failed = 0; $savedSession = $_SESSION ?? []; +try { + SettingsReview25::eq(count($cases), 25, 'Acceptance review must contain exactly 25 independent scenarios'); + $suite = new SettingsReview25(); + ConfigStore::usingConnection($suite->db, static function () use ($suite, $cases, &$passed, &$failed): void { + $selected = array_filter(explode(',', getenv('REVIEW25_CASE') ?: '')); + $ids = array_map(static fn (string $name): string => substr($name, 0, 2), array_keys($cases)); + SettingsReview25::check(array_diff($selected, $ids) === [], 'REVIEW25_CASE must select existing two-digit scenario numbers'); + foreach ($cases as $name => $case) { + if ($selected !== [] && !in_array(substr($name, 0, 2), $selected, true)) { continue; } + try { + $suite->reset(); $case($suite); $passed++; + echo 'PASS ' . $name . PHP_EOL; + } catch (Throwable $e) { + $failed++; + echo 'FAIL ' . $name . ': ' . $e->getMessage() . PHP_EOL; + } + } + }); +} catch (Throwable $e) { + $failed++; fwrite(STDERR, 'FAIL acceptance review setup: ' . $e->getMessage() . PHP_EOL); +} finally { + $_SESSION = $savedSession; + LibraryCalendar::reset(); + if ($suite !== null) { $suite->close(); } +} +echo "Independent settings acceptance: {$passed} passed, {$failed} failed" . PHP_EOL; +exit($failed === 0 ? 0 : 1); diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php index 6acb3bbc0..5af95189d 100644 --- a/vendor/composer/autoload_classmap.php +++ b/vendor/composer/autoload_classmap.php @@ -8,14 +8,18 @@ return array( 'App\\Controllers\\Admin\\CmsAdminController' => $baseDir . '/app/Controllers/Admin/CmsAdminController.php', 'App\\Controllers\\Admin\\LanguagesController' => $baseDir . '/app/Controllers/Admin/LanguagesController.php', + 'App\\Controllers\\Admin\\LibraryCalendarController' => $baseDir . '/app/Controllers/Admin/LibraryCalendarController.php', 'App\\Controllers\\Admin\\MessagesController' => $baseDir . '/app/Controllers/Admin/MessagesController.php', 'App\\Controllers\\Admin\\NotificationsController' => $baseDir . '/app/Controllers/Admin/NotificationsController.php', 'App\\Controllers\\Admin\\RecensioniAdminController' => $baseDir . '/app/Controllers/Admin/RecensioniAdminController.php', 'App\\Controllers\\Admin\\StatsController' => $baseDir . '/app/Controllers/Admin/StatsController.php', + 'App\\Controllers\\AttendanceController' => $baseDir . '/app/Controllers/AttendanceController.php', 'App\\Controllers\\AuthController' => $baseDir . '/app/Controllers/AuthController.php', 'App\\Controllers\\AutoriApiController' => $baseDir . '/app/Controllers/AutoriApiController.php', 'App\\Controllers\\AutoriController' => $baseDir . '/app/Controllers/AutoriController.php', 'App\\Controllers\\BulkEnrichController' => $baseDir . '/app/Controllers/BulkEnrichController.php', + 'App\\Controllers\\CatalogueCardController' => $baseDir . '/app/Controllers/CatalogueCardController.php', + 'App\\Controllers\\CirculationSettingsController' => $baseDir . '/app/Controllers/CirculationSettingsController.php', 'App\\Controllers\\CmsController' => $baseDir . '/app/Controllers/CmsController.php', 'App\\Controllers\\CollaneController' => $baseDir . '/app/Controllers/CollaneController.php', 'App\\Controllers\\CollocazioneController' => $baseDir . '/app/Controllers/CollocazioneController.php', @@ -34,13 +38,18 @@ 'App\\Controllers\\GeneriApiController' => $baseDir . '/app/Controllers/GeneriApiController.php', 'App\\Controllers\\GeneriController' => $baseDir . '/app/Controllers/GeneriController.php', 'App\\Controllers\\ImportHistoryController' => $baseDir . '/app/Controllers/ImportHistoryController.php', + 'App\\Controllers\\InventoryController' => $baseDir . '/app/Controllers/InventoryController.php', 'App\\Controllers\\LanguageController' => $baseDir . '/app/Controllers/LanguageController.php', 'App\\Controllers\\LibraryThingImportController' => $baseDir . '/app/Controllers/LibraryThingImportController.php', 'App\\Controllers\\LibriApiController' => $baseDir . '/app/Controllers/LibriApiController.php', 'App\\Controllers\\LibriController' => $baseDir . '/app/Controllers/LibriController.php', 'App\\Controllers\\LoanApprovalController' => $baseDir . '/app/Controllers/LoanApprovalController.php', 'App\\Controllers\\MaintenanceController' => $baseDir . '/app/Controllers/MaintenanceController.php', + 'App\\Controllers\\MarcExportController' => $baseDir . '/app/Controllers/MarcExportController.php', + 'App\\Controllers\\MarcImportController' => $baseDir . '/app/Controllers/MarcImportController.php', 'App\\Controllers\\PasswordController' => $baseDir . '/app/Controllers/PasswordController.php', + 'App\\Controllers\\PatronCardsController' => $baseDir . '/app/Controllers/PatronCardsController.php', + 'App\\Controllers\\PatronLedgerController' => $baseDir . '/app/Controllers/PatronLedgerController.php', 'App\\Controllers\\PluginController' => $baseDir . '/app/Controllers/PluginController.php', 'App\\Controllers\\PrestitiApiController' => $baseDir . '/app/Controllers/PrestitiApiController.php', 'App\\Controllers\\PrestitiController' => $baseDir . '/app/Controllers/PrestitiController.php', @@ -53,31 +62,41 @@ 'App\\Controllers\\ReservationManager' => $baseDir . '/app/Controllers/ReservationManager.php', 'App\\Controllers\\ReservationsAdminController' => $baseDir . '/app/Controllers/ReservationsAdminController.php', 'App\\Controllers\\ReservationsController' => $baseDir . '/app/Controllers/ReservationsController.php', + 'App\\Controllers\\RolesController' => $baseDir . '/app/Controllers/RolesController.php', 'App\\Controllers\\ScrapeController' => $baseDir . '/app/Controllers/ScrapeController.php', 'App\\Controllers\\SearchController' => $baseDir . '/app/Controllers/SearchController.php', 'App\\Controllers\\SecurityLogsController' => $baseDir . '/app/Controllers/SecurityLogsController.php', 'App\\Controllers\\SeoController' => $baseDir . '/app/Controllers/SeoController.php', 'App\\Controllers\\SettingsController' => $baseDir . '/app/Controllers/SettingsController.php', + 'App\\Controllers\\SuppliersController' => $baseDir . '/app/Controllers/SuppliersController.php', 'App\\Controllers\\ThemeController' => $baseDir . '/app/Controllers/ThemeController.php', 'App\\Controllers\\UpdateController' => $baseDir . '/app/Controllers/UpdateController.php', 'App\\Controllers\\UserActionsController' => $baseDir . '/app/Controllers/UserActionsController.php', 'App\\Controllers\\UserDashboardController' => $baseDir . '/app/Controllers/UserDashboardController.php', + 'App\\Controllers\\UserImportController' => $baseDir . '/app/Controllers/UserImportController.php', 'App\\Controllers\\UserWishlistController' => $baseDir . '/app/Controllers/UserWishlistController.php', 'App\\Controllers\\UsersController' => $baseDir . '/app/Controllers/UsersController.php', 'App\\Controllers\\UtentiApiController' => $baseDir . '/app/Controllers/UtentiApiController.php', 'App\\Middleware\\AdminAuthMiddleware' => $baseDir . '/app/Middleware/AdminAuthMiddleware.php', 'App\\Middleware\\ApiKeyMiddleware' => $baseDir . '/app/Middleware/ApiKeyMiddleware.php', 'App\\Middleware\\AuthMiddleware' => $baseDir . '/app/Middleware/AuthMiddleware.php', + 'App\\Middleware\\BackendPermissionsMiddleware' => $baseDir . '/app/Middleware/BackendPermissionsMiddleware.php', 'App\\Middleware\\BasePathMiddleware' => $baseDir . '/app/Middleware/BasePathMiddleware.php', 'App\\Middleware\\CsrfMiddleware' => $baseDir . '/app/Middleware/CsrfMiddleware.php', + 'App\\Middleware\\FeatureGateMiddleware' => $baseDir . '/app/Middleware/FeatureGateMiddleware.php', + 'App\\Middleware\\LiteSpeedCacheMiddleware' => $baseDir . '/app/Middleware/LiteSpeedCacheMiddleware.php', 'App\\Middleware\\PrivateModeMiddleware' => $baseDir . '/app/Middleware/PrivateModeMiddleware.php', 'App\\Middleware\\RateLimitMiddleware' => $baseDir . '/app/Middleware/RateLimitMiddleware.php', 'App\\Middleware\\RememberMeMiddleware' => $baseDir . '/app/Middleware/RememberMeMiddleware.php', + 'App\\Middleware\\RoutedSessionMiddleware' => $baseDir . '/app/Middleware/RoutedSessionMiddleware.php', + 'App\\Middleware\\SessionRoleRefreshMiddleware' => $baseDir . '/app/Middleware/SessionRoleRefreshMiddleware.php', 'App\\Models\\ApiKeyRepository' => $baseDir . '/app/Models/ApiKeyRepository.php', + 'App\\Models\\AttendanceRepository' => $baseDir . '/app/Models/AttendanceRepository.php', 'App\\Models\\AuthorRepository' => $baseDir . '/app/Models/AuthorRepository.php', 'App\\Models\\BookRepository' => $baseDir . '/app/Models/BookRepository.php', 'App\\Models\\CollocationRepository' => $baseDir . '/app/Models/CollocationRepository.php', 'App\\Models\\CopyRepository' => $baseDir . '/app/Models/CopyRepository.php', + 'App\\Models\\CustomRoleRepository' => $baseDir . '/app/Models/CustomRoleRepository.php', 'App\\Models\\DashboardStats' => $baseDir . '/app/Models/DashboardStats.php', 'App\\Models\\GenereRepository' => $baseDir . '/app/Models/GenereRepository.php', 'App\\Models\\Language' => $baseDir . '/app/Models/Language.php', @@ -85,36 +104,85 @@ 'App\\Models\\PublisherRepository' => $baseDir . '/app/Models/PublisherRepository.php', 'App\\Models\\SeriesRepository' => $baseDir . '/app/Models/SeriesRepository.php', 'App\\Models\\SettingsRepository' => $baseDir . '/app/Models/SettingsRepository.php', + 'App\\Models\\SupplierRepository' => $baseDir . '/app/Models/SupplierRepository.php', 'App\\Models\\TaxonomyRepository' => $baseDir . '/app/Models/TaxonomyRepository.php', 'App\\Models\\UserRepository' => $baseDir . '/app/Models/UserRepository.php', 'App\\Repositories\\RecensioniRepository' => $baseDir . '/app/Repositories/RecensioniRepository.php', + 'App\\Services\\ArticleAuthorService' => $baseDir . '/app/Services/ArticleAuthorService.php', 'App\\Services\\BulkEnrichmentService' => $baseDir . '/app/Services/BulkEnrichmentService.php', 'App\\Services\\CapacityService' => $baseDir . '/app/Services/CapacityService.php', + 'App\\Services\\CirculationPolicy' => $baseDir . '/app/Services/CirculationPolicy.php', + 'App\\Services\\Circulation\\LoanPolicy' => $baseDir . '/app/Services/Circulation/LoanPolicy.php', + 'App\\Services\\Circulation\\PolicyResolver' => $baseDir . '/app/Services/Circulation/PolicyResolver.php', + 'App\\Services\\Circulation\\UserPolicy' => $baseDir . '/app/Services/Circulation/UserPolicy.php', + 'App\\Services\\FineCalculator' => $baseDir . '/app/Services/FineCalculator.php', + 'App\\Services\\FineParameters' => $baseDir . '/app/Services/FineParameters.php', + 'App\\Services\\LoanRequestGate' => $baseDir . '/app/Services/LoanRequestGate.php', + 'App\\Services\\LoanRequestGateResult' => $baseDir . '/app/Services/LoanRequestGateResult.php', + 'App\\Services\\PatronLedger' => $baseDir . '/app/Services/PatronLedger.php', 'App\\Services\\ReservationReassignmentService' => $baseDir . '/app/Services/ReservationReassignmentService.php', + 'App\\Services\\StocktakeService' => $baseDir . '/app/Services/StocktakeService.php', + 'App\\Services\\UnifiedCatalogService' => $baseDir . '/app/Services/UnifiedCatalogService.php', + 'App\\Support\\ActivityLog' => $baseDir . '/app/Support/ActivityLog.php', 'App\\Support\\AuthorName' => $baseDir . '/app/Support/AuthorName.php', 'App\\Support\\AuthorNormalizer' => $baseDir . '/app/Support/AuthorNormalizer.php', 'App\\Support\\AuthorizationHelper' => $baseDir . '/app/Support/AuthorizationHelper.php', 'App\\Support\\BackupManager' => $baseDir . '/app/Support/BackupManager.php', + 'App\\Support\\BookChangeStamp' => $baseDir . '/app/Support/BookChangeStamp.php', + 'App\\Support\\BookCitation' => $baseDir . '/app/Support/BookCitation.php', 'App\\Support\\BookDataMerger' => $baseDir . '/app/Support/BookDataMerger.php', + 'App\\Support\\BookVisibility' => $baseDir . '/app/Support/BookVisibility.php', 'App\\Support\\Branding' => $baseDir . '/app/Support/Branding.php', + 'App\\Support\\BulkFieldEditor' => $baseDir . '/app/Support/BulkFieldEditor.php', 'App\\Support\\BundledPlugins' => $baseDir . '/app/Support/BundledPlugins.php', + 'App\\Support\\CatalogAuthorProjection' => $baseDir . '/app/Support/CatalogAuthorProjection.php', + 'App\\Support\\CatalogHeader' => $baseDir . '/app/Support/CatalogHeader.php', + 'App\\Support\\CatalogSnapshot' => $baseDir . '/app/Support/CatalogSnapshot.php', + 'App\\Support\\CatalogueCard' => $baseDir . '/app/Support/CatalogueCard.php', + 'App\\Support\\CataloguingExtras' => $baseDir . '/app/Support/CataloguingExtras.php', + 'App\\Support\\CitationStyles' => $baseDir . '/app/Support/CitationStyles.php', + 'App\\Support\\ClientIpResolver' => $baseDir . '/app/Support/ClientIpResolver.php', 'App\\Support\\CmsHelper' => $baseDir . '/app/Support/CmsHelper.php', + 'App\\Support\\CmsImageStorage' => $baseDir . '/app/Support/CmsImageStorage.php', 'App\\Support\\ConfigStore' => $baseDir . '/app/Support/ConfigStore.php', + 'App\\Support\\ContainerRuntime' => $baseDir . '/app/Support/ContainerRuntime.php', 'App\\Support\\ContentCache' => $baseDir . '/app/Support/ContentCache.php', 'App\\Support\\ContentSanitizer' => $baseDir . '/app/Support/ContentSanitizer.php', + 'App\\Support\\ContentSecurityPolicy' => $baseDir . '/app/Support/ContentSecurityPolicy.php', 'App\\Support\\ContributorBackfill' => $baseDir . '/app/Support/ContributorBackfill.php', 'App\\Support\\ContributorRoles' => $baseDir . '/app/Support/ContributorRoles.php', 'App\\Support\\ContributorSync' => $baseDir . '/app/Support/ContributorSync.php', + 'App\\Support\\CopyHoldings' => $baseDir . '/app/Support/CopyHoldings.php', + 'App\\Support\\CredentialRevoker' => $baseDir . '/app/Support/CredentialRevoker.php', 'App\\Support\\Csrf' => $baseDir . '/app/Support/Csrf.php', 'App\\Support\\CsrfHelper' => $baseDir . '/app/Support/CsrfHelper.php', 'App\\Support\\Csv' => $baseDir . '/app/Support/Csv.php', 'App\\Support\\DataIntegrity' => $baseDir . '/app/Support/DataIntegrity.php', 'App\\Support\\DateHelper' => $baseDir . '/app/Support/DateHelper.php', + 'App\\Support\\DbAtomic' => $baseDir . '/app/Support/DbAtomic.php', 'App\\Support\\DescriptionText' => $baseDir . '/app/Support/DescriptionText.php', 'App\\Support\\DeweyAutoPopulator' => $baseDir . '/app/Support/DeweyAutoPopulator.php', + 'App\\Support\\DeweyDataFiles' => $baseDir . '/app/Support/DeweyDataFiles.php', + 'App\\Support\\DigitalAccess' => $baseDir . '/app/Support/DigitalAccess.php', + 'App\\Support\\DigitalAttachments' => $baseDir . '/app/Support/DigitalAttachments.php', + 'App\\Support\\DigitalStorage' => $baseDir . '/app/Support/DigitalStorage.php', + 'App\\Support\\EditionStatement' => $baseDir . '/app/Support/EditionStatement.php', 'App\\Support\\EmailLayout' => $baseDir . '/app/Support/EmailLayout.php', + 'App\\Support\\EmailOutboxSchema' => $baseDir . '/app/Support/EmailOutboxSchema.php', 'App\\Support\\EmailService' => $baseDir . '/app/Support/EmailService.php', + 'App\\Support\\ExactSearch' => $baseDir . '/app/Support/ExactSearch.php', + 'App\\Support\\Features' => $baseDir . '/app/Support/Features.php', 'App\\Support\\GenreHelper' => $baseDir . '/app/Support/GenreHelper.php', + 'App\\Support\\GenreTree' => $baseDir . '/app/Support/GenreTree.php', + 'App\\Support\\GndIdentifier' => $baseDir . '/app/Support/GndIdentifier.php', + 'App\\Support\\Holidays\\ChineseLunarCalendar' => $baseDir . '/app/Support/Holidays/ChineseLunarCalendar.php', + 'App\\Support\\Holidays\\CustomHolidays' => $baseDir . '/app/Support/Holidays/CustomHolidays.php', + 'App\\Support\\Holidays\\HolidayCalculator' => $baseDir . '/app/Support/Holidays/HolidayCalculator.php', + 'App\\Support\\Holidays\\HolidayDataset' => $baseDir . '/app/Support/Holidays/HolidayDataset.php', + 'App\\Support\\Holidays\\HolidayPresets' => $baseDir . '/app/Support/Holidays/HolidayPresets.php', + 'App\\Support\\Holidays\\HolidayRules' => $baseDir . '/app/Support/Holidays/HolidayRules.php', + 'App\\Support\\Holidays\\WorldCalendars' => $baseDir . '/app/Support/Holidays/WorldCalendars.php', + 'App\\Support\\HomeTexts' => $baseDir . '/app/Support/HomeTexts.php', 'App\\Support\\HookManager' => $baseDir . '/app/Support/HookManager.php', 'App\\Support\\Hooks' => $baseDir . '/app/Support/Hooks.php', 'App\\Support\\HreflangHelper' => $baseDir . '/app/Support/HreflangHelper.php', @@ -125,36 +193,104 @@ 'App\\Support\\ImportLogger' => $baseDir . '/app/Support/ImportLogger.php', 'App\\Support\\InputValidator' => $baseDir . '/app/Support/InputValidator.php', 'App\\Support\\IsbnFormatter' => $baseDir . '/app/Support/IsbnFormatter.php', + 'App\\Support\\LanguageCodes' => $baseDir . '/app/Support/LanguageCodes.php', + 'App\\Support\\LibraryCalendar' => $baseDir . '/app/Support/LibraryCalendar.php', 'App\\Support\\LibraryThingInstaller' => $baseDir . '/app/Support/LibraryThingInstaller.php', + 'App\\Support\\LiteSpeedCache' => $baseDir . '/app/Support/LiteSpeedCache.php', + 'App\\Support\\LoanCalendarLinks' => $baseDir . '/app/Support/LoanCalendarLinks.php', 'App\\Support\\LoanEligibility' => $baseDir . '/app/Support/LoanEligibility.php', + 'App\\Support\\LoanMultiplicityPolicy' => $baseDir . '/app/Support/LoanMultiplicityPolicy.php', 'App\\Support\\LoanPdfGenerator' => $baseDir . '/app/Support/LoanPdfGenerator.php', + 'App\\Support\\LoanStatistics' => $baseDir . '/app/Support/LoanStatistics.php', + 'App\\Support\\LoanWindow' => $baseDir . '/app/Support/LoanWindow.php', + 'App\\Support\\LocalizedRouteRegistrar' => $baseDir . '/app/Support/LocalizedRouteRegistrar.php', + 'App\\Support\\LocalizedRouteSet' => $baseDir . '/app/Support/LocalizedRouteSet.php', 'App\\Support\\Log' => $baseDir . '/app/Support/Log.php', 'App\\Support\\Mailer' => $baseDir . '/app/Support/Mailer.php', 'App\\Support\\MaintenanceService' => $baseDir . '/app/Support/MaintenanceService.php', + 'App\\Support\\MapEmbed' => $baseDir . '/app/Support/MapEmbed.php', + 'App\\Support\\Marc\\BookMarcBuilder' => $baseDir . '/app/Support/Marc/BookMarcBuilder.php', + 'App\\Support\\Marc\\ContributorSorter' => $baseDir . '/app/Support/Marc/ContributorSorter.php', + 'App\\Support\\Marc\\Danmarc2LibriMapper' => $baseDir . '/app/Support/Marc/Danmarc2LibriMapper.php', + 'App\\Support\\Marc\\Danmarc2Parser' => $baseDir . '/app/Support/Marc/Danmarc2Parser.php', + 'App\\Support\\Marc\\GeneratorStream' => $baseDir . '/app/Support/Marc/GeneratorStream.php', + 'App\\Support\\Marc\\Iso2709Reader' => $baseDir . '/app/Support/Marc/Iso2709Reader.php', + 'App\\Support\\Marc\\Iso2709Writer' => $baseDir . '/app/Support/Marc/Iso2709Writer.php', + 'App\\Support\\Marc\\Marc21LibriMapper' => $baseDir . '/app/Support/Marc/Marc21LibriMapper.php', + 'App\\Support\\Marc\\Marc8Decoder' => $baseDir . '/app/Support/Marc/Marc8Decoder.php', + 'App\\Support\\Marc\\MarcBookData' => $baseDir . '/app/Support/Marc/MarcBookData.php', + 'App\\Support\\Marc\\MarcBookImporter' => $baseDir . '/app/Support/Marc/MarcBookImporter.php', + 'App\\Support\\Marc\\MarcCoverFetcher' => $baseDir . '/app/Support/Marc/MarcCoverFetcher.php', + 'App\\Support\\Marc\\MarcField' => $baseDir . '/app/Support/Marc/MarcField.php', + 'App\\Support\\Marc\\MarcFormat' => $baseDir . '/app/Support/Marc/MarcFormat.php', + 'App\\Support\\Marc\\MarcLanguage' => $baseDir . '/app/Support/Marc/MarcLanguage.php', + 'App\\Support\\Marc\\MarcLibriMapper' => $baseDir . '/app/Support/Marc/MarcLibriMapper.php', + 'App\\Support\\Marc\\MarcMediaType' => $baseDir . '/app/Support/Marc/MarcMediaType.php', + 'App\\Support\\Marc\\MarcParseException' => $baseDir . '/app/Support/Marc/MarcParseException.php', + 'App\\Support\\Marc\\MarcReadError' => $baseDir . '/app/Support/Marc/MarcReadError.php', + 'App\\Support\\Marc\\MarcRecord' => $baseDir . '/app/Support/Marc/MarcRecord.php', + 'App\\Support\\Marc\\MarcXmlReader' => $baseDir . '/app/Support/Marc/MarcXmlReader.php', + 'App\\Support\\Marc\\MarcXmlWriter' => $baseDir . '/app/Support/Marc/MarcXmlWriter.php', + 'App\\Support\\Marc\\RelatorRoles' => $baseDir . '/app/Support/Marc/RelatorRoles.php', + 'App\\Support\\Marc\\UnimarcLibriMapper' => $baseDir . '/app/Support/Marc/UnimarcLibriMapper.php', + 'App\\Support\\Marc\\UnimarcLibriParser' => $baseDir . '/app/Support/Marc/UnimarcLibriParser.php', 'App\\Support\\MediaLabels' => $baseDir . '/app/Support/MediaLabels.php', 'App\\Support\\MergeHelper' => $baseDir . '/app/Support/MergeHelper.php', 'App\\Support\\NotificationService' => $baseDir . '/app/Support/NotificationService.php', + 'App\\Support\\OffsetFileStream' => $baseDir . '/app/Support/OffsetFileStream.php', + 'App\\Support\\OneTimeFormToken' => $baseDir . '/app/Support/OneTimeFormToken.php', + 'App\\Support\\PasswordSetupToken' => $baseDir . '/app/Support/PasswordSetupToken.php', + 'App\\Support\\PatronCardPdf' => $baseDir . '/app/Support/PatronCardPdf.php', + 'App\\Support\\PatronCardSettings' => $baseDir . '/app/Support/PatronCardSettings.php', + 'App\\Support\\PatronPhoto' => $baseDir . '/app/Support/PatronPhoto.php', + 'App\\Support\\PatronTypeAssignment' => $baseDir . '/app/Support/PatronTypeAssignment.php', + 'App\\Support\\PatronTypeImport' => $baseDir . '/app/Support/PatronTypeImport.php', + 'App\\Support\\PaymentReceiptPdf' => $baseDir . '/app/Support/PaymentReceiptPdf.php', + 'App\\Support\\PeriodicalArticlesHint' => $baseDir . '/app/Support/PeriodicalArticlesHint.php', + 'App\\Support\\Permissions' => $baseDir . '/app/Support/Permissions.php', + 'App\\Support\\PhpSecurityAdvisory' => $baseDir . '/app/Support/PhpSecurityAdvisory.php', + 'App\\Support\\PickupDeadlineBackfill' => $baseDir . '/app/Support/PickupDeadlineBackfill.php', + 'App\\Support\\PickupNotificationSchema' => $baseDir . '/app/Support/PickupNotificationSchema.php', 'App\\Support\\PluginManager' => $baseDir . '/app/Support/PluginManager.php', + 'App\\Support\\PrivateUploadsRewriteHeal' => $baseDir . '/app/Support/PrivateUploadsRewriteHeal.php', + 'App\\Support\\PublicationPlace' => $baseDir . '/app/Support/PublicationPlace.php', 'App\\Support\\QueryCache' => $baseDir . '/app/Support/QueryCache.php', + 'App\\Support\\QuickSearchOrder' => $baseDir . '/app/Support/QuickSearchOrder.php', 'App\\Support\\RateLimiter' => $baseDir . '/app/Support/RateLimiter.php', + 'App\\Support\\RdaTypes' => $baseDir . '/app/Support/RdaTypes.php', 'App\\Support\\RefererGuard' => $baseDir . '/app/Support/RefererGuard.php', 'App\\Support\\RegistrationFields' => $baseDir . '/app/Support/RegistrationFields.php', 'App\\Support\\RememberMeService' => $baseDir . '/app/Support/RememberMeService.php', + 'App\\Support\\RememberMeSessionRegistry' => $baseDir . '/app/Support/RememberMeSessionRegistry.php', + 'App\\Support\\RolePresets' => $baseDir . '/app/Support/RolePresets.php', 'App\\Support\\RouteTranslator' => $baseDir . '/app/Support/RouteTranslator.php', 'App\\Support\\SchemaInfo' => $baseDir . '/app/Support/SchemaInfo.php', 'App\\Support\\ScrapingService' => $baseDir . '/app/Support/ScrapingService.php', 'App\\Support\\SearchIndexBuilder' => $baseDir . '/app/Support/SearchIndexBuilder.php', 'App\\Support\\SecureLogger' => $baseDir . '/app/Support/SecureLogger.php', 'App\\Support\\SeriesLabels' => $baseDir . '/app/Support/SeriesLabels.php', + 'App\\Support\\SessionPolicy' => $baseDir . '/app/Support/SessionPolicy.php', + 'App\\Support\\SessionRoleRevalidator' => $baseDir . '/app/Support/SessionRoleRevalidator.php', + 'App\\Support\\SessionRuntime' => $baseDir . '/app/Support/SessionRuntime.php', 'App\\Support\\SettingsEncryption' => $baseDir . '/app/Support/SettingsEncryption.php', 'App\\Support\\SettingsMailTemplates' => $baseDir . '/app/Support/SettingsMailTemplates.php', 'App\\Support\\SharingProviders' => $baseDir . '/app/Support/SharingProviders.php', + 'App\\Support\\SitemapCache' => $baseDir . '/app/Support/SitemapCache.php', 'App\\Support\\SitemapGenerator' => $baseDir . '/app/Support/SitemapGenerator.php', 'App\\Support\\SsrfGuard' => $baseDir . '/app/Support/SsrfGuard.php', + 'App\\Support\\StaleCspHeader' => $baseDir . '/app/Support/StaleCspHeader.php', + 'App\\Support\\StatisticsSections' => $baseDir . '/app/Support/StatisticsSections.php', + 'App\\Support\\StocktakePdfGenerator' => $baseDir . '/app/Support/StocktakePdfGenerator.php', 'App\\Support\\ThemeColorizer' => $baseDir . '/app/Support/ThemeColorizer.php', 'App\\Support\\ThemeManager' => $baseDir . '/app/Support/ThemeManager.php', + 'App\\Support\\TrailingSlash' => $baseDir . '/app/Support/TrailingSlash.php', + 'App\\Support\\TranslationScript' => $baseDir . '/app/Support/TranslationScript.php', + 'App\\Support\\TrustedLink' => $baseDir . '/app/Support/TrustedLink.php', 'App\\Support\\UniqueViolation' => $baseDir . '/app/Support/UniqueViolation.php', 'App\\Support\\Updater' => $baseDir . '/app/Support/Updater.php', + 'App\\Support\\UpdaterPreflightException' => $baseDir . '/app/Support/UpdaterPreflightException.php', + 'App\\Support\\UserImport' => $baseDir . '/app/Support/UserImport.php', + 'App\\Support\\UserRoleAssignment' => $baseDir . '/app/Support/UserRoleAssignment.php', 'App\\Support\\UsersPdfGenerator' => $baseDir . '/app/Support/UsersPdfGenerator.php', 'Attribute' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/Attribute.php', 'Carbon\\AbstractTranslator' => $vendorDir . '/nesbot/carbon/src/Carbon/AbstractTranslator.php', @@ -1093,8 +1229,6 @@ 'Monolog\\Registry' => $vendorDir . '/monolog/monolog/src/Monolog/Registry.php', 'Monolog\\ResettableInterface' => $vendorDir . '/monolog/monolog/src/Monolog/ResettableInterface.php', 'Monolog\\SignalHandler' => $vendorDir . '/monolog/monolog/src/Monolog/SignalHandler.php', - 'Monolog\\Test\\MonologTestCase' => $vendorDir . '/monolog/monolog/src/Monolog/Test/MonologTestCase.php', - 'Monolog\\Test\\TestCase' => $vendorDir . '/monolog/monolog/src/Monolog/Test/TestCase.php', 'Monolog\\Utils' => $vendorDir . '/monolog/monolog/src/Monolog/Utils.php', 'Override' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/Override.php', 'PDF417' => $vendorDir . '/tecnickcom/tcpdf/include/barcodes/pdf417.php', @@ -1254,7 +1388,6 @@ 'Symfony\\Component\\Clock\\MockClock' => $vendorDir . '/symfony/clock/MockClock.php', 'Symfony\\Component\\Clock\\MonotonicClock' => $vendorDir . '/symfony/clock/MonotonicClock.php', 'Symfony\\Component\\Clock\\NativeClock' => $vendorDir . '/symfony/clock/NativeClock.php', - 'Symfony\\Component\\Clock\\Test\\ClockSensitiveTrait' => $vendorDir . '/symfony/clock/Test/ClockSensitiveTrait.php', 'Symfony\\Component\\HtmlSanitizer\\HtmlSanitizer' => $vendorDir . '/symfony/html-sanitizer/HtmlSanitizer.php', 'Symfony\\Component\\HtmlSanitizer\\HtmlSanitizerConfig' => $vendorDir . '/symfony/html-sanitizer/HtmlSanitizerConfig.php', 'Symfony\\Component\\HtmlSanitizer\\HtmlSanitizerInterface' => $vendorDir . '/symfony/html-sanitizer/HtmlSanitizerInterface.php', @@ -1359,10 +1492,6 @@ 'Symfony\\Component\\Translation\\Reader\\TranslationReader' => $vendorDir . '/symfony/translation/Reader/TranslationReader.php', 'Symfony\\Component\\Translation\\Reader\\TranslationReaderInterface' => $vendorDir . '/symfony/translation/Reader/TranslationReaderInterface.php', 'Symfony\\Component\\Translation\\StaticMessage' => $vendorDir . '/symfony/translation/StaticMessage.php', - 'Symfony\\Component\\Translation\\Test\\AbstractProviderFactoryTestCase' => $vendorDir . '/symfony/translation/Test/AbstractProviderFactoryTestCase.php', - 'Symfony\\Component\\Translation\\Test\\IncompleteDsnTestTrait' => $vendorDir . '/symfony/translation/Test/IncompleteDsnTestTrait.php', - 'Symfony\\Component\\Translation\\Test\\ProviderFactoryTestCase' => $vendorDir . '/symfony/translation/Test/ProviderFactoryTestCase.php', - 'Symfony\\Component\\Translation\\Test\\ProviderTestCase' => $vendorDir . '/symfony/translation/Test/ProviderTestCase.php', 'Symfony\\Component\\Translation\\TranslatableMessage' => $vendorDir . '/symfony/translation/TranslatableMessage.php', 'Symfony\\Component\\Translation\\Translator' => $vendorDir . '/symfony/translation/Translator.php', 'Symfony\\Component\\Translation\\TranslatorBag' => $vendorDir . '/symfony/translation/TranslatorBag.php', diff --git a/vendor/composer/autoload_real.php b/vendor/composer/autoload_real.php index 6dd88be7a..568698b37 100644 --- a/vendor/composer/autoload_real.php +++ b/vendor/composer/autoload_real.php @@ -31,7 +31,7 @@ public static function getLoader() require __DIR__ . '/autoload_static.php'; call_user_func(\Composer\Autoload\ComposerStaticInite58358eec498b7b6927cfe671382554c::getInitializer($loader)); - $loader->setApcuPrefix('dfef13bc149f3fb6d102'); + $loader->setApcuPrefix('81a648f40e30a5012509'); $loader->register(true); $filesToLoad = \Composer\Autoload\ComposerStaticInite58358eec498b7b6927cfe671382554c::$files; diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index 8fea0a2b2..90b175a04 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -303,14 +303,18 @@ class ComposerStaticInite58358eec498b7b6927cfe671382554c public static $classMap = array ( 'App\\Controllers\\Admin\\CmsAdminController' => __DIR__ . '/../..' . '/app/Controllers/Admin/CmsAdminController.php', 'App\\Controllers\\Admin\\LanguagesController' => __DIR__ . '/../..' . '/app/Controllers/Admin/LanguagesController.php', + 'App\\Controllers\\Admin\\LibraryCalendarController' => __DIR__ . '/../..' . '/app/Controllers/Admin/LibraryCalendarController.php', 'App\\Controllers\\Admin\\MessagesController' => __DIR__ . '/../..' . '/app/Controllers/Admin/MessagesController.php', 'App\\Controllers\\Admin\\NotificationsController' => __DIR__ . '/../..' . '/app/Controllers/Admin/NotificationsController.php', 'App\\Controllers\\Admin\\RecensioniAdminController' => __DIR__ . '/../..' . '/app/Controllers/Admin/RecensioniAdminController.php', 'App\\Controllers\\Admin\\StatsController' => __DIR__ . '/../..' . '/app/Controllers/Admin/StatsController.php', + 'App\\Controllers\\AttendanceController' => __DIR__ . '/../..' . '/app/Controllers/AttendanceController.php', 'App\\Controllers\\AuthController' => __DIR__ . '/../..' . '/app/Controllers/AuthController.php', 'App\\Controllers\\AutoriApiController' => __DIR__ . '/../..' . '/app/Controllers/AutoriApiController.php', 'App\\Controllers\\AutoriController' => __DIR__ . '/../..' . '/app/Controllers/AutoriController.php', 'App\\Controllers\\BulkEnrichController' => __DIR__ . '/../..' . '/app/Controllers/BulkEnrichController.php', + 'App\\Controllers\\CatalogueCardController' => __DIR__ . '/../..' . '/app/Controllers/CatalogueCardController.php', + 'App\\Controllers\\CirculationSettingsController' => __DIR__ . '/../..' . '/app/Controllers/CirculationSettingsController.php', 'App\\Controllers\\CmsController' => __DIR__ . '/../..' . '/app/Controllers/CmsController.php', 'App\\Controllers\\CollaneController' => __DIR__ . '/../..' . '/app/Controllers/CollaneController.php', 'App\\Controllers\\CollocazioneController' => __DIR__ . '/../..' . '/app/Controllers/CollocazioneController.php', @@ -329,13 +333,18 @@ class ComposerStaticInite58358eec498b7b6927cfe671382554c 'App\\Controllers\\GeneriApiController' => __DIR__ . '/../..' . '/app/Controllers/GeneriApiController.php', 'App\\Controllers\\GeneriController' => __DIR__ . '/../..' . '/app/Controllers/GeneriController.php', 'App\\Controllers\\ImportHistoryController' => __DIR__ . '/../..' . '/app/Controllers/ImportHistoryController.php', + 'App\\Controllers\\InventoryController' => __DIR__ . '/../..' . '/app/Controllers/InventoryController.php', 'App\\Controllers\\LanguageController' => __DIR__ . '/../..' . '/app/Controllers/LanguageController.php', 'App\\Controllers\\LibraryThingImportController' => __DIR__ . '/../..' . '/app/Controllers/LibraryThingImportController.php', 'App\\Controllers\\LibriApiController' => __DIR__ . '/../..' . '/app/Controllers/LibriApiController.php', 'App\\Controllers\\LibriController' => __DIR__ . '/../..' . '/app/Controllers/LibriController.php', 'App\\Controllers\\LoanApprovalController' => __DIR__ . '/../..' . '/app/Controllers/LoanApprovalController.php', 'App\\Controllers\\MaintenanceController' => __DIR__ . '/../..' . '/app/Controllers/MaintenanceController.php', + 'App\\Controllers\\MarcExportController' => __DIR__ . '/../..' . '/app/Controllers/MarcExportController.php', + 'App\\Controllers\\MarcImportController' => __DIR__ . '/../..' . '/app/Controllers/MarcImportController.php', 'App\\Controllers\\PasswordController' => __DIR__ . '/../..' . '/app/Controllers/PasswordController.php', + 'App\\Controllers\\PatronCardsController' => __DIR__ . '/../..' . '/app/Controllers/PatronCardsController.php', + 'App\\Controllers\\PatronLedgerController' => __DIR__ . '/../..' . '/app/Controllers/PatronLedgerController.php', 'App\\Controllers\\PluginController' => __DIR__ . '/../..' . '/app/Controllers/PluginController.php', 'App\\Controllers\\PrestitiApiController' => __DIR__ . '/../..' . '/app/Controllers/PrestitiApiController.php', 'App\\Controllers\\PrestitiController' => __DIR__ . '/../..' . '/app/Controllers/PrestitiController.php', @@ -348,31 +357,41 @@ class ComposerStaticInite58358eec498b7b6927cfe671382554c 'App\\Controllers\\ReservationManager' => __DIR__ . '/../..' . '/app/Controllers/ReservationManager.php', 'App\\Controllers\\ReservationsAdminController' => __DIR__ . '/../..' . '/app/Controllers/ReservationsAdminController.php', 'App\\Controllers\\ReservationsController' => __DIR__ . '/../..' . '/app/Controllers/ReservationsController.php', + 'App\\Controllers\\RolesController' => __DIR__ . '/../..' . '/app/Controllers/RolesController.php', 'App\\Controllers\\ScrapeController' => __DIR__ . '/../..' . '/app/Controllers/ScrapeController.php', 'App\\Controllers\\SearchController' => __DIR__ . '/../..' . '/app/Controllers/SearchController.php', 'App\\Controllers\\SecurityLogsController' => __DIR__ . '/../..' . '/app/Controllers/SecurityLogsController.php', 'App\\Controllers\\SeoController' => __DIR__ . '/../..' . '/app/Controllers/SeoController.php', 'App\\Controllers\\SettingsController' => __DIR__ . '/../..' . '/app/Controllers/SettingsController.php', + 'App\\Controllers\\SuppliersController' => __DIR__ . '/../..' . '/app/Controllers/SuppliersController.php', 'App\\Controllers\\ThemeController' => __DIR__ . '/../..' . '/app/Controllers/ThemeController.php', 'App\\Controllers\\UpdateController' => __DIR__ . '/../..' . '/app/Controllers/UpdateController.php', 'App\\Controllers\\UserActionsController' => __DIR__ . '/../..' . '/app/Controllers/UserActionsController.php', 'App\\Controllers\\UserDashboardController' => __DIR__ . '/../..' . '/app/Controllers/UserDashboardController.php', + 'App\\Controllers\\UserImportController' => __DIR__ . '/../..' . '/app/Controllers/UserImportController.php', 'App\\Controllers\\UserWishlistController' => __DIR__ . '/../..' . '/app/Controllers/UserWishlistController.php', 'App\\Controllers\\UsersController' => __DIR__ . '/../..' . '/app/Controllers/UsersController.php', 'App\\Controllers\\UtentiApiController' => __DIR__ . '/../..' . '/app/Controllers/UtentiApiController.php', 'App\\Middleware\\AdminAuthMiddleware' => __DIR__ . '/../..' . '/app/Middleware/AdminAuthMiddleware.php', 'App\\Middleware\\ApiKeyMiddleware' => __DIR__ . '/../..' . '/app/Middleware/ApiKeyMiddleware.php', 'App\\Middleware\\AuthMiddleware' => __DIR__ . '/../..' . '/app/Middleware/AuthMiddleware.php', + 'App\\Middleware\\BackendPermissionsMiddleware' => __DIR__ . '/../..' . '/app/Middleware/BackendPermissionsMiddleware.php', 'App\\Middleware\\BasePathMiddleware' => __DIR__ . '/../..' . '/app/Middleware/BasePathMiddleware.php', 'App\\Middleware\\CsrfMiddleware' => __DIR__ . '/../..' . '/app/Middleware/CsrfMiddleware.php', + 'App\\Middleware\\FeatureGateMiddleware' => __DIR__ . '/../..' . '/app/Middleware/FeatureGateMiddleware.php', + 'App\\Middleware\\LiteSpeedCacheMiddleware' => __DIR__ . '/../..' . '/app/Middleware/LiteSpeedCacheMiddleware.php', 'App\\Middleware\\PrivateModeMiddleware' => __DIR__ . '/../..' . '/app/Middleware/PrivateModeMiddleware.php', 'App\\Middleware\\RateLimitMiddleware' => __DIR__ . '/../..' . '/app/Middleware/RateLimitMiddleware.php', 'App\\Middleware\\RememberMeMiddleware' => __DIR__ . '/../..' . '/app/Middleware/RememberMeMiddleware.php', + 'App\\Middleware\\RoutedSessionMiddleware' => __DIR__ . '/../..' . '/app/Middleware/RoutedSessionMiddleware.php', + 'App\\Middleware\\SessionRoleRefreshMiddleware' => __DIR__ . '/../..' . '/app/Middleware/SessionRoleRefreshMiddleware.php', 'App\\Models\\ApiKeyRepository' => __DIR__ . '/../..' . '/app/Models/ApiKeyRepository.php', + 'App\\Models\\AttendanceRepository' => __DIR__ . '/../..' . '/app/Models/AttendanceRepository.php', 'App\\Models\\AuthorRepository' => __DIR__ . '/../..' . '/app/Models/AuthorRepository.php', 'App\\Models\\BookRepository' => __DIR__ . '/../..' . '/app/Models/BookRepository.php', 'App\\Models\\CollocationRepository' => __DIR__ . '/../..' . '/app/Models/CollocationRepository.php', 'App\\Models\\CopyRepository' => __DIR__ . '/../..' . '/app/Models/CopyRepository.php', + 'App\\Models\\CustomRoleRepository' => __DIR__ . '/../..' . '/app/Models/CustomRoleRepository.php', 'App\\Models\\DashboardStats' => __DIR__ . '/../..' . '/app/Models/DashboardStats.php', 'App\\Models\\GenereRepository' => __DIR__ . '/../..' . '/app/Models/GenereRepository.php', 'App\\Models\\Language' => __DIR__ . '/../..' . '/app/Models/Language.php', @@ -380,36 +399,85 @@ class ComposerStaticInite58358eec498b7b6927cfe671382554c 'App\\Models\\PublisherRepository' => __DIR__ . '/../..' . '/app/Models/PublisherRepository.php', 'App\\Models\\SeriesRepository' => __DIR__ . '/../..' . '/app/Models/SeriesRepository.php', 'App\\Models\\SettingsRepository' => __DIR__ . '/../..' . '/app/Models/SettingsRepository.php', + 'App\\Models\\SupplierRepository' => __DIR__ . '/../..' . '/app/Models/SupplierRepository.php', 'App\\Models\\TaxonomyRepository' => __DIR__ . '/../..' . '/app/Models/TaxonomyRepository.php', 'App\\Models\\UserRepository' => __DIR__ . '/../..' . '/app/Models/UserRepository.php', 'App\\Repositories\\RecensioniRepository' => __DIR__ . '/../..' . '/app/Repositories/RecensioniRepository.php', + 'App\\Services\\ArticleAuthorService' => __DIR__ . '/../..' . '/app/Services/ArticleAuthorService.php', 'App\\Services\\BulkEnrichmentService' => __DIR__ . '/../..' . '/app/Services/BulkEnrichmentService.php', 'App\\Services\\CapacityService' => __DIR__ . '/../..' . '/app/Services/CapacityService.php', + 'App\\Services\\CirculationPolicy' => __DIR__ . '/../..' . '/app/Services/CirculationPolicy.php', + 'App\\Services\\Circulation\\LoanPolicy' => __DIR__ . '/../..' . '/app/Services/Circulation/LoanPolicy.php', + 'App\\Services\\Circulation\\PolicyResolver' => __DIR__ . '/../..' . '/app/Services/Circulation/PolicyResolver.php', + 'App\\Services\\Circulation\\UserPolicy' => __DIR__ . '/../..' . '/app/Services/Circulation/UserPolicy.php', + 'App\\Services\\FineCalculator' => __DIR__ . '/../..' . '/app/Services/FineCalculator.php', + 'App\\Services\\FineParameters' => __DIR__ . '/../..' . '/app/Services/FineParameters.php', + 'App\\Services\\LoanRequestGate' => __DIR__ . '/../..' . '/app/Services/LoanRequestGate.php', + 'App\\Services\\LoanRequestGateResult' => __DIR__ . '/../..' . '/app/Services/LoanRequestGateResult.php', + 'App\\Services\\PatronLedger' => __DIR__ . '/../..' . '/app/Services/PatronLedger.php', 'App\\Services\\ReservationReassignmentService' => __DIR__ . '/../..' . '/app/Services/ReservationReassignmentService.php', + 'App\\Services\\StocktakeService' => __DIR__ . '/../..' . '/app/Services/StocktakeService.php', + 'App\\Services\\UnifiedCatalogService' => __DIR__ . '/../..' . '/app/Services/UnifiedCatalogService.php', + 'App\\Support\\ActivityLog' => __DIR__ . '/../..' . '/app/Support/ActivityLog.php', 'App\\Support\\AuthorName' => __DIR__ . '/../..' . '/app/Support/AuthorName.php', 'App\\Support\\AuthorNormalizer' => __DIR__ . '/../..' . '/app/Support/AuthorNormalizer.php', 'App\\Support\\AuthorizationHelper' => __DIR__ . '/../..' . '/app/Support/AuthorizationHelper.php', 'App\\Support\\BackupManager' => __DIR__ . '/../..' . '/app/Support/BackupManager.php', + 'App\\Support\\BookChangeStamp' => __DIR__ . '/../..' . '/app/Support/BookChangeStamp.php', + 'App\\Support\\BookCitation' => __DIR__ . '/../..' . '/app/Support/BookCitation.php', 'App\\Support\\BookDataMerger' => __DIR__ . '/../..' . '/app/Support/BookDataMerger.php', + 'App\\Support\\BookVisibility' => __DIR__ . '/../..' . '/app/Support/BookVisibility.php', 'App\\Support\\Branding' => __DIR__ . '/../..' . '/app/Support/Branding.php', + 'App\\Support\\BulkFieldEditor' => __DIR__ . '/../..' . '/app/Support/BulkFieldEditor.php', 'App\\Support\\BundledPlugins' => __DIR__ . '/../..' . '/app/Support/BundledPlugins.php', + 'App\\Support\\CatalogAuthorProjection' => __DIR__ . '/../..' . '/app/Support/CatalogAuthorProjection.php', + 'App\\Support\\CatalogHeader' => __DIR__ . '/../..' . '/app/Support/CatalogHeader.php', + 'App\\Support\\CatalogSnapshot' => __DIR__ . '/../..' . '/app/Support/CatalogSnapshot.php', + 'App\\Support\\CatalogueCard' => __DIR__ . '/../..' . '/app/Support/CatalogueCard.php', + 'App\\Support\\CataloguingExtras' => __DIR__ . '/../..' . '/app/Support/CataloguingExtras.php', + 'App\\Support\\CitationStyles' => __DIR__ . '/../..' . '/app/Support/CitationStyles.php', + 'App\\Support\\ClientIpResolver' => __DIR__ . '/../..' . '/app/Support/ClientIpResolver.php', 'App\\Support\\CmsHelper' => __DIR__ . '/../..' . '/app/Support/CmsHelper.php', + 'App\\Support\\CmsImageStorage' => __DIR__ . '/../..' . '/app/Support/CmsImageStorage.php', 'App\\Support\\ConfigStore' => __DIR__ . '/../..' . '/app/Support/ConfigStore.php', + 'App\\Support\\ContainerRuntime' => __DIR__ . '/../..' . '/app/Support/ContainerRuntime.php', 'App\\Support\\ContentCache' => __DIR__ . '/../..' . '/app/Support/ContentCache.php', 'App\\Support\\ContentSanitizer' => __DIR__ . '/../..' . '/app/Support/ContentSanitizer.php', + 'App\\Support\\ContentSecurityPolicy' => __DIR__ . '/../..' . '/app/Support/ContentSecurityPolicy.php', 'App\\Support\\ContributorBackfill' => __DIR__ . '/../..' . '/app/Support/ContributorBackfill.php', 'App\\Support\\ContributorRoles' => __DIR__ . '/../..' . '/app/Support/ContributorRoles.php', 'App\\Support\\ContributorSync' => __DIR__ . '/../..' . '/app/Support/ContributorSync.php', + 'App\\Support\\CopyHoldings' => __DIR__ . '/../..' . '/app/Support/CopyHoldings.php', + 'App\\Support\\CredentialRevoker' => __DIR__ . '/../..' . '/app/Support/CredentialRevoker.php', 'App\\Support\\Csrf' => __DIR__ . '/../..' . '/app/Support/Csrf.php', 'App\\Support\\CsrfHelper' => __DIR__ . '/../..' . '/app/Support/CsrfHelper.php', 'App\\Support\\Csv' => __DIR__ . '/../..' . '/app/Support/Csv.php', 'App\\Support\\DataIntegrity' => __DIR__ . '/../..' . '/app/Support/DataIntegrity.php', 'App\\Support\\DateHelper' => __DIR__ . '/../..' . '/app/Support/DateHelper.php', + 'App\\Support\\DbAtomic' => __DIR__ . '/../..' . '/app/Support/DbAtomic.php', 'App\\Support\\DescriptionText' => __DIR__ . '/../..' . '/app/Support/DescriptionText.php', 'App\\Support\\DeweyAutoPopulator' => __DIR__ . '/../..' . '/app/Support/DeweyAutoPopulator.php', + 'App\\Support\\DeweyDataFiles' => __DIR__ . '/../..' . '/app/Support/DeweyDataFiles.php', + 'App\\Support\\DigitalAccess' => __DIR__ . '/../..' . '/app/Support/DigitalAccess.php', + 'App\\Support\\DigitalAttachments' => __DIR__ . '/../..' . '/app/Support/DigitalAttachments.php', + 'App\\Support\\DigitalStorage' => __DIR__ . '/../..' . '/app/Support/DigitalStorage.php', + 'App\\Support\\EditionStatement' => __DIR__ . '/../..' . '/app/Support/EditionStatement.php', 'App\\Support\\EmailLayout' => __DIR__ . '/../..' . '/app/Support/EmailLayout.php', + 'App\\Support\\EmailOutboxSchema' => __DIR__ . '/../..' . '/app/Support/EmailOutboxSchema.php', 'App\\Support\\EmailService' => __DIR__ . '/../..' . '/app/Support/EmailService.php', + 'App\\Support\\ExactSearch' => __DIR__ . '/../..' . '/app/Support/ExactSearch.php', + 'App\\Support\\Features' => __DIR__ . '/../..' . '/app/Support/Features.php', 'App\\Support\\GenreHelper' => __DIR__ . '/../..' . '/app/Support/GenreHelper.php', + 'App\\Support\\GenreTree' => __DIR__ . '/../..' . '/app/Support/GenreTree.php', + 'App\\Support\\GndIdentifier' => __DIR__ . '/../..' . '/app/Support/GndIdentifier.php', + 'App\\Support\\Holidays\\ChineseLunarCalendar' => __DIR__ . '/../..' . '/app/Support/Holidays/ChineseLunarCalendar.php', + 'App\\Support\\Holidays\\CustomHolidays' => __DIR__ . '/../..' . '/app/Support/Holidays/CustomHolidays.php', + 'App\\Support\\Holidays\\HolidayCalculator' => __DIR__ . '/../..' . '/app/Support/Holidays/HolidayCalculator.php', + 'App\\Support\\Holidays\\HolidayDataset' => __DIR__ . '/../..' . '/app/Support/Holidays/HolidayDataset.php', + 'App\\Support\\Holidays\\HolidayPresets' => __DIR__ . '/../..' . '/app/Support/Holidays/HolidayPresets.php', + 'App\\Support\\Holidays\\HolidayRules' => __DIR__ . '/../..' . '/app/Support/Holidays/HolidayRules.php', + 'App\\Support\\Holidays\\WorldCalendars' => __DIR__ . '/../..' . '/app/Support/Holidays/WorldCalendars.php', + 'App\\Support\\HomeTexts' => __DIR__ . '/../..' . '/app/Support/HomeTexts.php', 'App\\Support\\HookManager' => __DIR__ . '/../..' . '/app/Support/HookManager.php', 'App\\Support\\Hooks' => __DIR__ . '/../..' . '/app/Support/Hooks.php', 'App\\Support\\HreflangHelper' => __DIR__ . '/../..' . '/app/Support/HreflangHelper.php', @@ -420,36 +488,104 @@ class ComposerStaticInite58358eec498b7b6927cfe671382554c 'App\\Support\\ImportLogger' => __DIR__ . '/../..' . '/app/Support/ImportLogger.php', 'App\\Support\\InputValidator' => __DIR__ . '/../..' . '/app/Support/InputValidator.php', 'App\\Support\\IsbnFormatter' => __DIR__ . '/../..' . '/app/Support/IsbnFormatter.php', + 'App\\Support\\LanguageCodes' => __DIR__ . '/../..' . '/app/Support/LanguageCodes.php', + 'App\\Support\\LibraryCalendar' => __DIR__ . '/../..' . '/app/Support/LibraryCalendar.php', 'App\\Support\\LibraryThingInstaller' => __DIR__ . '/../..' . '/app/Support/LibraryThingInstaller.php', + 'App\\Support\\LiteSpeedCache' => __DIR__ . '/../..' . '/app/Support/LiteSpeedCache.php', + 'App\\Support\\LoanCalendarLinks' => __DIR__ . '/../..' . '/app/Support/LoanCalendarLinks.php', 'App\\Support\\LoanEligibility' => __DIR__ . '/../..' . '/app/Support/LoanEligibility.php', + 'App\\Support\\LoanMultiplicityPolicy' => __DIR__ . '/../..' . '/app/Support/LoanMultiplicityPolicy.php', 'App\\Support\\LoanPdfGenerator' => __DIR__ . '/../..' . '/app/Support/LoanPdfGenerator.php', + 'App\\Support\\LoanStatistics' => __DIR__ . '/../..' . '/app/Support/LoanStatistics.php', + 'App\\Support\\LoanWindow' => __DIR__ . '/../..' . '/app/Support/LoanWindow.php', + 'App\\Support\\LocalizedRouteRegistrar' => __DIR__ . '/../..' . '/app/Support/LocalizedRouteRegistrar.php', + 'App\\Support\\LocalizedRouteSet' => __DIR__ . '/../..' . '/app/Support/LocalizedRouteSet.php', 'App\\Support\\Log' => __DIR__ . '/../..' . '/app/Support/Log.php', 'App\\Support\\Mailer' => __DIR__ . '/../..' . '/app/Support/Mailer.php', 'App\\Support\\MaintenanceService' => __DIR__ . '/../..' . '/app/Support/MaintenanceService.php', + 'App\\Support\\MapEmbed' => __DIR__ . '/../..' . '/app/Support/MapEmbed.php', + 'App\\Support\\Marc\\BookMarcBuilder' => __DIR__ . '/../..' . '/app/Support/Marc/BookMarcBuilder.php', + 'App\\Support\\Marc\\ContributorSorter' => __DIR__ . '/../..' . '/app/Support/Marc/ContributorSorter.php', + 'App\\Support\\Marc\\Danmarc2LibriMapper' => __DIR__ . '/../..' . '/app/Support/Marc/Danmarc2LibriMapper.php', + 'App\\Support\\Marc\\Danmarc2Parser' => __DIR__ . '/../..' . '/app/Support/Marc/Danmarc2Parser.php', + 'App\\Support\\Marc\\GeneratorStream' => __DIR__ . '/../..' . '/app/Support/Marc/GeneratorStream.php', + 'App\\Support\\Marc\\Iso2709Reader' => __DIR__ . '/../..' . '/app/Support/Marc/Iso2709Reader.php', + 'App\\Support\\Marc\\Iso2709Writer' => __DIR__ . '/../..' . '/app/Support/Marc/Iso2709Writer.php', + 'App\\Support\\Marc\\Marc21LibriMapper' => __DIR__ . '/../..' . '/app/Support/Marc/Marc21LibriMapper.php', + 'App\\Support\\Marc\\Marc8Decoder' => __DIR__ . '/../..' . '/app/Support/Marc/Marc8Decoder.php', + 'App\\Support\\Marc\\MarcBookData' => __DIR__ . '/../..' . '/app/Support/Marc/MarcBookData.php', + 'App\\Support\\Marc\\MarcBookImporter' => __DIR__ . '/../..' . '/app/Support/Marc/MarcBookImporter.php', + 'App\\Support\\Marc\\MarcCoverFetcher' => __DIR__ . '/../..' . '/app/Support/Marc/MarcCoverFetcher.php', + 'App\\Support\\Marc\\MarcField' => __DIR__ . '/../..' . '/app/Support/Marc/MarcField.php', + 'App\\Support\\Marc\\MarcFormat' => __DIR__ . '/../..' . '/app/Support/Marc/MarcFormat.php', + 'App\\Support\\Marc\\MarcLanguage' => __DIR__ . '/../..' . '/app/Support/Marc/MarcLanguage.php', + 'App\\Support\\Marc\\MarcLibriMapper' => __DIR__ . '/../..' . '/app/Support/Marc/MarcLibriMapper.php', + 'App\\Support\\Marc\\MarcMediaType' => __DIR__ . '/../..' . '/app/Support/Marc/MarcMediaType.php', + 'App\\Support\\Marc\\MarcParseException' => __DIR__ . '/../..' . '/app/Support/Marc/MarcParseException.php', + 'App\\Support\\Marc\\MarcReadError' => __DIR__ . '/../..' . '/app/Support/Marc/MarcReadError.php', + 'App\\Support\\Marc\\MarcRecord' => __DIR__ . '/../..' . '/app/Support/Marc/MarcRecord.php', + 'App\\Support\\Marc\\MarcXmlReader' => __DIR__ . '/../..' . '/app/Support/Marc/MarcXmlReader.php', + 'App\\Support\\Marc\\MarcXmlWriter' => __DIR__ . '/../..' . '/app/Support/Marc/MarcXmlWriter.php', + 'App\\Support\\Marc\\RelatorRoles' => __DIR__ . '/../..' . '/app/Support/Marc/RelatorRoles.php', + 'App\\Support\\Marc\\UnimarcLibriMapper' => __DIR__ . '/../..' . '/app/Support/Marc/UnimarcLibriMapper.php', + 'App\\Support\\Marc\\UnimarcLibriParser' => __DIR__ . '/../..' . '/app/Support/Marc/UnimarcLibriParser.php', 'App\\Support\\MediaLabels' => __DIR__ . '/../..' . '/app/Support/MediaLabels.php', 'App\\Support\\MergeHelper' => __DIR__ . '/../..' . '/app/Support/MergeHelper.php', 'App\\Support\\NotificationService' => __DIR__ . '/../..' . '/app/Support/NotificationService.php', + 'App\\Support\\OffsetFileStream' => __DIR__ . '/../..' . '/app/Support/OffsetFileStream.php', + 'App\\Support\\OneTimeFormToken' => __DIR__ . '/../..' . '/app/Support/OneTimeFormToken.php', + 'App\\Support\\PasswordSetupToken' => __DIR__ . '/../..' . '/app/Support/PasswordSetupToken.php', + 'App\\Support\\PatronCardPdf' => __DIR__ . '/../..' . '/app/Support/PatronCardPdf.php', + 'App\\Support\\PatronCardSettings' => __DIR__ . '/../..' . '/app/Support/PatronCardSettings.php', + 'App\\Support\\PatronPhoto' => __DIR__ . '/../..' . '/app/Support/PatronPhoto.php', + 'App\\Support\\PatronTypeAssignment' => __DIR__ . '/../..' . '/app/Support/PatronTypeAssignment.php', + 'App\\Support\\PatronTypeImport' => __DIR__ . '/../..' . '/app/Support/PatronTypeImport.php', + 'App\\Support\\PaymentReceiptPdf' => __DIR__ . '/../..' . '/app/Support/PaymentReceiptPdf.php', + 'App\\Support\\PeriodicalArticlesHint' => __DIR__ . '/../..' . '/app/Support/PeriodicalArticlesHint.php', + 'App\\Support\\Permissions' => __DIR__ . '/../..' . '/app/Support/Permissions.php', + 'App\\Support\\PhpSecurityAdvisory' => __DIR__ . '/../..' . '/app/Support/PhpSecurityAdvisory.php', + 'App\\Support\\PickupDeadlineBackfill' => __DIR__ . '/../..' . '/app/Support/PickupDeadlineBackfill.php', + 'App\\Support\\PickupNotificationSchema' => __DIR__ . '/../..' . '/app/Support/PickupNotificationSchema.php', 'App\\Support\\PluginManager' => __DIR__ . '/../..' . '/app/Support/PluginManager.php', + 'App\\Support\\PrivateUploadsRewriteHeal' => __DIR__ . '/../..' . '/app/Support/PrivateUploadsRewriteHeal.php', + 'App\\Support\\PublicationPlace' => __DIR__ . '/../..' . '/app/Support/PublicationPlace.php', 'App\\Support\\QueryCache' => __DIR__ . '/../..' . '/app/Support/QueryCache.php', + 'App\\Support\\QuickSearchOrder' => __DIR__ . '/../..' . '/app/Support/QuickSearchOrder.php', 'App\\Support\\RateLimiter' => __DIR__ . '/../..' . '/app/Support/RateLimiter.php', + 'App\\Support\\RdaTypes' => __DIR__ . '/../..' . '/app/Support/RdaTypes.php', 'App\\Support\\RefererGuard' => __DIR__ . '/../..' . '/app/Support/RefererGuard.php', 'App\\Support\\RegistrationFields' => __DIR__ . '/../..' . '/app/Support/RegistrationFields.php', 'App\\Support\\RememberMeService' => __DIR__ . '/../..' . '/app/Support/RememberMeService.php', + 'App\\Support\\RememberMeSessionRegistry' => __DIR__ . '/../..' . '/app/Support/RememberMeSessionRegistry.php', + 'App\\Support\\RolePresets' => __DIR__ . '/../..' . '/app/Support/RolePresets.php', 'App\\Support\\RouteTranslator' => __DIR__ . '/../..' . '/app/Support/RouteTranslator.php', 'App\\Support\\SchemaInfo' => __DIR__ . '/../..' . '/app/Support/SchemaInfo.php', 'App\\Support\\ScrapingService' => __DIR__ . '/../..' . '/app/Support/ScrapingService.php', 'App\\Support\\SearchIndexBuilder' => __DIR__ . '/../..' . '/app/Support/SearchIndexBuilder.php', 'App\\Support\\SecureLogger' => __DIR__ . '/../..' . '/app/Support/SecureLogger.php', 'App\\Support\\SeriesLabels' => __DIR__ . '/../..' . '/app/Support/SeriesLabels.php', + 'App\\Support\\SessionPolicy' => __DIR__ . '/../..' . '/app/Support/SessionPolicy.php', + 'App\\Support\\SessionRoleRevalidator' => __DIR__ . '/../..' . '/app/Support/SessionRoleRevalidator.php', + 'App\\Support\\SessionRuntime' => __DIR__ . '/../..' . '/app/Support/SessionRuntime.php', 'App\\Support\\SettingsEncryption' => __DIR__ . '/../..' . '/app/Support/SettingsEncryption.php', 'App\\Support\\SettingsMailTemplates' => __DIR__ . '/../..' . '/app/Support/SettingsMailTemplates.php', 'App\\Support\\SharingProviders' => __DIR__ . '/../..' . '/app/Support/SharingProviders.php', + 'App\\Support\\SitemapCache' => __DIR__ . '/../..' . '/app/Support/SitemapCache.php', 'App\\Support\\SitemapGenerator' => __DIR__ . '/../..' . '/app/Support/SitemapGenerator.php', 'App\\Support\\SsrfGuard' => __DIR__ . '/../..' . '/app/Support/SsrfGuard.php', + 'App\\Support\\StaleCspHeader' => __DIR__ . '/../..' . '/app/Support/StaleCspHeader.php', + 'App\\Support\\StatisticsSections' => __DIR__ . '/../..' . '/app/Support/StatisticsSections.php', + 'App\\Support\\StocktakePdfGenerator' => __DIR__ . '/../..' . '/app/Support/StocktakePdfGenerator.php', 'App\\Support\\ThemeColorizer' => __DIR__ . '/../..' . '/app/Support/ThemeColorizer.php', 'App\\Support\\ThemeManager' => __DIR__ . '/../..' . '/app/Support/ThemeManager.php', + 'App\\Support\\TrailingSlash' => __DIR__ . '/../..' . '/app/Support/TrailingSlash.php', + 'App\\Support\\TranslationScript' => __DIR__ . '/../..' . '/app/Support/TranslationScript.php', + 'App\\Support\\TrustedLink' => __DIR__ . '/../..' . '/app/Support/TrustedLink.php', 'App\\Support\\UniqueViolation' => __DIR__ . '/../..' . '/app/Support/UniqueViolation.php', 'App\\Support\\Updater' => __DIR__ . '/../..' . '/app/Support/Updater.php', + 'App\\Support\\UpdaterPreflightException' => __DIR__ . '/../..' . '/app/Support/UpdaterPreflightException.php', + 'App\\Support\\UserImport' => __DIR__ . '/../..' . '/app/Support/UserImport.php', + 'App\\Support\\UserRoleAssignment' => __DIR__ . '/../..' . '/app/Support/UserRoleAssignment.php', 'App\\Support\\UsersPdfGenerator' => __DIR__ . '/../..' . '/app/Support/UsersPdfGenerator.php', 'Attribute' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Attribute.php', 'Carbon\\AbstractTranslator' => __DIR__ . '/..' . '/nesbot/carbon/src/Carbon/AbstractTranslator.php', @@ -1388,8 +1524,6 @@ class ComposerStaticInite58358eec498b7b6927cfe671382554c 'Monolog\\Registry' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Registry.php', 'Monolog\\ResettableInterface' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/ResettableInterface.php', 'Monolog\\SignalHandler' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/SignalHandler.php', - 'Monolog\\Test\\MonologTestCase' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Test/MonologTestCase.php', - 'Monolog\\Test\\TestCase' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Test/TestCase.php', 'Monolog\\Utils' => __DIR__ . '/..' . '/monolog/monolog/src/Monolog/Utils.php', 'Override' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/Override.php', 'PDF417' => __DIR__ . '/..' . '/tecnickcom/tcpdf/include/barcodes/pdf417.php', @@ -1549,7 +1683,6 @@ class ComposerStaticInite58358eec498b7b6927cfe671382554c 'Symfony\\Component\\Clock\\MockClock' => __DIR__ . '/..' . '/symfony/clock/MockClock.php', 'Symfony\\Component\\Clock\\MonotonicClock' => __DIR__ . '/..' . '/symfony/clock/MonotonicClock.php', 'Symfony\\Component\\Clock\\NativeClock' => __DIR__ . '/..' . '/symfony/clock/NativeClock.php', - 'Symfony\\Component\\Clock\\Test\\ClockSensitiveTrait' => __DIR__ . '/..' . '/symfony/clock/Test/ClockSensitiveTrait.php', 'Symfony\\Component\\HtmlSanitizer\\HtmlSanitizer' => __DIR__ . '/..' . '/symfony/html-sanitizer/HtmlSanitizer.php', 'Symfony\\Component\\HtmlSanitizer\\HtmlSanitizerConfig' => __DIR__ . '/..' . '/symfony/html-sanitizer/HtmlSanitizerConfig.php', 'Symfony\\Component\\HtmlSanitizer\\HtmlSanitizerInterface' => __DIR__ . '/..' . '/symfony/html-sanitizer/HtmlSanitizerInterface.php', @@ -1654,10 +1787,6 @@ class ComposerStaticInite58358eec498b7b6927cfe671382554c 'Symfony\\Component\\Translation\\Reader\\TranslationReader' => __DIR__ . '/..' . '/symfony/translation/Reader/TranslationReader.php', 'Symfony\\Component\\Translation\\Reader\\TranslationReaderInterface' => __DIR__ . '/..' . '/symfony/translation/Reader/TranslationReaderInterface.php', 'Symfony\\Component\\Translation\\StaticMessage' => __DIR__ . '/..' . '/symfony/translation/StaticMessage.php', - 'Symfony\\Component\\Translation\\Test\\AbstractProviderFactoryTestCase' => __DIR__ . '/..' . '/symfony/translation/Test/AbstractProviderFactoryTestCase.php', - 'Symfony\\Component\\Translation\\Test\\IncompleteDsnTestTrait' => __DIR__ . '/..' . '/symfony/translation/Test/IncompleteDsnTestTrait.php', - 'Symfony\\Component\\Translation\\Test\\ProviderFactoryTestCase' => __DIR__ . '/..' . '/symfony/translation/Test/ProviderFactoryTestCase.php', - 'Symfony\\Component\\Translation\\Test\\ProviderTestCase' => __DIR__ . '/..' . '/symfony/translation/Test/ProviderTestCase.php', 'Symfony\\Component\\Translation\\TranslatableMessage' => __DIR__ . '/..' . '/symfony/translation/TranslatableMessage.php', 'Symfony\\Component\\Translation\\Translator' => __DIR__ . '/..' . '/symfony/translation/Translator.php', 'Symfony\\Component\\Translation\\TranslatorBag' => __DIR__ . '/..' . '/symfony/translation/TranslatorBag.php', diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index 864c48e54..a0985909b 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -2960,17 +2960,17 @@ }, { "name": "symfony/html-sanitizer", - "version": "v6.4.41", - "version_normalized": "6.4.41.0", + "version": "v6.4.50", + "version_normalized": "6.4.50.0", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "fba29d97d888ca69b496a626c9248fd56a293d8a" + "reference": "fe5105b37dd51916915525cfaf0d4b220a552907" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/fba29d97d888ca69b496a626c9248fd56a293d8a", - "reference": "fba29d97d888ca69b496a626c9248fd56a293d8a", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/fe5105b37dd51916915525cfaf0d4b220a552907", + "reference": "fe5105b37dd51916915525cfaf0d4b220a552907", "shasum": "" }, "require": { @@ -2979,7 +2979,7 @@ "masterminds/html5": "^2.7.2", "php": ">=8.1" }, - "time": "2026-05-24T11:08:37+00:00", + "time": "2026-11-04T07:45:19+00:00", "type": "library", "installation-source": "dist", "autoload": { @@ -3012,7 +3012,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v6.4.41" + "source": "https://github.com/symfony/html-sanitizer/tree/v6.4.50" }, "funding": [ { diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index 4cab45a19..436e66b0b 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'pinakes/slim-app', 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '6f84359c4477daeea582619fc4f0dd58e2ccb54a', + 'reference' => '2103f1474297788831d16d4a65d4c1310b53dbdc', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -247,7 +247,7 @@ 'pinakes/slim-app' => array( 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '6f84359c4477daeea582619fc4f0dd58e2ccb54a', + 'reference' => '2103f1474297788831d16d4a65d4c1310b53dbdc', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -433,9 +433,9 @@ 'dev_requirement' => false, ), 'symfony/html-sanitizer' => array( - 'pretty_version' => 'v6.4.41', - 'version' => '6.4.41.0', - 'reference' => 'fba29d97d888ca69b496a626c9248fd56a293d8a', + 'pretty_version' => 'v6.4.50', + 'version' => '6.4.50.0', + 'reference' => 'fe5105b37dd51916915525cfaf0d4b220a552907', 'type' => 'library', 'install_path' => __DIR__ . '/../symfony/html-sanitizer', 'aliases' => array(), diff --git a/vendor/symfony/html-sanitizer/HtmlSanitizer.php b/vendor/symfony/html-sanitizer/HtmlSanitizer.php index faef08ffa..2d0066b83 100644 --- a/vendor/symfony/html-sanitizer/HtmlSanitizer.php +++ b/vendor/symfony/html-sanitizer/HtmlSanitizer.php @@ -52,8 +52,13 @@ public function sanitizeFor(string $element, string $input): string private function sanitizeWithContext(string $context, string $input): string { // Prevent DOS attack induced by extremely long HTML strings - if (-1 !== $this->config->getMaxInputLength() && \strlen($input) > $this->config->getMaxInputLength()) { - $input = substr($input, 0, $this->config->getMaxInputLength()); + if (-1 !== ($maxLength = $this->config->getMaxInputLength()) && \strlen($input) > $maxLength) { + // Don't cut a multibyte character, which would make the input invalid UTF-8 + while (0 < $maxLength && 0x80 === (\ord($input[$maxLength]) & 0xC0)) { + --$maxLength; + } + + $input = substr($input, 0, $maxLength); } // Text context: early return with HTML encoding diff --git a/vendor/symfony/html-sanitizer/TextSanitizer/UrlSanitizer.php b/vendor/symfony/html-sanitizer/TextSanitizer/UrlSanitizer.php index f3cf3b770..64313bce6 100644 --- a/vendor/symfony/html-sanitizer/TextSanitizer/UrlSanitizer.php +++ b/vendor/symfony/html-sanitizer/TextSanitizer/UrlSanitizer.php @@ -23,8 +23,18 @@ final class UrlSanitizer * Characters with no legitimate place in a URL: explicit-direction BiDi * formatting marks plus Unicode whitespace and the zero-width no-break * space. ASCII space is tolerated and percent-encoded by parse(). + * + * The characters are matched as UTF-8 byte sequences: with the "u" modifier, + * preg_match() returns false on malformed UTF-8, which percent-decoding can + * produce, and the check would then be skipped. */ - private const DENIED_CHARS_PATTERN = '/[\t\n\v\f\r\x{0085}\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}\x{202A}-\x{202E}\x{2066}-\x{2069}]/u'; + private const DENIED_CHARS_PATTERN = '/[\t\n\x0B\f\r]' + .'|\xC2[\x85\xA0]' // U+0085, U+00A0 + .'|\xE1\x9A\x80' // U+1680 + .'|\xE2\x80[\x80-\x8A\xA8-\xAF]' // U+2000-U+200A, U+2028-U+202F + .'|\xE2\x81[\x9F\xA6-\xA9]' // U+205F, U+2066-U+2069 + .'|\xE3\x80\x80' // U+3000 + .'|\xEF\xBB\xBF/'; // U+FEFF /** * Sanitizes a given URL string. @@ -42,7 +52,13 @@ public static function sanitize(?string $input, ?array $allowedSchemes = null, b return null; } - if (false !== strpbrk($input, '\\') || preg_match('~^(?:https?|ftp|wss?):(/[^/]|///)~i', $input)) { + // Schemes that browsers parse per WHATWG, where the authority is optional + // and the number of slashes is not significant, must carry exactly the two + // slashes RFC 3986 requires; otherwise the host seen by the browser is not + // the host this sanitizer checks against the allowed hosts. The same holds + // for scheme-relative URLs: "///host" is a path for RFC 3986 but an + // authority for browsers. + if (false !== strpbrk($input, '\\') || preg_match('~^(?:https?|ftp|wss?):(?!//[^/])|^///~i', $input)) { return null; } @@ -63,15 +79,34 @@ public static function sanitize(?string $input, ?array $allowedSchemes = null, b return null; } + // A view-source URL wraps another URL, which has to pass the same checks; + // browsers reject a view-source URL wrapped in another one + if ('view-source' === $url['scheme']) { + $nested = substr($input, \strlen('view-source:')); + + if (0 === strncasecmp($nested, 'view-source:', 12) || null === $nested = self::sanitize($nested, $allowedSchemes, $forceHttps, $allowedHosts, $allowRelative)) { + return null; + } + + return 'view-source:'.$nested; + } + // If the scheme used is not supposed to have a host, do not check the host if (!self::isHostlessScheme($url['scheme'])) { - // No host and relative not allowed - if (!$allowRelative && !$url['host']) { + // An empty authority is never a valid destination, and rebuilding the URL + // around it changes the number of slashes in front of the path, which + // browsers would then read as an authority + if ('' === $url['host']) { + return null; + } + + // No host and relative not allowed; "0" is a host, not a missing one + if (!$allowRelative && null === $url['host']) { return null; } // Forbidden host - if ($url['host'] && null !== $allowedHosts && !self::isAllowedHost($url['host'], $allowedHosts)) { + if (null !== $url['host'] && null !== $allowedHosts && !self::isAllowedHost($url['host'], $allowedHosts)) { return null; } } @@ -81,7 +116,18 @@ public static function sanitize(?string $input, ?array $allowedSchemes = null, b $url['scheme'] = 'https'; } - return UriString::build($url); + // In absence of a scheme and of an authority, RFC 3986 forbids a colon in the + // first path segment, which would otherwise be read as a scheme. league/uri + // enforces this in UriString::build(), but only since version 7. + if (!$url['scheme'] && null === $url['host'] && preg_match('#^[^/:]*+:#', $url['path'])) { + return null; + } + + try { + return UriString::build($url); + } catch (SyntaxError) { + return null; + } } /** @@ -142,6 +188,9 @@ public static function parse(string $url): ?array } $parsedUrl = UriString::parse($url); + if (isset($parsedUrl['scheme'])) { + $parsedUrl['scheme'] = strtolower($parsedUrl['scheme']); + } if (isset($parsedUrl['host']) && self::decodeUnreservedCharacters($parsedUrl['host']) !== $parsedUrl['host']) { return null; @@ -149,8 +198,22 @@ public static function parse(string $url): ?array // Reject denied characters reachable via percent-encoding in any // component; otherwise the upfront check is bypassed by encoding. + // Percent-encoded line breaks and tabs are legitimate in the query + // and fragment of hostless schemes: RFC 6068 requires %0D%0A for + // line breaks in the body of a mailto URL. + $isHostless = self::isHostlessScheme($parsedUrl['scheme']); foreach (['user', 'pass', 'host', 'path', 'query', 'fragment'] as $part) { - if (isset($parsedUrl[$part]) && preg_match(self::DENIED_CHARS_PATTERN, rawurldecode($parsedUrl[$part]))) { + if (!isset($parsedUrl[$part])) { + continue; + } + + $decoded = rawurldecode($parsedUrl[$part]); + + if ($isHostless && ('query' === $part || 'fragment' === $part)) { + $decoded = str_replace(["\r", "\n", "\t"], '', $decoded); + } + + if (preg_match(self::DENIED_CHARS_PATTERN, $decoded)) { return null; } } @@ -163,7 +226,7 @@ public static function parse(string $url): ?array private static function isHostlessScheme(?string $scheme): bool { - return \in_array($scheme, ['blob', 'chrome', 'data', 'file', 'geo', 'mailto', 'maps', 'tel', 'sms', 'view-source'], true); + return \in_array($scheme, ['blob', 'chrome', 'data', 'file', 'geo', 'mailto', 'maps', 'tel', 'sms'], true); } private static function isAllowedHost(?string $host, array $allowedHosts): bool @@ -172,10 +235,10 @@ private static function isAllowedHost(?string $host, array $allowedHosts): bool return \in_array(null, $allowedHosts, true); } - $parts = array_reverse(explode('.', $host)); + $parts = array_reverse(explode('.', strtolower($host))); foreach ($allowedHosts as $allowedHost) { - if (self::matchAllowedHostParts($parts, array_reverse(explode('.', $allowedHost)))) { + if (self::matchAllowedHostParts($parts, array_reverse(explode('.', strtolower($allowedHost))))) { return true; } } diff --git a/vendor/symfony/html-sanitizer/Visitor/AttributeSanitizer/MetaRefreshAttributeSanitizer.php b/vendor/symfony/html-sanitizer/Visitor/AttributeSanitizer/MetaRefreshAttributeSanitizer.php index 368d96b04..dc15a2292 100644 --- a/vendor/symfony/html-sanitizer/Visitor/AttributeSanitizer/MetaRefreshAttributeSanitizer.php +++ b/vendor/symfony/html-sanitizer/Visitor/AttributeSanitizer/MetaRefreshAttributeSanitizer.php @@ -18,8 +18,13 @@ * Sanitizes the URL embedded in the content attribute of a * element, since the http-equiv value is not visible from a per-attribute sanitizer. * + * The value is parsed following the "shared declarative refresh steps" of the HTML + * specification: the time can be fractional, the separator can be a semicolon, a comma + * or ASCII whitespace, and the "url" keyword is optional. + * * The content attribute carries an unrelated value for other meta types (description, - * keywords, generator…), which is passed through unchanged. + * keywords, generator…), which is passed through unchanged as long as it cannot be read + * as a refresh directive by a browser. */ final class MetaRefreshAttributeSanitizer implements AttributeSanitizerInterface { @@ -35,12 +40,64 @@ public function getSupportedAttributes(): ?array public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string { - if (!preg_match('/^(\s*\d+\s*[;,]\s*url\s*=\s*)(["\']?)(.+?)\2(\s*)$/i', $value, $m)) { + // A leading time is the only marker of a refresh directive: without one, the value + // cannot trigger a navigation and belongs to another meta type. Whitespace means + // the ASCII whitespace of the specification, which \s does not match exactly. + if (!preg_match('/^([ \t\n\f\r]*(?:\d[\d.]*|\.[\d.]*))(.*)$/s', $value, $m)) { + return $value; + } + + [, $time, $rest] = $m; + + // Nothing follows the time: the directive reloads the current document. + if ('' === $rest) { + return $value; + } + + // The time is followed by a semicolon, a comma or ASCII whitespace, the latter + // optionally followed by a semicolon or a comma. Anything else is dropped instead + // of being trusted, so that a spelling this parser does not know about cannot slip + // an unsanitized URL through. + if (!preg_match('/^((?:[;,]|[ \t\n\f\r]+[;,]?)[ \t\n\f\r]*)(.*)$/s', $rest, $m)) { + return null; + } + + [, $separator, $url] = $m; + + $keyword = ''; + $quote = ''; + $trailing = ''; + + // An incomplete "url=" keyword is part of the URL and disables the quote handling. + $stripQuotes = 'u' !== strtolower($url[0] ?? ''); + + if (preg_match('/^(url[ \t\n\f\r]*=[ \t\n\f\r]*)(.*)$/is', $url, $m)) { + [, $keyword, $url] = $m; + $stripQuotes = true; + } + + if ($stripQuotes && ('"' === ($first = $url[0] ?? '') || "'" === $first)) { + $quote = $first; + $url = substr($url, 1); + + // Everything after the closing quote is ignored by browsers. + if (false !== $end = strpos($url, $quote)) { + $url = substr($url, 0, $end); + } + } else { + $trimmed = rtrim($url, " \t\n\f\r"); + $trailing = substr($url, \strlen($trimmed)); + $url = $trimmed; + } + + // An empty URL resolves to the document's own URL: the directive reloads the + // current document, exactly like a directive made of a time only. + if ('' === $url) { return $value; } $sanitized = UrlSanitizer::sanitize( - $m[3], + $url, $config->getAllowedLinkSchemes(), $config->getForceHttpsUrls(), $config->getAllowedLinkHosts(), @@ -51,6 +108,6 @@ public function sanitizeAttribute(string $element, string $attribute, string $va return null; } - return $m[1].$m[2].$sanitized.$m[2].$m[4]; + return $time.$separator.$keyword.$quote.$sanitized.$quote.$trailing; } } diff --git a/vendor/symfony/html-sanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php b/vendor/symfony/html-sanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php index 6ec865fad..4a2bbd001 100644 --- a/vendor/symfony/html-sanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php +++ b/vendor/symfony/html-sanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php @@ -27,27 +27,106 @@ public function getSupportedElements(): ?array public function getSupportedAttributes(): ?array { - return ['src', 'href', 'lowsrc', 'background', 'ping', 'action', 'formaction', 'poster', 'cite', 'data', 'codebase', 'archive', 'longdesc']; + return ['src', 'srcset', 'imagesrcset', 'href', 'lowsrc', 'background', 'ping', 'action', 'formaction', 'poster', 'cite', 'data', 'codebase', 'archive', 'longdesc']; } public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string { if (\in_array($element, ['a', 'area'], true) || \in_array($attribute, ['action', 'formaction', 'cite'], true)) { - return UrlSanitizer::sanitize( - $value, - $config->getAllowedLinkSchemes(), - $config->getForceHttpsUrls(), - $config->getAllowedLinkHosts(), - $config->getAllowRelativeLinks(), - ); + $allowedSchemes = $config->getAllowedLinkSchemes(); + $allowedHosts = $config->getAllowedLinkHosts(); + $allowRelative = $config->getAllowRelativeLinks(); + } else { + $allowedSchemes = $config->getAllowedMediaSchemes(); + $allowedHosts = $config->getAllowedMediaHosts(); + $allowRelative = $config->getAllowRelativeMedias(); } - return UrlSanitizer::sanitize( - $value, - $config->getAllowedMediaSchemes(), - $config->getForceHttpsUrls(), - $config->getAllowedMediaHosts(), - $config->getAllowRelativeMedias(), - ); + if (\in_array($attribute, ['srcset', 'imagesrcset'], true)) { + return self::sanitizeSrcset($value, $allowedSchemes, $config->getForceHttpsUrls(), $allowedHosts, $allowRelative); + } + + return UrlSanitizer::sanitize($value, $allowedSchemes, $config->getForceHttpsUrls(), $allowedHosts, $allowRelative); + } + + /** + * Sanitizes an image candidate strings list: a comma-separated list of candidates, + * each made of an URL optionally followed by width or pixel density descriptors. + * + * Candidates whose URL doesn't pass the policy are dropped; the attribute itself is + * dropped when no candidate is left, as an empty list would be invalid anyway. + * + * @see https://html.spec.whatwg.org/multipage/images.html#parsing-a-srcset-attribute + */ + private static function sanitizeSrcset(string $value, ?array $allowedSchemes, bool $forceHttps, ?array $allowedHosts, bool $allowRelative): ?string + { + $candidates = []; + $length = \strlen($value); + $position = 0; + + while ($position < $length) { + $position += strspn($value, " \t\n\f\r,", $position); + + if ($position >= $length) { + break; + } + + $urlLength = strcspn($value, " \t\n\f\r", $position); + $url = substr($value, $position, $urlLength); + $position += $urlLength; + + // An URL may contain commas, so only trailing ones separate candidates + if (str_ends_with($url, ',')) { + $url = rtrim($url, ','); + $descriptors = ''; + } else { + $descriptors = self::consumeSrcsetDescriptors($value, $position, $length); + } + + if (null === $url = UrlSanitizer::sanitize($url, $allowedSchemes, $forceHttps, $allowedHosts, $allowRelative)) { + continue; + } + + $candidates[] = '' === $descriptors ? $url : $url.' '.$descriptors; + } + + return $candidates ? implode(', ', $candidates) : null; + } + + /** + * Consumes the descriptors of the current candidate, up to the next separating comma, + * ignoring commas nested in parentheses as the HTML specification requires. + */ + private static function consumeSrcsetDescriptors(string $value, int &$position, int $length): string + { + $start = $position; + $inParens = false; + + while ($position < $length) { + $char = $value[$position]; + + if ($inParens) { + $inParens = ')' !== $char; + } elseif ('(' === $char) { + $inParens = true; + } elseif (',' === $char) { + break; + } + + ++$position; + } + + $consumed = strtr(substr($value, $start, $position - $start), "\t\n\f\r", ' '); + ++$position; + + $descriptors = []; + + foreach (explode(' ', $consumed) as $descriptor) { + if ('' !== $descriptor) { + $descriptors[] = $descriptor; + } + } + + return implode(' ', $descriptors); } } diff --git a/version.json b/version.json index 9f9e29f89..f09ebcdff 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "name": "Pinakes", - "version": "0.9.0-rc.2", + "version": "0.9.0-rc.3", "description": "Library Management System - Sistema di Gestione Bibliotecaria" }