From c89decf50871be5269ab831279c218abb3c9e6bd Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:45:27 +0200 Subject: [PATCH 01/26] Give copies received through Desiderata the book's own inventory prefix The desiderata plugin created received copies with the base code 'LIB-' . $bookId in all three of its receipt paths (book form, direct receipt, donation offer), ignoring the book's "Prefisso inventario copie". Libraries with their own numbering scheme got LIB--C1 codes they cannot correct, since a copy's code is written once. The rule "the book's prefix, else LIB-{id}" now lives in one place, CopyRepository::inventoryBase(), and every path that allocates copy codes uses it: the three plugin paths (reading the prefix from the libri row they already lock FOR UPDATE), the book create form, the copies form, POST /api/libri/{id}/increase-copies and the Mobile API add-copies endpoint. The Mobile API receive endpoints delegate to the plugin and inherit the fix. Existing codes are never renamed. Fixes #489 --- app/Controllers/CopyController.php | 2 +- app/Controllers/LibriController.php | 4 +- app/Models/CopyRepository.php | 16 ++ app/Routes/web.php | 4 +- .../plugins/desiderata/DesiderataPlugin.php | 16 +- storage/plugins/desiderata/plugin.json | 2 +- .../src/Controllers/StaffBooksController.php | 4 +- .../desiderata-inventory-prefix-489.unit.php | 210 ++++++++++++++++++ 8 files changed, 241 insertions(+), 17 deletions(-) create mode 100644 tests/desiderata-inventory-prefix-489.unit.php diff --git a/app/Controllers/CopyController.php b/app/Controllers/CopyController.php index b9c53ffc3..7f5b655af 100644 --- a/app/Controllers/CopyController.php +++ b/app/Controllers/CopyController.php @@ -430,7 +430,7 @@ public function createCopy(Request $request, Response $response, mysqli $db, int // Re-evaluate after sanitisation: an explicit value made only of // control characters must fall back to automatic allocation. if ($numero === '') { - $base = !empty($book['numero_inventario']) ? (string) $book['numero_inventario'] : "LIB-{$bookId}"; + $base = CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); $newCopyIds = $repo->createManyForBookWithIdsAndNote( $bookId, $base, diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index 1025d5cfc..dacbdf7c5 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -1335,9 +1335,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons // Genera copie fisiche del libro $copyRepo = new \App\Models\CopyRepository($db); $copieTotali = (int) $fields['copie_totali']; - $baseInventario = !empty($fields['numero_inventario']) - ? $fields['numero_inventario'] - : "LIB-{$id}"; + $baseInventario = \App\Models\CopyRepository::inventoryBase($id, $fields['numero_inventario'] ?? null); // Create the requested holding set with one atomic multi-row INSERT: // a request for three copies must never leave a partial 1/3 or 2/3 diff --git a/app/Models/CopyRepository.php b/app/Models/CopyRepository.php index 171d10365..8da299766 100644 --- a/app/Models/CopyRepository.php +++ b/app/Models/CopyRepository.php @@ -152,6 +152,22 @@ public function getById(int $id): ?array return $copia ?: null; } + /** + * Base of the "{base}-C{N}" codes given to new copies of a book: the book's + * own "Prefisso inventario copie" (libri.numero_inventario) when it has + * one, otherwise "LIB-{id}". Every path that allocates copy codes reads the + * rule from here, so a library with its own inventory scheme gets it + * whichever screen, plugin or API created the copy (#489). + * + * $bookPrefix is the raw column/form value. A blank or whitespace-only + * value means "no prefix"; surrounding whitespace is never part of a code. + */ + public static function inventoryBase(int $bookId, mixed $bookPrefix): string + { + $prefix = is_scalar($bookPrefix) ? trim((string) $bookPrefix) : ''; + return $prefix !== '' ? $prefix : "LIB-{$bookId}"; + } + /** * Crea una nuova copia */ diff --git a/app/Routes/web.php b/app/Routes/web.php index 5b75cdbab..81df456cf 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -2658,9 +2658,7 @@ // manually-inflated copie_totali and a partial copy set behind. No manual // UPDATE, one transaction, rolled back on any failure. $copyRepo = new \App\Models\CopyRepository($db); - $baseInventario = !empty($book['numero_inventario']) - ? $book['numero_inventario'] - : "LIB-{$bookId}"; + $baseInventario = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); $reassignmentService = null; $reservationManager = null; diff --git a/storage/plugins/desiderata/DesiderataPlugin.php b/storage/plugins/desiderata/DesiderataPlugin.php index c7b33867e..a4b8f5fe0 100644 --- a/storage/plugins/desiderata/DesiderataPlugin.php +++ b/storage/plugins/desiderata/DesiderataPlugin.php @@ -556,7 +556,7 @@ private function registerCopies(int $bookId, int $howMany): void { $this->db->begin_transaction(); try { - $stmt = $this->db->prepare('SELECT titolo FROM libri l WHERE l.id = ? AND l.deleted_at IS NULL AND l.is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); + $stmt = $this->db->prepare('SELECT titolo, numero_inventario FROM libri l WHERE l.id = ? AND l.deleted_at IS NULL AND l.is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); if ($stmt === false) { throw new RuntimeException($this->db->error); } $stmt->bind_param('i', $bookId); $stmt->execute(); @@ -565,8 +565,10 @@ private function registerCopies(int $bookId, int $howMany): void if (!$book) { $this->db->rollback(); return; } $title = (string) ($book['titolo'] ?? ''); $this->assertNoOpenOffers($bookId); + // The book's own inventory prefix, as every core path uses it (#489). + $base = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); $ids = (new \App\Models\CopyRepository($this->db)) - ->createManyForBookWithIdsAndNote($bookId, 'LIB-' . $bookId, $howMany, 'disponibile', 'Donazione diretta'); + ->createManyForBookWithIdsAndNote($bookId, $base, $howMany, 'disponibile', 'Donazione diretta'); if (count($ids) !== $howMany) { throw new RuntimeException('Copy creation failed'); } $copyId = $ids[0]; $stmt = $this->db->prepare('UPDATE libri SET is_desiderata = 0' . \App\Support\BookVisibility::catalogueStamp($this->db) . ' WHERE id = ? AND deleted_at IS NULL'); @@ -1550,7 +1552,7 @@ public function receiveDirect(Request $q, Response $r, int $bookId): Response // inconsistency DataIntegrity exists to repair, and without this // clause a receipt recorded in that window would answer it by // creating a SECOND inventory copy rather than refusing. - $stmt = $this->db->prepare('SELECT titolo FROM libri l WHERE id = ? AND deleted_at IS NULL AND is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); + $stmt = $this->db->prepare('SELECT titolo, numero_inventario FROM libri l WHERE id = ? AND deleted_at IS NULL AND is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); if ($stmt === false) { throw new RuntimeException($this->db->error); } $stmt->bind_param('i', $bookId); $stmt->execute(); @@ -1559,7 +1561,8 @@ public function receiveDirect(Request $q, Response $r, int $bookId): Response if (!$book) { throw new InvalidArgumentException(__('Il libro non è più una richiesta aperta.')); } $title = (string) ($book['titolo'] ?? ''); $this->assertNoOpenOffers($bookId); - $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, 'LIB-' . $bookId, 'disponibile', 'Donazione diretta'); + $base = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); + $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, $base, 'disponibile', 'Donazione diretta'); if ($copyId <= 0) { throw new RuntimeException('Copy creation failed'); } // Self-guarding, per ABSOLUTE RULE 2, and kept even though // DataIntegrity clears the flag by itself once a copy exists: the @@ -1933,13 +1936,14 @@ public function manage(Request $q, Response $r, int $id): Response throw new InvalidArgumentException(__('Seleziona la scheda del libro ricevuto.')); } $bookId = (int)$rawBookId; - $stmt = $this->db->prepare('SELECT id FROM libri WHERE id=? AND deleted_at IS NULL FOR UPDATE'); + $stmt = $this->db->prepare('SELECT id, numero_inventario FROM libri WHERE id=? AND deleted_at IS NULL FOR UPDATE'); if ($stmt === false) { throw new RuntimeException('Book lock prepare failed: ' . $this->db->error); } $stmt->bind_param('i', $bookId); $stmt->execute(); $bookRow = $stmt->get_result()->fetch_assoc(); $stmt->close(); if (!$bookRow) { throw new InvalidArgumentException(__('Per la ricezione scegli una scheda libro esistente. Puoi prima crearla con zero copie.')); } - $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, 'LIB-' . $bookId, 'disponibile', 'Donazione #' . $id); + $base = \App\Models\CopyRepository::inventoryBase($bookId, $bookRow['numero_inventario'] ?? null); + $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, $base, 'disponibile', 'Donazione #' . $id); if ($copyId <= 0) { throw new RuntimeException('Copy creation failed'); } // Self-guarding, per ABSOLUTE RULE 2. Provably a no-op here — // the row was locked FOR UPDATE and checked for deleted_at two diff --git a/storage/plugins/desiderata/plugin.json b/storage/plugins/desiderata/plugin.json index db9740bde..1893e6195 100644 --- a/storage/plugins/desiderata/plugin.json +++ b/storage/plugins/desiderata/plugin.json @@ -2,7 +2,7 @@ "name": "desiderata", "display_name": "Desiderata e donazioni", "description": "Libri cercati dalla biblioteca, ricerca pubblica e proposte di donazione con ricezione controllata.", - "version": "1.2.0", + "version": "1.2.1", "author": "Pinakes", "main_file": "wrapper.php", "requires_php": "8.2", diff --git a/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php b/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php index 6d06d960b..6eae0d944 100644 --- a/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php +++ b/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php @@ -528,9 +528,7 @@ public function addCopies(ServerRequestInterface $request, ResponseInterface $re return ResponseEnvelope::error($response, 'not_found', __('Libro non trovato.'), 404); } $before = \App\Support\ActivityLog::loadBookSnapshot($this->db, $bookId); - $base = trim((string) ($book['numero_inventario'] ?? '')) !== '' - ? (string) $book['numero_inventario'] - : 'LIB-' . $bookId; + $base = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); // Same steps as POST /api/libri/{id}/increase-copies: one // transaction, book row locked first, every new copy first repairs diff --git a/tests/desiderata-inventory-prefix-489.unit.php b/tests/desiderata-inventory-prefix-489.unit.php new file mode 100644 index 000000000..55922ce73 --- /dev/null +++ b/tests/desiderata-inventory-prefix-489.unit.php @@ -0,0 +1,210 @@ +-C1 codes it + * cannot correct (a copy's code is written once and is editable nowhere, by + * design). This suite receives a book through each of those paths and reads + * back the code that was actually written: + * + * A. the book form — request closed by saving the record with copies + * (prepareBook() + bookSaved() → registerCopies()) + * B. direct receipt — "received at the desk", no proposal (receiveDirect()) + * C. donation offer — a proposal marked received (manage()) + * + * Each path runs twice: once on a book WITH a prefix (codes must be + * "-C") and once on a book WITHOUT one (codes must stay + * "LIB--C", the historical default). The Mobile API receive endpoints + * delegate to B and C, so they are covered by the same assertions. + * + * Real database, real plugin methods, no stubs except the mailer. FAILS HARD + * when the database is unreachable: a skip would be indistinguishable from a + * pass. Fixtures are removed by id at the end. + * + * Run: php tests/desiderata-inventory-prefix-489.unit.php + */ + +$root = dirname(__DIR__); +require $root . '/vendor/autoload.php'; +require_once $root . '/storage/plugins/desiderata/wrapper.php'; + +use App\Models\CopyRepository; + +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + +$pass = 0; +$fail = 0; +$check = static function (bool $ok, string $label) use (&$pass, &$fail): void { + echo ($ok ? 'PASS ' : 'FAIL ') . $label . "\n"; + $ok ? $pass++ : $fail++; +}; + +/** .env is parsed by hand: this file must run as a bare `php tests/...` process. */ +$env = []; +foreach (preg_split('/\r?\n/', (string) @file_get_contents($root . '/.env')) ?: [] as $line) { + if (!str_contains($line, '=') || str_starts_with(trim($line), '#')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $env[trim($key)] = trim(trim($value), "\"'"); +} + +$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? '/opt/homebrew/var/mysql/mysql.sock'); +$dbUser = getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''); +$dbPass = getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')); +$dbName = getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''); +$db = $socket !== '' && file_exists($socket) + ? new mysqli(null, $dbUser, $dbPass, $dbName, 0, $socket) + : new mysqli($env['DB_HOST'] ?? '127.0.0.1', $dbUser, $dbPass, $dbName, (int) ($env['DB_PORT'] ?? 3306)); +$db->set_charset('utf8mb4'); + +$plugin = new DesiderataPlugin($db, new App\Support\HookManager($db)); +// Receipts notify the operators by e-mail: a test must never mail anyone. +$plugin->setEmailService(new class($db) extends App\Support\EmailService { + public function sendEmail(string $to, string $subject, string $body, string $toName = '', ?string $locale = null, array $attachments = []): bool + { + return true; + } +}); + +// A per-run tag keeps the prefixes globally unique: numero_inventario has a +// GLOBAL unique index, so a fixed "BIB-" would collide with a previous run or +// with a real library's codes and the allocator would (correctly) skip to -C2. +$tag = 'INV489' . bin2hex(random_bytes(4)); +$bookIds = []; +$offerIds = []; +$fatalError = null; + +$repo = new App\Models\BookRepository($db); +$factory = new Slim\Psr7\Factory\ServerRequestFactory(); +$post = static fn (string $path, array $body) => $factory->createServerRequest('POST', $path)->withParsedBody($body); + +/** A wanted book (desiderata), created through the plugin's own form filter. */ +$createWanted = static function (string $suffix, ?string $prefix) use ($plugin, $repo, $tag, &$bookIds): int { + $fields = ['titolo' => $tag . ' ' . $suffix, 'copie_totali' => 1, 'numero_inventario' => $prefix]; + $fields = $plugin->prepareBook($fields, ['desiderata_form' => '1', 'is_desiderata' => '1'], null); + $id = $repo->createBasic($fields); + $bookIds[] = $id; + return $id; +}; + +/** @return list the codes of the book's copies, oldest first */ +$codesOf = static function (int $bookId) use ($db): array { + $stmt = $db->prepare('SELECT numero_inventario FROM copie WHERE libro_id = ? ORDER BY id'); + $stmt->bind_param('i', $bookId); + $stmt->execute(); + $codes = array_map(static fn (array $row): string => (string) $row[0], $stmt->get_result()->fetch_all(MYSQLI_NUM)); + $stmt->close(); + return $codes; +}; + +$show = static fn (array $codes): string => '[' . implode(', ', $codes) . ']'; + +try { + // Built, never skipped: the receipt paths need is_desiderata and the + // offers table, and a missing one would make every path below a no-op. + $plugin->ensureSchema(); + if (!App\Support\BookVisibility::hasDesiderata($db)) { + throw new RuntimeException('libri.is_desiderata is absent after ensureSchema()'); + } + + // One prefix per book: codes are globally unique, so books sharing a + // prefix continue one "-C" family instead of each starting at -C1. + foreach (['prefixed' => true, 'unprefixed' => false] as $case => $withPrefix) { + $prefixFor = static fn (string $path): ?string => $withPrefix ? "BIB-{$tag}{$path}" : null; + $expected = static fn (int $id, ?string $prefix, int $n): string => ($prefix ?? "LIB-{$id}") . "-C{$n}"; + + echo "\nA. Book form receipt ({$case})\n"; + $_SESSION = []; + $prefix = $prefixFor('A'); + $bookA = $createWanted("form {$case}", $prefix); + // The edit that closes the request: flag unticked, two copies asked for. + // prepareBook() is the book.form.save filter, bookSaved() the + // book.save.after action — the same pair core fires on a real save. + $plugin->prepareBook(['titolo' => $tag . " form {$case}"], ['desiderata_form' => '1', 'is_desiderata' => '0', 'desiderata_copies' => '2'], $bookA); + $plugin->bookSaved($bookA); + $codes = $codesOf($bookA); + $want = [$expected($bookA, $prefix, 1), $expected($bookA, $prefix, 2)]; + $check($codes === $want, "two copies coded {$show($want)} (got {$show($codes)})"); + + echo "\nB. Direct receipt at the desk ({$case})\n"; + $_SESSION = []; + $prefix = $prefixFor('B'); + $bookB = $createWanted("direct {$case}", $prefix); + $response = $plugin->receiveDirect($post('/admin/desiderata/books/' . $bookB . '/receive', []), new Slim\Psr7\Response(), $bookB); + $codes = $codesOf($bookB); + $check($response->getStatusCode() === 303, 'the receipt is accepted (got HTTP ' . $response->getStatusCode() . ')'); + $want = [$expected($bookB, $prefix, 1)]; + $check($codes === $want, "one copy coded {$show($want)} (got {$show($codes)})"); + + echo "\nC. Donation offer marked received ({$case})\n"; + $_SESSION = []; + $prefix = $prefixFor('C'); + $bookC = $createWanted("offer {$case}", $prefix); + $title = $tag . " offer {$case}"; + $stmt = $db->prepare("INSERT INTO desiderata_offers (book_id, donor_name, donor_email, title, notes, status) VALUES (?, 'Test donor', 'donor@example.invalid', ?, '', 'accepted')"); + $stmt->bind_param('is', $bookC, $title); + $stmt->execute(); + $offerId = (int) $db->insert_id; + $stmt->close(); + $offerIds[] = $offerId; + $response = $plugin->manage($post('/admin/desiderata/offers/' . $offerId, ['action' => 'received']), new Slim\Psr7\Response(), $offerId); + $codes = $codesOf($bookC); + $check($response->getStatusCode() === 303, 'the receipt is accepted (got HTTP ' . $response->getStatusCode() . ')'); + $want = [$expected($bookC, $prefix, 1)]; + $check($codes === $want, "one copy coded {$show($want)} (got {$show($codes)})"); + } + + echo "\nD. The rule itself\n"; + $check(CopyRepository::inventoryBase(7, 'BIB-') === 'BIB-', 'a prefix is used as the base'); + $check(CopyRepository::inventoryBase(7, ' BIB- ') === 'BIB-', 'surrounding whitespace is not part of the base'); + $check(CopyRepository::inventoryBase(7, '0') === '0', 'a prefix of "0" is a prefix, not an empty value'); + foreach ([null, '', ' '] as $blank) { + $check(CopyRepository::inventoryBase(7, $blank) === 'LIB-7', 'no prefix (' . var_export($blank, true) . ') falls back to LIB-{id}'); + } +} catch (Throwable $thrown) { + $fatalError = $thrown; +} finally { + $cleanupErrors = []; + $run = static function (string $sql) use ($db, &$cleanupErrors): void { + try { + $db->query($sql); + } catch (Throwable $e) { + $cleanupErrors[] = $sql . ': ' . $e->getMessage(); + } + }; + if ($offerIds !== []) { + $run('DELETE FROM desiderata_offers WHERE id IN (' . implode(',', $offerIds) . ')'); + } + if ($bookIds !== []) { + $list = implode(',', $bookIds); + // Paths A and B write their own audit row (status 'received') per book. + $run('DELETE FROM desiderata_offers WHERE book_id IN (' . $list . ') OR received_book_id IN (' . $list . ')'); + $run('DELETE FROM copie WHERE libro_id IN (' . $list . ')'); + $run("DELETE FROM log_modifiche WHERE tabella = 'libri' AND record_id IN (" . $list . ')'); + $run('DELETE FROM libri WHERE id IN (' . $list . ')'); + } + // notifyReceipt() rings the operators' bell with the book title. + $run("DELETE FROM admin_notifications WHERE title LIKE '%" . $db->real_escape_string($tag) . "%' OR message LIKE '%" . $db->real_escape_string($tag) . "%'"); + try { + App\Support\ContentCache::booksChanged(); + } catch (Throwable $e) { + $cleanupErrors[] = 'ContentCache::booksChanged(): ' . $e->getMessage(); + } +} + +foreach ($cleanupErrors as $message) { + fwrite(STDERR, "CLEANUP FAILED: {$message}\n"); +} +if ($fatalError !== null) { + fwrite(STDERR, "\nFAIL: " . get_class($fatalError) . ': ' . $fatalError->getMessage() . "\n"); + exit(1); +} + +echo "\n" . ($fail === 0 ? "ALL {$pass} PASS\n" : "{$pass} PASS, {$fail} FAIL\n"); +exit($fail === 0 && $cleanupErrors === [] ? 0 : 1); From c90951d6b2e59f1a914e4fbd4921f006c66543ce Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:58:31 +0200 Subject: [PATCH 02/26] Check the Desiderata inventory prefix through the real admin screens A Playwright spec creates wanted books from the admin book form with an inventory prefix and receives them through the three buttons an operator has (closing the request from the book form, the requests list, a donor proposal), then reads the copy code on the book page. A book without a prefix keeps the LIB--C1 default. --- tests/desiderata-inventory-prefix-489.spec.js | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/desiderata-inventory-prefix-489.spec.js diff --git a/tests/desiderata-inventory-prefix-489.spec.js b/tests/desiderata-inventory-prefix-489.spec.js new file mode 100644 index 000000000..e2f40caeb --- /dev/null +++ b/tests/desiderata-inventory-prefix-489.spec.js @@ -0,0 +1,163 @@ +// @ts-check +/** + * #489 — a copy received through Desiderata carries the book's own + * "Prefisso inventario copie", through the real admin screens. + * + * Each wanted book is created from the admin book form with a prefix, then + * received through one of the three buttons an operator actually has: + * + * A. edit the record, untick "Desiderata", save with two copies + * B. "Libro donato: registra la copia" on /admin/desiderata + * C. a donor's proposal: "Accetta proposta", then + * "Libro arrivato: registra una copia" + * + * The code is read where the operator reads it: the Copie Fisiche table of the + * book page. A fourth book without a prefix keeps the LIB--C1 default. + * + * Run: /tmp/run-e2e.sh tests/desiderata-inventory-prefix-489.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('node:child_process'); +const crypto = require('node:crypto'); +const { ensurePluginActive } = require('./helpers/plugin-activation'); + +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; + +test.skip(!ADMIN_EMAIL || !ADMIN_PASS || !DB_NAME, 'Requires an installed app, E2E_ADMIN_* and E2E_DB_* credentials'); +test.describe.configure({ mode: 'serial' }); + +// Unique per run: copy codes are globally unique, so a fixed prefix would +// continue a previous run's -C family instead of starting at -C1. +const TAG = 'E2E489' + crypto.randomBytes(4).toString('hex'); +const bookIds = []; + +function dbQuery(sql) { + const args = ['-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql]; + if (DB_SOCKET) args.splice(2, 0, '-S', DB_SOCKET); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); +} + +async function login(page) { + await page.goto('/accedi'); + await page.getByRole('textbox', { name: 'Email', exact: true }).fill(ADMIN_EMAIL); + await page.getByRole('textbox', { name: 'Password', exact: true }).fill(ADMIN_PASS); + await page.getByRole('button', { name: 'Accedi', exact: true }).click(); + await page.waitForURL(/\/admin/); +} + +/** Submit the core book form, confirming its SweetAlert when it asks. */ +async function submitBookForm(page) { + await page.locator('#bookForm button[type="submit"]').click(); + const confirm = page.locator('.swal2-confirm'); + if (await confirm.isVisible({ timeout: 5000 }).catch(() => false)) { + await confirm.click(); + } + await page.waitForURL(/\/admin\/books\/(?!create|edit)\d+/, { timeout: 15000 }); +} + +/** A wanted book created from the admin form, as an operator does it. */ +async function createWanted(page, label, prefix) { + const title = `${TAG} ${label}`; + await page.goto('/admin/books/create'); + await page.fill('#titolo', title); + await page.getByRole('checkbox', { name: 'Desiderata: cerchiamo questo libro', exact: true }).check(); + if (prefix !== null) { + await page.fill('#numero_inventario', prefix); + } + await submitBookForm(page); + const id = Number(dbQuery(`SELECT id FROM libri WHERE titolo = '${title}' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1`)); + expect(id).toBeGreaterThan(0); + bookIds.push(id); + expect(dbQuery(`SELECT is_desiderata FROM libri WHERE id = ${id}`)).toBe('1'); + return { id, title }; +} + +/** The codes the operator sees in the book page's Copie Fisiche table. */ +async function expectCopyCodes(page, bookId, codes) { + await page.goto(`/admin/books/${bookId}#physical-copies`); + const section = page.locator('#physical-copies'); + for (const code of codes) { + await expect(section).toContainText(code); + } + const stored = dbQuery(`SELECT GROUP_CONCAT(numero_inventario ORDER BY id SEPARATOR ',') FROM copie WHERE libro_id = ${bookId}`); + expect(stored).toBe(codes.join(',')); +} + +test.beforeAll(async ({ browser }) => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + await login(page); + await ensurePluginActive(page, 'desiderata', { smokePath: '/desiderata' }); + } finally { + await context.close(); + } +}); + +test.afterAll(() => { + if (bookIds.length === 0) return; + const list = bookIds.join(','); + dbQuery(`DELETE FROM desiderata_offers WHERE book_id IN (${list}) OR received_book_id IN (${list})`); + dbQuery(`DELETE FROM admin_notifications WHERE title LIKE '%${TAG}%' OR message LIKE '%${TAG}%'`); + dbQuery(`DELETE FROM copie WHERE libro_id IN (${list})`); + dbQuery(`DELETE FROM log_modifiche WHERE tabella = 'libri' AND record_id IN (${list})`); + dbQuery(`DELETE FROM libri WHERE id IN (${list})`); +}); + +test('A — closing the request from the book form uses the prefix', async ({ page }) => { + await login(page); + const prefix = `BIB-${TAG}A`; + const book = await createWanted(page, 'form', prefix); + await page.goto(`/admin/books/edit/${book.id}`); + await page.getByRole('checkbox', { name: 'Desiderata: cerchiamo questo libro', exact: true }).uncheck(); + await page.fill('#desiderata_copies', '2'); + await submitBookForm(page); + await expectCopyCodes(page, book.id, [`${prefix}-C1`, `${prefix}-C2`]); +}); + +test('B — "Libro donato" on the requests list uses the prefix', async ({ page }) => { + await login(page); + const prefix = `BIB-${TAG}B`; + const book = await createWanted(page, 'direct', prefix); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('/admin/desiderata'); + const row = page.locator('li').filter({ hasText: book.title }); + await row.getByRole('button', { name: 'Libro donato: registra la copia', exact: true }).click(); + await page.waitForURL(/\/admin\/desiderata/); + await expectCopyCodes(page, book.id, [`${prefix}-C1`]); +}); + +test('C — a donor proposal received through the admin buttons uses the prefix', async ({ page }) => { + await login(page); + const prefix = `BIB-${TAG}C`; + const book = await createWanted(page, 'offer', prefix); + // The donor's side is not under test here: the proposal row is what the + // public form writes, inserted directly so this spec does not depend on the + // anonymous form's rate limiter or captcha settings. + dbQuery(`INSERT INTO desiderata_offers (book_id, donor_name, donor_email, title, notes, status) VALUES (${book.id}, 'E2E donor', 'donor-${TAG}@example.invalid', '${book.title}', '', 'pending')`); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('/admin/desiderata'); + let offer = page.locator('article').filter({ hasText: book.title }); + await offer.getByRole('button', { name: 'Accetta proposta', exact: true }).click(); + offer = page.locator('article').filter({ hasText: book.title }); + await expect(offer).toContainText('In attesa della consegna'); + await offer.getByRole('button', { name: 'Libro arrivato: registra una copia', exact: true }).click(); + await expect(page.locator('article').filter({ hasText: book.title })).toContainText('Ricevuta'); + await expectCopyCodes(page, book.id, [`${prefix}-C1`]); +}); + +test('without a prefix the copy keeps the LIB- default', async ({ page }) => { + await login(page); + const book = await createWanted(page, 'no prefix', null); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('/admin/desiderata'); + const row = page.locator('li').filter({ hasText: book.title }); + await row.getByRole('button', { name: 'Libro donato: registra la copia', exact: true }).click(); + await page.waitForURL(/\/admin\/desiderata/); + await expectCopyCodes(page, book.id, [`LIB-${book.id}-C1`]); +}); From 362529721395e498f151fbbf6265546973733929 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 11:50:22 +0000 Subject: [PATCH 03/26] Cap the public loan request at the day before a scheduled loan and say so When a scheduled loan or a reservation on every copy began before the default return date, the book-page calendar still proposed start + the default duration. flatpickr dropped that date because it fell on a disabled day, the request went out without end_date, the server filled in the full default again and refused it with a bare "no copies available", although a shorter loan was possible. The visitor was never told how long the loan could be. The calendar now looks ahead through the availability it already loaded, caps the return picker at the day before the first blocked day, proposes that date, and explains it under the field: maximum loan length, return date, and the day from which the book is already booked. A return date past that day, or an empty one that the server would fill with the full default, is stopped before sending with the same message. The reservation endpoint's 400 carries the same figures (first blocked day, latest return date, maximum duration) and the same message when the start day itself is free, so a client that skips the calendar gets a precise answer too. The message ships in all five locales. A Playwright suite seeds a scheduled loan on a single-copy title and checks both the calendar and the server response. --- app/Controllers/ReservationsController.php | 54 ++++- app/Views/frontend/book-detail.php | 80 ++++++- locale/da_DK.json | 3 +- locale/de_DE.json | 3 +- locale/en_US.json | 3 +- locale/fr_FR.json | 3 +- locale/it_IT.json | 3 +- public/assets/css/swal-theme.css | 15 ++ ...n-request-capped-by-scheduled-loan.spec.js | 212 ++++++++++++++++++ 9 files changed, 353 insertions(+), 23 deletions(-) create mode 100644 tests/loan-request-capped-by-scheduled-loan.spec.js diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php index 467fd97b1..d36741a89 100644 --- a/app/Controllers/ReservationsController.php +++ b/app/Controllers/ReservationsController.php @@ -330,11 +330,7 @@ public function createReservation($request, $response, $args) if (!$capacity->hasFreeCapacity($bookId, $startDate, $endDate, excludeUserId: $userId)) { $conflictDates = $this->capacityConflictDates($capacity, $bookId, $requestedDates, $userId); - $response->getBody()->write(json_encode([ - 'success' => false, - 'message' => __('Nessuna copia disponibile nelle date richieste'), - 'conflict_dates' => $conflictDates - ])); + $response->getBody()->write(json_encode($this->capacityConflictPayload($startDate, $conflictDates))); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } @@ -406,11 +402,7 @@ public function createReservation($request, $response, $args) if (!$capacity->hasFreeCapacity($bookId, $startDate, $endDate, excludeUserId: $userId)) { $postLockConflicts = $this->capacityConflictDates($capacity, $bookId, $requestedDates, $userId); $this->db->rollback(); - $response->getBody()->write(json_encode([ - 'success' => false, - 'message' => __('Nessuna copia disponibile nelle date richieste'), - 'conflict_dates' => $postLockConflicts - ])); + $response->getBody()->write(json_encode($this->capacityConflictPayload($startDate, $postLockConflicts))); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } @@ -710,6 +702,48 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId); } + /** + * The 400 body for a request whose window runs into a day without a free + * copy. When the start day itself is free, the first blocked day bounds the + * loan: the message then names the longest loan still possible from that + * start ("maximum N days, the book is already booked from D") instead of + * the bare "no copies on the requested dates", and `max_end_date` / + * `max_duration_days` let a client propose that return date. When the + * start day is the blocked one there is nothing to shorten, so the generic + * message stays. + * + * @param string[] $conflictDates + * @return array + */ + private function capacityConflictPayload(string $startDate, array $conflictDates): array + { + $payload = [ + 'success' => false, + 'error' => 'no_capacity', + 'message' => __('Nessuna copia disponibile nelle date richieste'), + 'conflict_dates' => $conflictDates, + ]; + $firstConflict = $conflictDates === [] ? null : min($conflictDates); + if ($firstConflict === null || $firstConflict <= $startDate) { + return $payload; + } + $maxEnd = (new DateTime($firstConflict))->modify('-1 day')->format('Y-m-d'); + $maxDays = (int) (new DateTime($startDate))->diff(new DateTime($maxEnd))->days; + // Same day layout the public calendar shows (its altFormat). + $display = static fn (string $ymd): string => (new DateTime($ymd)) + ->format(str_starts_with(\App\Support\I18n::getLocale(), 'en') ? 'm-d-Y' : 'd-m-Y'); + $payload['message'] = __( + 'Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.', + $maxDays, + $display($maxEnd), + $display($firstConflict) + ); + $payload['first_conflict_date'] = $firstConflict; + $payload['max_end_date'] = $maxEnd; + $payload['max_duration_days'] = $maxDays; + return $payload; + } + /** * Days within $requestedDates for which the book has no free capacity, per the * canonical CapacityService gate. Used only to enrich the client error payload diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index b70108f4e..cb9e22577 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -1584,6 +1584,41 @@ function addDaysToIso(isoDate, days) { return date.toISOString().slice(0, 10); } + function daysBetweenIso(fromIso, toIso) { + const parse = (iso) => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || ''); + return m ? Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : NaN; + }; + const a = parse(fromIso); + const b = parse(toIso); + if (Number.isNaN(a) || Number.isNaN(b)) return NaN; + return Math.round((b - a) / 86400000); + } + + // The loan the visitor can still ask for from a given start: the default + // duration, or less when a scheduled loan or a reservation on every copy + // begins before that default return date. The server applies the same + // default when end_date is omitted and rejects a window that crosses a + // blocked day, so without this cap a request sent without touching the + // calendars came back "no copies available" even though a shorter loan + // was possible, and the visitor was never told how much shorter. + function loanWindowFrom(startIso, availabilityByDate, defaultDays) { + let firstBlocked = null; + for (let i = 1; i <= defaultDays; i++) { + const day = addDaysToIso(startIso, i); + const info = availabilityByDate[day]; + if (info && info.state !== 'free') { + firstBlocked = day; + break; + } + } + if (firstBlocked === null) { + return { endDate: addDaysToIso(startIso, defaultDays), maxDays: defaultDays, firstBlocked: null, capped: false }; + } + const endDate = addDaysToIso(firstBlocked, -1); + return { endDate, maxDays: daysBetweenIso(startIso, endDate), firstBlocked, capped: true }; + } + async function updateReservationsBadge() { const badge = document.getElementById('nav-res-count'); if (!badge) return; @@ -1683,6 +1718,29 @@ function addDaysToIso(isoDate, days) { }; const infoText = __('Le date rosse o arancioni non sono disponibili. La richiesta verrà valutata da un amministratore.'); + const loanLimitText = (loanWindow) => __( + 'Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.', + loanWindow.maxDays, + formatDateIT(loanWindow.endDate), + formatDateIT(loanWindow.firstBlocked) + ); + // Cap the return picker at the day before the first blocked day and + // say so under the field; from a start with no conflict ahead the + // picker is free again and the note disappears. + const applyLoanWindow = (startIso) => { + const loanWindow = loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays); + const note = document.getElementById('swal-loan-limit'); + if (endPicker) { + endPicker.set('minDate', startIso); + endPicker.set('maxDate', loanWindow.capped ? loanWindow.endDate : undefined); + endPicker.setDate(loanWindow.endDate, true); + } + if (note) { + note.textContent = loanWindow.capped ? loanLimitText(loanWindow) : ''; + note.hidden = !loanWindow.capped; + } + return loanWindow; + }; let startPicker = null; let endPicker = null; @@ -1698,6 +1756,7 @@ function addDaysToIso(isoDate, days) { `
`+ ``+ ``+ + ``+ `
`+ `

`+ ``+ @@ -1787,11 +1846,7 @@ function addDaysToIso(isoDate, days) { ...baseOpts, onChange: function(selectedDates, dateStr, instance) { if (selectedDates.length > 0 && dateStr) { - const endDate = addDaysToIso(dateStr, defaultRequestLoanDays); - if (endPicker) { - endPicker.setDate(endDate, true); - endPicker.set('minDate', dateStr); - } + applyLoanWindow(dateStr); } } }); @@ -1802,10 +1857,11 @@ function addDaysToIso(isoDate, days) { // baseOpts.defaultDate is the start date: inherited here, it made a // request sent without touching the calendars a one-day loan. The // start picker's onChange only fires on a click, so the default - // return date has to be set here too. - defaultDate: addDaysToIso(suggestedDate, defaultRequestLoanDays), - maxDate: undefined // End date can extend beyond availability range + // return date is set by applyLoanWindow() right below. + defaultDate: undefined, + maxDate: undefined }); + applyLoanWindow(suggestedDate); if (startPicker.altInput) { startPicker.altInput.id = 'swal-date-start-display'; @@ -1831,6 +1887,14 @@ function addDaysToIso(isoDate, days) { Swal.showValidationMessage(__('Seleziona una data di inizio')); return false; } + // A return date past the first blocked day (or no return date, which + // the server fills with the full default) would be refused anyway: + // say how long the loan can be instead of sending it. + const loanWindow = loanWindowFrom(startDate, availabilityByDate, defaultRequestLoanDays); + if (loanWindow.capped && (!endDate || endDate > loanWindow.endDate)) { + Swal.showValidationMessage(loanLimitText(loanWindow)); + return false; + } return { startDate, endDate }; } }); diff --git a/locale/da_DK.json b/locale/da_DK.json index c9ff8b2dd..fdf656442 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8373,5 +8373,6 @@ "Revoca tutti i link dell’app": "Tilbagekald alle appens links", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Hvis den samme fil også er vedhæftet andre poster, gælder den mest åbne synlighed blandt disse poster.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Administration af digitale vedhæftninger omfatter også åbning af begrænsede vedhæftninger: den, der kan ændre en fils synlighed, kan alligevel åbne den, så de to tilladelser gemmes sammen.", - "Diritti esistenti": "Eksisterende rettigheder" + "Diritti esistenti": "Eksisterende rettigheder", + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maksimal lånetid: %1$d dage (aflevering senest %2$s). Fra %3$s er bogen allerede reserveret." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 60433b038..27ddcb942 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8373,5 +8373,6 @@ "Revoca tutti i link dell’app": "Alle App-Links widerrufen", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Ist dieselbe Datei auch an andere Datensätze angehängt, gilt die offenste Sichtbarkeit unter diesen Datensätzen.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Die Verwaltung digitaler Anhänge umfasst auch das Öffnen geschützter Anhänge: Wer die Sichtbarkeit einer Datei ändern kann, kann sie ohnehin öffnen, daher werden beide Berechtigungen gemeinsam gespeichert.", - "Diritti esistenti": "Bisherige Rechte" + "Diritti esistenti": "Bisherige Rechte", + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximale Leihdauer: %1$d Tage (Rückgabe bis %2$s). Ab dem %3$s ist das Buch bereits vorgemerkt." } diff --git a/locale/en_US.json b/locale/en_US.json index 114f36767..7dcf76ece 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8373,5 +8373,6 @@ "Revoca tutti i link dell’app": "Revoke all app links", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "If the same file is also attached to other records, the most open visibility among those records applies.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Managing digital attachments includes opening restricted attachments: whoever can change a file's visibility can open it anyway, so the two permissions are saved together.", - "Diritti esistenti": "Existing rights" + "Diritti esistenti": "Existing rights", + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximum loan length: %1$d days (return by %2$s). The book is already booked from %3$s." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index de529e362..ce44bc43e 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8373,5 +8373,6 @@ "Revoca tutti i link dell’app": "Révoquer tous les liens de l’application", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Si le même fichier est aussi joint à d’autres notices, la visibilité la plus ouverte parmi ces notices s’applique.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "La gestion des pièces jointes numériques comprend aussi l’ouverture des pièces jointes réservées : qui peut modifier la visibilité d’un fichier peut de toute façon l’ouvrir, les deux autorisations sont donc enregistrées ensemble.", - "Diritti esistenti": "Droits existants" + "Diritti esistenti": "Droits existants", + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durée maximale du prêt : %1$d jours (retour avant le %2$s). À partir du %3$s, le livre est déjà réservé." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 1c73a5189..db8274c22 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8373,5 +8373,6 @@ "Revoca tutti i link dell’app": "Revoca tutti i link dell’app", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.", - "Diritti esistenti": "Diritti esistenti" + "Diritti esistenti": "Diritti esistenti", + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato." } diff --git a/public/assets/css/swal-theme.css b/public/assets/css/swal-theme.css index f0ff6a14a..52badf2f4 100644 --- a/public/assets/css/swal-theme.css +++ b/public/assets/css/swal-theme.css @@ -341,6 +341,21 @@ body.swal2-shown { color: var(--primary-text, var(--primary-color, #d70161)); } +/* Under the return-date field: the longest loan still possible when a + scheduled loan or reservation begins before the default return date. */ +.loan-request-limit { + margin: .45rem 0 0; + color: #b45309; + font-size: .8rem; + font-weight: 600; + line-height: 1.45; + text-align: left; +} + +.loan-request-limit[hidden] { + display: none; +} + .loan-request-actions { justify-content: flex-start !important; gap: .65rem !important; diff --git a/tests/loan-request-capped-by-scheduled-loan.spec.js b/tests/loan-request-capped-by-scheduled-loan.spec.js new file mode 100644 index 000000000..f6677ac64 --- /dev/null +++ b/tests/loan-request-capped-by-scheduled-loan.spec.js @@ -0,0 +1,212 @@ +// @ts-check +// Regression: when a scheduled loan (or a reservation on every copy) begins +// before the default return date, the public request calendar used to propose +// start + the default duration anyway. flatpickr silently dropped that date +// because it fell on a disabled day, the request went out without end_date, +// and the server rejected it with a bare "no copies available" although a +// shorter loan was possible. The calendar must now cap the return date at the +// day before the conflict and say how long the loan can be; the server's +// rejection must carry the same figures for clients that skip the calendar. +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; + +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_HOST = process.env.E2E_DB_HOST || ''; +const DB_PORT = process.env.E2E_DB_PORT || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; + +// The seeded loan starts this many days after today: inside the default +// duration, so it must shorten the proposed loan to CONFLICT_OFFSET - 1 days. +const CONFLICT_OFFSET = 5; + +test.skip( + !ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_NAME, + 'E2E credentials not configured (set E2E_ADMIN_EMAIL, E2E_ADMIN_PASS, E2E_DB_USER, E2E_DB_NAME)', +); + +/** + * Runs one statement against the test database (TCP when E2E_DB_HOST is set, else the + * socket) and returns its tab-separated output, trimmed. + * @param {string} sql + * @returns {string} + */ +function dbQuery(sql) { + const args = []; + if (DB_HOST) { + args.push('-h', DB_HOST); + if (DB_PORT) args.push('-P', DB_PORT); + } else if (DB_SOCKET) { + args.push('-S', DB_SOCKET); + } + args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql); + return execFileSync('mysql', args, { + encoding: 'utf-8', timeout: 10000, + env: { ...process.env, MYSQL_PWD: DB_PASS }, + }).trim(); +} + +/** + * A loans setting as a positive integer, or the fallback the server uses when it is + * missing or below 1. + * @param {string} key + * @param {number} fallback + * @returns {number} + */ +function setting(key, fallback) { + const value = parseInt( + dbQuery(`SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='${key}' LIMIT 1`) || '', + 10, + ); + return Number.isFinite(value) && value >= 1 ? value : fallback; +} + +/** + * yyyy-mm-dd plus a number of days, computed in UTC so no time zone shifts the day. + * @param {string} iso + * @param {number} days + * @returns {string} + */ +function addDays(iso, days) { + const [y, m, d] = iso.split('-').map(Number); + const date = new Date(Date.UTC(y, m - 1, d)); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +test.describe('Book page: loan request capped by a scheduled loan', () => { + let bookId = 0; + let copyId = 0; + let seededLoanId = 0; + let today = ''; + let defaultDays = 0; + + test.beforeAll(() => { + defaultDays = Math.min(setting('loan_duration_days', 30), setting('max_loan_duration_days', 90)); + test.skip(defaultDays <= CONFLICT_OFFSET, `Default loan duration must exceed ${CONFLICT_OFFSET} days for this scenario`); + + // A title with exactly one lendable copy and nothing in circulation on it, + // so the seeded scheduled loan is the only thing blocking the calendar. + bookId = parseInt( + dbQuery( + "SELECT c.libro_id FROM copie c JOIN libri l ON l.id=c.libro_id " + + "WHERE l.deleted_at IS NULL " + + "AND NOT EXISTS (SELECT 1 FROM prestiti p WHERE p.libro_id=c.libro_id AND p.attivo=1) " + + "AND NOT EXISTS (SELECT 1 FROM prenotazioni r WHERE r.libro_id=c.libro_id AND r.stato='attiva') " + + "GROUP BY c.libro_id HAVING COUNT(*)=1 AND SUM(c.stato='disponibile')=1 LIMIT 1", + ) || '0', + 10, + ); + if (!bookId) return; + copyId = parseInt(dbQuery(`SELECT id FROM copie WHERE libro_id=${bookId} LIMIT 1`) || '0', 10); + today = dbQuery('SELECT CURDATE()'); + + // Another patron's scheduled loan: the admin's own commitments never block + // the admin, so the borrower must be someone else when one exists. + const borrowerId = parseInt( + dbQuery(`SELECT id FROM utenti WHERE email<>'${ADMIN_EMAIL.replace(/'/g, "''")}' ORDER BY id LIMIT 1`) + || dbQuery(`SELECT id FROM utenti WHERE email='${ADMIN_EMAIL.replace(/'/g, "''")}' LIMIT 1`) + || '0', + 10, + ); + if (!borrowerId) { bookId = 0; return; } + + const loanStart = addDays(today, CONFLICT_OFFSET); + const loanEnd = addDays(today, CONFLICT_OFFSET + 14); + dbQuery( + `INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) ` + + `VALUES (${bookId}, ${copyId}, ${borrowerId}, '${loanStart}', '${loanEnd}', 'prenotato', 'prenotazione', 1)`, + ); + seededLoanId = parseInt(dbQuery(`SELECT id FROM prestiti WHERE libro_id=${bookId} AND utente_id=${borrowerId} ORDER BY id DESC LIMIT 1`) || '0', 10); + }); + + test.afterAll(() => { + if (seededLoanId) { + dbQuery(`DELETE FROM prestiti WHERE id=${seededLoanId}`); + dbQuery(`DELETE FROM prestiti WHERE libro_id=${bookId} AND utente_id=(SELECT id FROM utenti WHERE email='${ADMIN_EMAIL.replace(/'/g, "''")}' LIMIT 1) AND data_prestito>='${today}'`); + dbQuery(`UPDATE copie SET stato='disponibile' WHERE id=${copyId}`); + } + }); + + // Runs first: the calendar test below ends by creating the admin's own loan. + test('the server rejection names the maximum loan length and the blocked day', async ({ page }) => { + test.skip(!bookId || !seededLoanId, 'No single-copy book free of circulation to seed the scenario on'); + + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/(\/$|admin|profilo|account)/, { timeout: 15000 }).catch(() => {}); + await page.goto(`${BASE}/libro/${bookId}`); + + // A client that skips the calendar and asks for the full default window. + const result = await page.evaluate(async ({ id, start, end }) => { + const meta = document.querySelector('meta[name="csrf-token"]'); + const csrf = meta ? meta.getAttribute('content') : ''; + const base = /^\/en\//.test(location.pathname) ? '/en/api/book' : '/api/libro'; + const res = await fetch(`${base}/${id}/reservation`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ start_date: start, end_date: end, csrf_token: csrf }), + }); + return { status: res.status, body: await res.json() }; + }, { id: bookId, start: today, end: addDays(today, CONFLICT_OFFSET + 1) }); + + expect(result.status).toBe(400); + expect(result.body.success).toBe(false); + expect(result.body.first_conflict_date).toBe(addDays(today, CONFLICT_OFFSET)); + expect(result.body.max_end_date).toBe(addDays(today, CONFLICT_OFFSET - 1)); + expect(result.body.max_duration_days).toBe(CONFLICT_OFFSET - 1); + expect(result.body.message).toContain(String(CONFLICT_OFFSET - 1)); + }); + + test('the return date stops the day before the scheduled loan and the limit is explained', async ({ page }) => { + test.skip(!bookId || !seededLoanId, 'No single-copy book free of circulation to seed the scenario on'); + + const expectedEnd = addDays(today, CONFLICT_OFFSET - 1); + const expectedDays = CONFLICT_OFFSET - 1; + + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/(\/$|admin|profilo|account)/, { timeout: 15000 }).catch(() => {}); + + await page.goto(`${BASE}/libro/${bookId}`); + const btn = page.locator('#btn-request-loan'); + test.skip((await btn.count()) === 0, 'This book has no request-loan button'); + + await btn.click(); + await page.waitForSelector('.swal2-popup', { timeout: 8000 }); + await page.waitForFunction( + () => { + const start = document.querySelector('#swal-date-start'); + const end = document.querySelector('#swal-date-end'); + return start && end && /** @type {any} */ (start)._flatpickr && /** @type {any} */ (end)._flatpickr; + }, + { timeout: 8000 }, + ); + + expect(await page.locator('#swal-date-start').inputValue()).toBe(today); + // Not start + default duration: the day before the other patron's loan. + expect(await page.locator('#swal-date-end').inputValue()).toBe(expectedEnd); + await expect(page.locator('#swal-date-end-display')).not.toHaveValue(''); + + const note = page.locator('#swal-loan-limit'); + await expect(note).toBeVisible(); + await expect(note).toContainText(String(expectedDays)); + + // The capped return date is accepted and the request goes through. + const createResponse = page.waitForResponse((r) => /\/reservation$/.test(r.url()) && r.request().method() === 'POST'); + await page.locator('.swal2-confirm').click(); + const created = await (await createResponse).json(); + expect(created.success, JSON.stringify(created)).toBe(true); + await page.waitForSelector('.swal2-popup.swal2-icon-success', { timeout: 8000 }); + }); +}); From bd0980e29751da0e34555dcff86a2261279b6999 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:01:06 +0000 Subject: [PATCH 04/26] Explain the capped loan window on every request surface, with tests The first commit capped the public calendar. The same capacity gate also refuses the user-area loan and reservation forms (which the Mobile API delegates to) and the admin loan form, each with its own bare "no copies available" even when a shorter loan was possible. LoanWindow (app/Support) is now the one authority: from the blocked days CapacityService reports it derives the first blocked day, the latest return date and the maximum duration, words them in the current locale, and carries them through a 302 as query parameters that are validated before being echoed. The public endpoint, POST /user/loan, POST /user/reserve, the Mobile API's not_available error (message plus details) and the admin create form all read the same figures. The public calendar now also bounds the return picker by the configured maximum duration, searches for a conflict through that whole range so a manual pick cannot jump past a scheduled loan to a day that is free again, refuses an over-long or crossing range before sending, and explains a red or orange day when it is clicked. The admin form proposes the shortened deadline with the same explanation under the field, and only pulls back a deadline staff already typed when it crosses the blocked day. tests/loan-window-cap.unit.php exercises the reservation system against the real database: CapacityService and LoanWindow agree on the window, the availability payload colours the right days, createReservation explains the cap with and without an end date, keeps the generic refusal when the start day is blocked, accepts the capped request, keeps its max-duration, inverted-range and past-start errors, the user-area loan and reservation forms carry the figures through the redirect, a tampered query is ignored, and a second free copy means no cap. --- app/Controllers/PrestitiController.php | 18 +- app/Controllers/ReservationsController.php | 26 +- app/Controllers/UserActionsController.php | 13 +- app/Support/LoanWindow.php | 114 +++++++ app/Views/frontend/book-detail.php | 90 ++++-- app/Views/prestiti/crea_prestito.php | 91 +++++- locale/da_DK.json | 3 +- locale/de_DE.json | 3 +- locale/en_US.json | 3 +- locale/fr_FR.json | 3 +- locale/it_IT.json | 3 +- .../src/Controllers/ActionsController.php | 20 +- tests/loan-window-cap.unit.php | 294 ++++++++++++++++++ 13 files changed, 621 insertions(+), 60 deletions(-) create mode 100644 app/Support/LoanWindow.php create mode 100644 tests/loan-window-cap.unit.php diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index a15e15b7b..5ffe1e371 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -22,6 +22,20 @@ */ class PrestitiController { + /** + * The create-form redirect for a window with no free copy on every day. + * When the start day is free, the query also carries the longest loan + * still possible (LoanWindow::redirectParams()), so the form can say + * "maximum N days, booked from D" instead of asking staff to wait for a + * return that is not what blocks them. + */ + private function noCapacityRedirect($response, \App\Services\CapacityService $capacity, int $libroId, string $start, string $end) + { + $blocked = $capacity->unavailableDatesInRange($libroId, $start, $end); + $query = http_build_query(['error' => 'no_copies_available'] + \App\Support\LoanWindow::redirectParams($start, $blocked)); + return $response->withHeader('Location', url('/admin/loans/create') . '?' . $query)->withStatus(302); + } + /** * Display the loans list page with filtering and pending loans widget * @@ -480,7 +494,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons $capacity = new \App\Services\CapacityService($db); if (!$capacity->hasFreeCapacity($libro_id, $data_prestito, $data_scadenza)) { $db->rollback(); - return $response->withHeader('Location', url('/admin/loans/create') . '?error=no_copies_available')->withStatus(302); + return $this->noCapacityRedirect($response, $capacity, $libro_id, $data_prestito, $data_scadenza); } $selectedCopy = ['id' => $forcedCopyId]; @@ -494,7 +508,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons $capacity = new \App\Services\CapacityService($db); if (!$capacity->hasFreeCapacity($libro_id, $data_prestito, $data_scadenza)) { $db->rollback(); - return $response->withHeader('Location', url('/admin/loans/create') . '?error=no_copies_available')->withStatus(302); + return $this->noCapacityRedirect($response, $capacity, $libro_id, $data_prestito, $data_scadenza); } // Pick a physical copy free for the whole interval. A copy may be diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php index d36741a89..4234334d2 100644 --- a/app/Controllers/ReservationsController.php +++ b/app/Controllers/ReservationsController.php @@ -710,7 +710,8 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int * the bare "no copies on the requested dates", and `max_end_date` / * `max_duration_days` let a client propose that return date. When the * start day is the blocked one there is nothing to shorten, so the generic - * message stays. + * message stays. The figures and the wording come from LoanWindow, shared + * with the user-area forms, the Mobile API and the admin loan form. * * @param string[] $conflictDates * @return array @@ -723,25 +724,16 @@ private function capacityConflictPayload(string $startDate, array $conflictDates 'message' => __('Nessuna copia disponibile nelle date richieste'), 'conflict_dates' => $conflictDates, ]; - $firstConflict = $conflictDates === [] ? null : min($conflictDates); - if ($firstConflict === null || $firstConflict <= $startDate) { + $cap = \App\Support\LoanWindow::cap($startDate, $conflictDates); + if ($cap === null) { return $payload; } - $maxEnd = (new DateTime($firstConflict))->modify('-1 day')->format('Y-m-d'); - $maxDays = (int) (new DateTime($startDate))->diff(new DateTime($maxEnd))->days; - // Same day layout the public calendar shows (its altFormat). - $display = static fn (string $ymd): string => (new DateTime($ymd)) - ->format(str_starts_with(\App\Support\I18n::getLocale(), 'en') ? 'm-d-Y' : 'd-m-Y'); - $payload['message'] = __( - 'Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.', - $maxDays, - $display($maxEnd), - $display($firstConflict) + $payload['message'] = \App\Support\LoanWindow::explain( + $cap['max_duration_days'], + $cap['max_end_date'], + $cap['first_conflict_date'] ); - $payload['first_conflict_date'] = $firstConflict; - $payload['max_end_date'] = $maxEnd; - $payload['max_duration_days'] = $maxDays; - return $payload; + return $payload + $cap; } /** diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php index 2ca3b3510..2dca5a0e6 100644 --- a/app/Controllers/UserActionsController.php +++ b/app/Controllers/UserActionsController.php @@ -741,7 +741,14 @@ public function loan(Request $request, Response $response, mysqli $db): Response // Pass utenteId to exclude their own reservation from blocking the loan request if (!$reservationManager->isBookAvailableForImmediateLoan($libroId, $data_prestito, $data_scadenza, $utenteId)) { $db->rollback(); - return $this->back($response, ['loan_error' => 'not_available']); + // This form has no return-date field, so the reader cannot + // shorten the loan here: say how long it could be and from + // which day the book is already booked (LoanWindow), so the + // page and the Mobile API can explain instead of a bare refusal. + $blocked = (new \App\Services\CapacityService($db)) + ->unavailableDatesInRange($libroId, $data_prestito, $data_scadenza, $utenteId); + return $this->back($response, ['loan_error' => 'not_available'] + + \App\Support\LoanWindow::redirectParams($data_prestito, $blocked)); } // Check for existing active loan from this user for this book: @@ -1016,7 +1023,9 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo } if (!$capacity->hasFreeCapacity($libroId, $start, $end, excludeUserId: $utenteId)) { $db->rollback(); - return $this->back($response, ['reserve_error' => 'not_available']); + $blocked = $capacity->unavailableDatesInRange($libroId, $start, $end, $utenteId); + return $this->back($response, ['reserve_error' => 'not_available'] + + \App\Support\LoanWindow::redirectParams($start, $blocked)); } // Calculate queue position diff --git a/app/Support/LoanWindow.php b/app/Support/LoanWindow.php new file mode 100644 index 000000000..cf0617668 --- /dev/null +++ b/app/Support/LoanWindow.php @@ -0,0 +1,114 @@ + is_string($d) && $d > $startDate); + if ($future === []) { + return null; + } + $firstConflict = min($future); + if (in_array($startDate, $conflictDates, true)) { + return null; + } + $maxEnd = (new DateTime($firstConflict))->modify('-1 day')->format('Y-m-d'); + $maxDays = (int) (new DateTime($startDate))->diff(new DateTime($maxEnd))->days; + + return [ + 'first_conflict_date' => $firstConflict, + 'max_end_date' => $maxEnd, + 'max_duration_days' => $maxDays, + ]; + } + + /** + * The explanation for a capped window, in the current locale, with the + * dates laid out as the public calendar shows them. + */ + public static function explain(int $maxDays, string $maxEndDate, string $firstConflictDate): string + { + return __( + 'Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.', + $maxDays, + self::displayDate($maxEndDate), + self::displayDate($firstConflictDate) + ); + } + + /** + * Redirect parameters that carry a cap through a 302 (user-area forms, + * Mobile API delegation). Empty when nothing bounds the window. + * + * @param string[] $conflictDates + * @return array{max_loan_days?: int, max_end_date?: string, blocked_from?: string} + */ + public static function redirectParams(string $startDate, array $conflictDates): array + { + $cap = self::cap($startDate, $conflictDates); + if ($cap === null) { + return []; + } + return [ + 'max_loan_days' => $cap['max_duration_days'], + 'max_end_date' => $cap['max_end_date'], + 'blocked_from' => $cap['first_conflict_date'], + ]; + } + + /** + * The explanation rebuilt from redirect parameters, or null when they are + * absent or not of the shape redirectParams() emits (query strings are + * user-editable: anything else is ignored, never echoed). + * + * @param array $params + */ + public static function explainFromParams(array $params): ?string + { + $days = $params['max_loan_days'] ?? null; + $end = $params['max_end_date'] ?? null; + $from = $params['blocked_from'] ?? null; + if (!is_scalar($days) || !is_string($end) || !is_string($from)) { + return null; + } + if (preg_match('/^\d{1,4}$/', (string) $days) !== 1 + || !DateHelper::isISODateFormat($end) + || !DateHelper::isISODateFormat($from)) { + return null; + } + return self::explain((int) $days, $end, $from); + } + + private static function displayDate(string $ymd): string + { + $format = str_starts_with(I18n::getLocale(), 'en') ? 'm-d-Y' : 'd-m-Y'; + return (new DateTime($ymd))->format($format); + } +} diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index cb9e22577..219db9dce 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -21,6 +21,7 @@ * @var array $seriesBooks Other books in the same series (collana) * @var string $collana Series/collection name * @var int $defaultRequestLoanDays Effective configured default duration for loan requests + * @var int $maxRequestDays Longest loan the reservation endpoint accepts (max_loan_duration_days) */ // Check if catalogue-only mode is enabled (hides loans, reservations, wishlist) @@ -685,7 +686,8 @@ class="book-cover-large img-fluid pk-book__img - + + @@ -707,7 +709,7 @@ class="book-cover-large img-fluid pk-book__img __('Hai già una prenotazione attiva per questo libro.'), 'invalid_date' => __('Data non valida.'), 'past_date' => __('La data non può essere nel passato.'), - 'not_available' => __('Nessuna copia disponibile per il periodo richiesto.') + 'not_available' => \App\Support\LoanWindow::explainFromParams($_GET) ?? __('Nessuna copia disponibile per il periodo richiesto.') ]; // Defense-in-depth: the values are __() translations from // the i18n catalog (app-controlled), and unknown keys fall @@ -1575,6 +1577,7 @@ function setFavUI(isFav) { const alreadyReservedMsg = ; const appToday = ; const defaultRequestLoanDays = ; + const maxRequestLoanDays = ; function addDaysToIso(isoDate, days) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate || ''); @@ -1602,9 +1605,17 @@ function daysBetweenIso(fromIso, toIso) { // blocked day, so without this cap a request sent without touching the // calendars came back "no copies available" even though a shorter loan // was possible, and the visitor was never told how much shorter. - function loanWindowFrom(startIso, availabilityByDate, defaultDays) { + // + // Two limits bound the return date: the configured maximum duration and + // the first blocked day, searched through that whole maximum so a manual + // pick can never jump past a conflict to a day that is free again. The + // proposed return date is the default duration, or the day before the + // conflict when that comes first (`capped`, which is what the note under + // the field explains). + function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { + const lookAhead = Math.max(defaultDays, maxDays || defaultDays); let firstBlocked = null; - for (let i = 1; i <= defaultDays; i++) { + for (let i = 1; i <= lookAhead; i++) { const day = addDaysToIso(startIso, i); const info = availabilityByDate[day]; if (info && info.state !== 'free') { @@ -1612,11 +1623,17 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays) { break; } } - if (firstBlocked === null) { - return { endDate: addDaysToIso(startIso, defaultDays), maxDays: defaultDays, firstBlocked: null, capped: false }; - } - const endDate = addDaysToIso(firstBlocked, -1); - return { endDate, maxDays: daysBetweenIso(startIso, endDate), firstBlocked, capped: true }; + const maxEndDate = firstBlocked === null ? addDaysToIso(startIso, lookAhead) : addDaysToIso(firstBlocked, -1); + const defaultEnd = addDaysToIso(startIso, defaultDays); + const capped = firstBlocked !== null && maxEndDate < defaultEnd; + const endDate = capped ? maxEndDate : defaultEnd; + return { + endDate, + maxEndDate, + maxDays: daysBetweenIso(startIso, maxEndDate), + firstBlocked, + capped + }; } async function updateReservationsBadge() { @@ -1721,26 +1738,47 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays) { const loanLimitText = (loanWindow) => __( 'Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.', loanWindow.maxDays, - formatDateIT(loanWindow.endDate), + formatDateIT(loanWindow.maxEndDate), formatDateIT(loanWindow.firstBlocked) ); - // Cap the return picker at the day before the first blocked day and - // say so under the field; from a start with no conflict ahead the - // picker is free again and the note disappears. - const applyLoanWindow = (startIso) => { - const loanWindow = loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays); + const maxDurationText = __('Il periodo richiesto supera la durata massima consentita di %d giorni', maxRequestLoanDays); + const blockedDayText = (isoDate, state) => __( + 'Giorno non disponibile (%1$s): %2$s', + formatDateIT(isoDate), + tooltipTexts[state] || tooltipTexts.borrowed + ); + const setLimitNote = (text) => { const note = document.getElementById('swal-loan-limit'); + if (!note) return; + note.textContent = text || ''; + note.hidden = !text; + }; + // Cap the return picker at the day before the first blocked day (or at + // the maximum duration) and say so under the field; from a start with + // no conflict ahead the note disappears. + const applyLoanWindow = (startIso) => { + const loanWindow = loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); if (endPicker) { endPicker.set('minDate', startIso); - endPicker.set('maxDate', loanWindow.capped ? loanWindow.endDate : undefined); + endPicker.set('maxDate', loanWindow.maxEndDate); endPicker.setDate(loanWindow.endDate, true); } - if (note) { - note.textContent = loanWindow.capped ? loanLimitText(loanWindow) : ''; - note.hidden = !loanWindow.capped; - } + setLimitNote(loanWindow.capped ? loanLimitText(loanWindow) : ''); return loanWindow; }; + // A red or orange day cannot be picked; clicking it must still say + // why, and how long a loan from the chosen start can last. + const explainBlockedDay = (isoDate, state) => { + const startIso = (document.getElementById('swal-date-start') || {}).value || ''; + let text = blockedDayText(isoDate, state); + if (/^\d{4}-\d{2}-\d{2}$/.test(startIso)) { + const loanWindow = loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); + if (loanWindow.firstBlocked !== null) { + text += ' ' + loanLimitText(loanWindow); + } + } + setLimitNote(text); + }; let startPicker = null; let endPicker = null; @@ -1814,6 +1852,7 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays) { dayElem.classList.add('flatpickr-disabled'); dayElem.setAttribute('aria-disabled', 'true'); dayElem.tabIndex = -1; + dayElem.addEventListener('click', () => explainBlockedDay(isoDate, info.state)); } if (tooltipTexts[info.state]) { dayElem.setAttribute('title', tooltipTexts[info.state]); @@ -1890,9 +1929,14 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays) { // A return date past the first blocked day (or no return date, which // the server fills with the full default) would be refused anyway: // say how long the loan can be instead of sending it. - const loanWindow = loanWindowFrom(startDate, availabilityByDate, defaultRequestLoanDays); - if (loanWindow.capped && (!endDate || endDate > loanWindow.endDate)) { - Swal.showValidationMessage(loanLimitText(loanWindow)); + if (endDate && endDate < startDate) { + Swal.showValidationMessage(__('La data di fine non può precedere la data di inizio')); + return false; + } + const loanWindow = loanWindowFrom(startDate, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); + const effectiveEnd = endDate || addDaysToIso(startDate, defaultRequestLoanDays); + if (effectiveEnd > loanWindow.maxEndDate) { + Swal.showValidationMessage(loanWindow.firstBlocked !== null ? loanLimitText(loanWindow) : maxDurationText); return false; } return { startDate, endDate }; diff --git a/app/Views/prestiti/crea_prestito.php b/app/Views/prestiti/crea_prestito.php index c60e15bea..f00a216fc 100644 --- a/app/Views/prestiti/crea_prestito.php +++ b/app/Views/prestiti/crea_prestito.php @@ -88,7 +88,14 @@ echo __('La finestra richiesta è già trascorsa: aggiorna le date del prestito prima di approvarlo'); break; case 'no_copies_available': - echo __('Tutte le copie di questo libro hanno già un prestito attivo o prenotato. Attendi che una copia venga restituita.'); + // A window that runs into a scheduled loan or a reservation: name + // the longest loan still possible instead of "wait for a return". + echo htmlspecialchars( + \App\Support\LoanWindow::explainFromParams($_GET) + ?? __('Tutte le copie di questo libro hanno già un prestito attivo o prenotato. Attendi che una copia venga restituita.'), + ENT_QUOTES, + 'UTF-8' + ); break; case 'duplicate_reservation': echo __('Questo utente ha già un prestito o una prenotazione attiva per questo libro.'); @@ -213,6 +220,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te

+
@@ -399,7 +407,8 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-gray-800 focus:ring-gray-500" copyResolved: , copyNotFound: , copyNotAvailable: , - borrowerAlreadyReserved: + borrowerAlreadyReserved: , + loanLimit: }; // F043: base della route di disponibilità risolta per il locale attivo, @@ -417,6 +426,69 @@ function addDaysToIso(isoDate, days) { return date.toISOString().slice(0, 10); } + function daysBetweenIso(fromIso, toIso) { + const parse = (iso) => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || ''); + return m ? Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : NaN; + }; + const a = parse(fromIso); + const b = parse(toIso); + if (Number.isNaN(a) || Number.isNaN(b)) return NaN; + return Math.round((b - a) / 86400000); + } + + // Same rule as the public calendar (book-detail.php) and the server's + // LoanWindow: when a scheduled loan or a reservation on every copy begins + // before the default return date, the proposed deadline stops the day + // before it. Staff keep the picker free to type a later date (they may + // be about to cancel the other commitment), but the hint says why the + // store would refuse the full default. + function loanWindowFrom(startIso, defaultDays) { + let firstBlocked = null; + for (let i = 1; i <= defaultDays; i++) { + const day = addDaysToIso(startIso, i); + const info = availabilityByDate[day]; + if (info && info.state !== 'free') { + firstBlocked = day; + break; + } + } + if (firstBlocked === null) { + return { endDate: addDaysToIso(startIso, defaultDays), maxDays: defaultDays, firstBlocked: null, capped: false }; + } + const endDate = addDaysToIso(firstBlocked, -1); + return { endDate, maxDays: daysBetweenIso(startIso, endDate), firstBlocked, capped: true }; + } + + function formatDisplayDate(iso) { + const parts = (iso || '').split('-'); + if (parts.length !== 3) return iso; + return isItalian ? parts.reverse().join('/') : `${parts[1]}/${parts[2]}/${parts[0]}`; + } + + function applyLoanWindow(startIso, onlyShorten) { + const loanWindow = loanWindowFrom(startIso, defaultLoanDays); + if (fpScadenza) { + fpScadenza.set('minDate', startIso); + // Availability arriving after a deadline was typed must not replace + // it, only pull it back when it crosses the blocked day. + const current = dataScadenzaEl.value || ''; + if (!onlyShorten || current === '' || (loanWindow.capped && current > loanWindow.endDate)) { + fpScadenza.setDate(loanWindow.endDate); + } + } + const hint = document.getElementById('data_scadenza_hint'); + if (hint) { + hint.textContent = loanWindow.capped + ? i18n.loanLimit + .replace('%1$d', String(loanWindow.maxDays)) + .replace('%2$s', formatDisplayDate(loanWindow.endDate)) + .replace('%3$s', formatDisplayDate(loanWindow.firstBlocked)) + : ''; + hint.classList.toggle('hidden', !loanWindow.capped); + } + } + // Book availability data let bookAvailability = { occupiedRanges: [], @@ -557,12 +629,9 @@ function updateConsegnaImmediataVisibility(dateStr) { onDayCreate: colorCalendarDates, onChange: function(selectedDates, dateStr) { if (dateStr) { - // Auto-update end date - const endDate = addDaysToIso(dateStr, defaultLoanDays); - if (fpScadenza) { - fpScadenza.set('minDate', dateStr); - fpScadenza.setDate(endDate); - } + // Auto-update end date, stopping before a scheduled loan or + // reservation that begins inside the default duration. + applyLoanWindow(dateStr); // Update hint about availability updateDateHint(dateStr); @@ -702,6 +771,12 @@ function fetchBookAvailability(bookId) { if (fpPrestito) fpPrestito.redraw(); if (fpScadenza) fpScadenza.redraw(); + // The default deadline was proposed before the availability was + // known: shorten it now if a commitment begins inside the window. + if (dataPrestitoEl.value && !bookAvailability.blockedByReservation) { + applyLoanWindow(dataPrestitoEl.value, true); + } + // Update hint if date is already selected if (bookAvailability.blockedByReservation && dataPrestitoHint) { dataPrestitoHint.textContent = i18n.borrowerAlreadyReserved; diff --git a/locale/da_DK.json b/locale/da_DK.json index fdf656442..c21695a31 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8374,5 +8374,6 @@ "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Hvis den samme fil også er vedhæftet andre poster, gælder den mest åbne synlighed blandt disse poster.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Administration af digitale vedhæftninger omfatter også åbning af begrænsede vedhæftninger: den, der kan ændre en fils synlighed, kan alligevel åbne den, så de to tilladelser gemmes sammen.", "Diritti esistenti": "Eksisterende rettigheder", - "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maksimal lånetid: %1$d dage (aflevering senest %2$s). Fra %3$s er bogen allerede reserveret." + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maksimal lånetid: %1$d dage (aflevering senest %2$s). Fra %3$s er bogen allerede reserveret.", + "Giorno non disponibile (%1$s): %2$s": "Dag ikke tilgængelig (%1$s): %2$s" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 27ddcb942..4e94c3301 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8374,5 +8374,6 @@ "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Ist dieselbe Datei auch an andere Datensätze angehängt, gilt die offenste Sichtbarkeit unter diesen Datensätzen.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Die Verwaltung digitaler Anhänge umfasst auch das Öffnen geschützter Anhänge: Wer die Sichtbarkeit einer Datei ändern kann, kann sie ohnehin öffnen, daher werden beide Berechtigungen gemeinsam gespeichert.", "Diritti esistenti": "Bisherige Rechte", - "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximale Leihdauer: %1$d Tage (Rückgabe bis %2$s). Ab dem %3$s ist das Buch bereits vorgemerkt." + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximale Leihdauer: %1$d Tage (Rückgabe bis %2$s). Ab dem %3$s ist das Buch bereits vorgemerkt.", + "Giorno non disponibile (%1$s): %2$s": "Tag nicht verfügbar (%1$s): %2$s" } diff --git a/locale/en_US.json b/locale/en_US.json index 7dcf76ece..0e67a617e 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8374,5 +8374,6 @@ "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "If the same file is also attached to other records, the most open visibility among those records applies.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Managing digital attachments includes opening restricted attachments: whoever can change a file's visibility can open it anyway, so the two permissions are saved together.", "Diritti esistenti": "Existing rights", - "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximum loan length: %1$d days (return by %2$s). The book is already booked from %3$s." + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximum loan length: %1$d days (return by %2$s). The book is already booked from %3$s.", + "Giorno non disponibile (%1$s): %2$s": "Day not available (%1$s): %2$s" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index ce44bc43e..7fc7070b2 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8374,5 +8374,6 @@ "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Si le même fichier est aussi joint à d’autres notices, la visibilité la plus ouverte parmi ces notices s’applique.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "La gestion des pièces jointes numériques comprend aussi l’ouverture des pièces jointes réservées : qui peut modifier la visibilité d’un fichier peut de toute façon l’ouvrir, les deux autorisations sont donc enregistrées ensemble.", "Diritti esistenti": "Droits existants", - "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durée maximale du prêt : %1$d jours (retour avant le %2$s). À partir du %3$s, le livre est déjà réservé." + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durée maximale du prêt : %1$d jours (retour avant le %2$s). À partir du %3$s, le livre est déjà réservé.", + "Giorno non disponibile (%1$s): %2$s": "Jour non disponible (%1$s) : %2$s" } diff --git a/locale/it_IT.json b/locale/it_IT.json index db8274c22..82e1cfda1 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8374,5 +8374,6 @@ "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.", "Diritti esistenti": "Diritti esistenti", - "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato." + "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.", + "Giorno non disponibile (%1$s): %2$s": "Giorno non disponibile (%1$s): %2$s" } diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index d08af1fb7..630639dbb 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -270,7 +270,7 @@ public function requestReservation(Request $request, ResponseInterface $response return $this->reservationCreatedResponse($response, $userId, $bookId); } - return $this->mapActionError($response, (string) ($outcome['loan_error'] ?? 'db')); + return $this->mapActionError($response, (string) ($outcome['loan_error'] ?? 'db'), $outcome); } $delegatedBody = ['libro_id' => $bookId]; @@ -285,7 +285,7 @@ public function requestReservation(Request $request, ResponseInterface $response return $this->reservationCreatedResponse($response, $userId, $bookId); } - return $this->mapActionError($response, (string) ($outcome['reserve_error'] ?? 'db')); + return $this->mapActionError($response, (string) ($outcome['reserve_error'] ?? 'db'), $outcome); } catch (\Throwable $e) { SecureLogger::error('[MobileApi] request reservation failed: ' . $e->getMessage()); return ResponseEnvelope::error($response, 'internal_error', __('Richiesta non disponibile.'), 500); @@ -1190,12 +1190,26 @@ private function redirectOutcome(ResponseInterface $result): array return $flat; } - private function mapActionError(ResponseInterface $response, string $code): ResponseInterface + /** + * @param array $outcome The redirect's query parameters: a + * capacity refusal may carry the longest loan still possible + * (LoanWindow::redirectParams()), surfaced as the message and as + * details so the app can propose that return date. + */ + private function mapActionError(ResponseInterface $response, string $code, array $outcome = []): ResponseInterface { switch ($code) { case 'duplicate': return ResponseEnvelope::error($response, 'duplicate', __('Hai già una richiesta attiva per questo libro.'), 409); case 'not_available': + $explained = \App\Support\LoanWindow::explainFromParams($outcome); + if ($explained !== null) { + return ResponseEnvelope::error($response, 'not_available', $explained, 409, [ + 'max_duration_days' => (int) $outcome['max_loan_days'], + 'max_end_date' => $outcome['max_end_date'], + 'first_conflict_date' => $outcome['blocked_from'], + ]); + } return ResponseEnvelope::error($response, 'not_available', __('Il libro non è disponibile per il periodo richiesto.'), 409); case 'max_loans_reached': return ResponseEnvelope::error($response, 'max_loans_reached', __('Hai raggiunto il numero massimo di prestiti attivi.'), 409); diff --git a/tests/loan-window-cap.unit.php b/tests/loan-window-cap.unit.php new file mode 100644 index 000000000..9d6663e2a --- /dev/null +++ b/tests/loan-window-cap.unit.php @@ -0,0 +1,294 @@ +set_charset('utf8mb4'); + DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — loan window suite is mandatory: {$e->getMessage()}\n"); + exit(1); +} + +$run = bin2hex(random_bytes(5)); +$prefix = "ZZ_LWCAP_{$run}"; +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label, string $detail = '') use (&$passed, &$failed): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . ($ok || $detail === '' ? '' : " [{$detail}]") . PHP_EOL; + $ok ? $passed++ : $failed++; +}; + +/** @var int[] $bookIds */ +$bookIds = []; +/** @var int[] $userIds */ +$userIds = []; +$cleanup = static function () use ($db, &$bookIds, &$userIds): void { + foreach ($bookIds as $id) { + $db->query("DELETE FROM prestiti WHERE libro_id = {$id}"); + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$id}"); + $db->query("DELETE FROM copie WHERE libro_id = {$id}"); + $db->query("DELETE FROM libri WHERE id = {$id}"); + } + foreach ($userIds as $id) { + $db->query("DELETE FROM utenti WHERE id = {$id}"); + } +}; +set_exception_handler(static function (Throwable $e) use ($cleanup, $db): void { + try { $cleanup(); } catch (Throwable) {} + fwrite(STDERR, 'FAIL: uncaught ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . PHP_EOL); + $db->close(); + exit(1); +}); + +// ── Sandbox ────────────────────────────────────────────────────────────────── +$mkUser = static function (int $n) use ($db, $prefix, &$userIds): int { + $card = "{$prefix}U{$n}"; + $email = strtolower("{$prefix}-u{$n}@example.invalid"); + $cognome = "{$prefix} {$n}"; + $stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) + VALUES (?, 'LoanWindow', ?, ?, 'x', 'attivo', 'standard', 1)"); + $stmt->bind_param('sss', $card, $cognome, $email); + $stmt->execute(); + $stmt->close(); + $userIds[] = (int) $db->insert_id; + return (int) $db->insert_id; +}; +$mkBook = static function (int $copies, int $n) use ($db, $prefix, &$bookIds): array { + $title = "{$prefix} book {$n}"; + $stmt = $db->prepare("INSERT INTO libri (titolo, copie_totali, copie_disponibili, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())"); + $stmt->bind_param('sii', $title, $copies, $copies); + $stmt->execute(); + $stmt->close(); + $bookId = (int) $db->insert_id; + $bookIds[] = $bookId; + $copyIds = []; + for ($i = 1; $i <= $copies; $i++) { + $code = "{$prefix}-{$bookId}-C{$i}"; + $stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'disponibile')"); + $stmt->bind_param('is', $bookId, $code); + $stmt->execute(); + $stmt->close(); + $copyIds[] = (int) $db->insert_id; + } + return [$bookId, $copyIds]; +}; +$mkScheduledLoan = static function (int $bookId, int $copyId, int $userId, string $from, string $to) use ($db): int { + $stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo, warning_sent, overdue_notification_sent) + VALUES (?, ?, ?, ?, ?, 'prenotato', 'diretto', 1, 0, 0)"); + $stmt->bind_param('iiiss', $bookId, $copyId, $userId, $from, $to); + $stmt->execute(); + $stmt->close(); + return (int) $db->insert_id; +}; +$addDays = static fn (string $ymd, int $days): string => (new DateTimeImmutable($ymd))->modify(($days >= 0 ? '+' : '') . "{$days} days")->format('Y-m-d'); +/** Drop what a successful request wrote for one patron on one title, so later checks start clean. */ +$resetRequester = static function (int $bookId, int $requesterId, array $copyIds) use ($db): void { + $db->query("DELETE FROM prestiti WHERE libro_id = {$bookId} AND utente_id = {$requesterId}"); + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$bookId} AND utente_id = {$requesterId}"); + // The sandbox's scheduled loans were inserted directly and never changed a + // copy's state, so every copy is physically 'disponibile' again here. + foreach ($copyIds as $copyId) { + $db->query("UPDATE copie SET stato = 'disponibile' WHERE id = {$copyId}"); + } +}; + +$settings = new SettingsRepository($db); +$defaultDays = $settings->loanDurationDays(); +$maxDays = max(1, (int) ($settings->get('loans', 'max_loan_duration_days', '90') ?? 90)); +$defaultDays = min($defaultDays, $maxDays); +// The other patron's loan starts inside the default duration, so it must +// shorten the proposed loan to offset - 1 days. +$offset = max(1, min(5, $defaultDays - 1)); +$today = DateHelper::today(); +$conflictStart = $addDays($today, $offset); +$conflictEnd = $addDays($today, $offset + 14); +$expectedMaxEnd = $addDays($today, $offset - 1); +$expectedMaxDays = $offset - 1; + +$otherUser = $mkUser(1); +$requester = $mkUser(2); +[$singleBook, $singleCopies] = $mkBook(1, 1); +[$doubleBook, $doubleCopies] = $mkBook(2, 2); +$mkScheduledLoan($singleBook, $singleCopies[0], $otherUser, $conflictStart, $conflictEnd); +$mkScheduledLoan($doubleBook, $doubleCopies[0], $otherUser, $conflictStart, $conflictEnd); +echo "Sandbox: today {$today}, scheduled loan {$conflictStart}..{$conflictEnd}, default {$defaultDays} days, max {$maxDays} days" . PHP_EOL; + +$requestCalendar = static function (int $bookId, array $body) use ($db, $requester): array { + $_SESSION['user'] = ['tipo_utente' => 'standard', 'id' => $requester]; + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', "/api/libro/{$bookId}/reservation") + ->withParsedBody($body); + $response = (new ReservationsController($db))->createReservation($request, new SlimResponse(), ['id' => (string) $bookId]); + $decoded = json_decode((string) $response->getBody(), true); + return ['status' => $response->getStatusCode(), 'body' => is_array($decoded) ? $decoded : []]; +}; +$userAction = static function (string $method, int $bookId, array $body) use ($db, $requester): array { + $_SESSION['user'] = ['tipo_utente' => 'standard', 'id' => $requester]; + $_SERVER['HTTP_REFERER'] = "/libro/{$bookId}"; + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', "/user/{$method}") + ->withParsedBody(['libro_id' => $bookId] + $body); + $response = (new UserActionsController())->{$method}($request, new SlimResponse(), $db); + $query = parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY); + $params = []; + parse_str(is_string($query) ? $query : '', $params); + return $params; +}; + +// ═════════ 1-3: the capacity service and the helper agree on the window ══════ +$capacity = new CapacityService($db); +$blocked = $capacity->unavailableDatesInRange($singleBook, $today, $addDays($today, $defaultDays), $requester); +$check($blocked !== [] && min($blocked) === $conflictStart, + '1 CapacityService reports the scheduled loan\'s first day as the first blocked day', + json_encode($blocked)); + +$cap = LoanWindow::cap($today, $blocked); +$check($cap !== null + && $cap['first_conflict_date'] === $conflictStart + && $cap['max_end_date'] === $expectedMaxEnd + && $cap['max_duration_days'] === $expectedMaxDays, + "2 LoanWindow::cap bounds the loan to {$expectedMaxDays} days ending {$expectedMaxEnd}", + json_encode($cap)); + +$check(LoanWindow::cap($conflictStart, $blocked) === null && LoanWindow::cap($today, []) === null, + '3 LoanWindow::cap reports no cap when the start day is blocked or nothing is blocked'); + +// ═════════ 4: the calendar data the pickers colour ═══════════════════════════ +$availability = (new ReservationsController($db))->getBookAvailabilityData($singleBook, null, $offset + 20, $requester); +$byDate = is_array($availability) ? array_column($availability['days'] ?? [], null, 'date') : []; +$check(($byDate[$expectedMaxEnd]['state'] ?? null) === 'free' + && ($byDate[$conflictStart]['state'] ?? null) === 'borrowed' + && in_array($conflictStart, $availability['unavailable_dates'] ?? [], true), + '4 availability payload: free the day before the scheduled loan, borrowed from its first day', + json_encode([$byDate[$expectedMaxEnd] ?? null, $byDate[$conflictStart] ?? null])); + +// ═════════ 5-7: the public calendar endpoint explains the cap ════════════════ +$r = $requestCalendar($singleBook, ['start_date' => $today]); +$check($r['status'] === 400 + && ($r['body']['max_duration_days'] ?? null) === $expectedMaxDays + && ($r['body']['max_end_date'] ?? null) === $expectedMaxEnd + && ($r['body']['first_conflict_date'] ?? null) === $conflictStart + && str_contains((string) ($r['body']['message'] ?? ''), (string) $expectedMaxDays), + '5 createReservation without end_date: 400 with the maximum duration, latest return date and blocked day', + json_encode($r)); + +$r = $requestCalendar($singleBook, ['start_date' => $today, 'end_date' => $addDays($today, $offset + 2)]); +$check($r['status'] === 400 + && ($r['body']['max_end_date'] ?? null) === $expectedMaxEnd + && ($r['body']['error'] ?? null) === 'no_capacity', + '6 createReservation with an end_date crossing the scheduled loan: same figures, error no_capacity', + json_encode($r)); + +$r = $requestCalendar($singleBook, ['start_date' => $conflictStart, 'end_date' => $addDays($conflictStart, 2)]); +$check($r['status'] === 400 + && !array_key_exists('max_end_date', $r['body']) + && ($r['body']['message'] ?? '') === __('Nessuna copia disponibile nelle date richieste'), + '7 createReservation starting on the blocked day: generic refusal, nothing to shorten', + json_encode($r)); + +// ═════════ 8: the capped request goes through ════════════════════════════════ +$r = $requestCalendar($singleBook, ['start_date' => $today, 'end_date' => $expectedMaxEnd]); +$check($r['status'] === 200 && ($r['body']['success'] ?? false) === true, + "8 createReservation ending {$expectedMaxEnd} (the day before the scheduled loan) is accepted", + json_encode($r)); +$resetRequester($singleBook, $requester, $singleCopies); + +// ═════════ 9-10: the other validations keep their own answers ════════════════ +$r = $requestCalendar($singleBook, ['start_date' => $today, 'end_date' => $addDays($today, $maxDays + 1)]); +$check($r['status'] === 400 && ($r['body']['error'] ?? null) === 'max_duration_exceeded', + "9 an end_date beyond max_loan_duration_days ({$maxDays}) is refused as max_duration_exceeded, before any capacity check", + json_encode($r)); + +$r1 = $requestCalendar($singleBook, ['start_date' => $addDays($today, 2), 'end_date' => $today]); +$r2 = $requestCalendar($singleBook, ['start_date' => $addDays($today, -1)]); +$check(($r1['body']['error'] ?? null) === 'invalid_range' && ($r2['body']['error'] ?? null) === 'past_date', + '10 an inverted range and a past start keep their dedicated errors', + json_encode([$r1['body'], $r2['body']])); + +// ═════════ 11-12: the user-area forms carry the explanation through the redirect +$params = $userAction('loan', $singleBook, []); +$check(($params['loan_error'] ?? null) === 'not_available' + && (int) ($params['max_loan_days'] ?? -1) === $expectedMaxDays + && ($params['max_end_date'] ?? null) === $expectedMaxEnd + && ($params['blocked_from'] ?? null) === $conflictStart, + '11 POST /user/loan: loan_error=not_available with max_loan_days, max_end_date and blocked_from', + json_encode($params)); + +$explained = LoanWindow::explainFromParams($params); +$check(is_string($explained) + && str_contains($explained, (string) $expectedMaxDays) + && LoanWindow::explainFromParams(['max_loan_days' => 'x'] + $params) === null, + '12 LoanWindow::explainFromParams rebuilds the message from the redirect, and ignores a tampered query', + (string) $explained); + +$params = $userAction('reserve', $singleBook, ['desired_date' => $today]); +$check(($params['reserve_error'] ?? null) === 'not_available' + && ($params['blocked_from'] ?? null) === $conflictStart, + '13 POST /user/reserve for today: reserve_error=not_available with the same blocked_from', + json_encode($params)); + +// ═════════ 14: a second free copy means no cap ═══════════════════════════════ +$blockedDouble = $capacity->unavailableDatesInRange($doubleBook, $today, $addDays($today, $defaultDays), $requester); +$r = $requestCalendar($doubleBook, ['start_date' => $today]); +$check($blockedDouble === [] && $r['status'] === 200 && ($r['body']['success'] ?? false) === true, + '14 two copies, one scheduled: no blocked day and the full default request is accepted', + json_encode(['blocked' => $blockedDouble, 'response' => $r])); +$resetRequester($doubleBook, $requester, $doubleCopies); + +// ═════════ 15: the public calendar reads the same figures (view contract) ════ +$viewSrc = (string) file_get_contents($root . '/app/Views/frontend/book-detail.php'); +$check(str_contains($viewSrc, 'function loanWindowFrom(') + && str_contains($viewSrc, "endPicker.set('maxDate', loanWindow.maxEndDate)") + && str_contains($viewSrc, 'Durata massima del prestito: %1$d giorni') + && str_contains($viewSrc, 'LoanWindow::explainFromParams($_GET)'), + '15 book-detail.php caps the return picker, explains it with the shared message and renders the redirect explanation'); + +$cleanup(); +$db->close(); + +echo PHP_EOL . "Passed: {$passed} Failed: {$failed}" . PHP_EOL; +exit($failed === 0 ? 0 : 1); From a0be2d15ded36c9324b755abf893717ace817e8f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:03:44 +0000 Subject: [PATCH 05/26] Re-pin the coherence audit to the calendars' loanWindowFrom() construct The audit guarded "loan calendars use the configured duration without local-time date arithmetic" by looking for the literal addDaysToIso(dateStr, defaultLoanDays) / addDaysToIso(dateStr, defaultRequestLoanDays) lines. Both calendars now reach the configured duration through loanWindowFrom(), which also stops the proposed return date before a scheduled loan, with the same UTC-based addDaysToIso() arithmetic underneath. The check now pins that construct in both views. --- tests/loan-coherence-audit.unit.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/loan-coherence-audit.unit.php b/tests/loan-coherence-audit.unit.php index a02102c6d..61e2604b6 100644 --- a/tests/loan-coherence-audit.unit.php +++ b/tests/loan-coherence-audit.unit.php @@ -290,12 +290,17 @@ && str_contains($loanCreateView, 'blockedByReservation: blockedByReservation') && str_contains($loanCreateView, 'borrowerAlreadyReserved'); +// The configured duration reaches both calendars through loanWindowFrom(), +// which also stops the proposed return date before a scheduled loan or a +// reservation; the day arithmetic stays UTC-based (addDaysToIso). $checks['loan calendars use the configured duration without local-time date arithmetic'] = str_contains($loanCreateView, 'const defaultLoanDays =') - && str_contains($loanCreateView, 'addDaysToIso(dateStr, defaultLoanDays)') + && str_contains($loanCreateView, 'loanWindowFrom(startIso, defaultLoanDays)') + && str_contains($loanCreateView, 'addDaysToIso(startIso, defaultDays)') && !str_contains($loanCreateView, 'setMonth(endDate.getMonth() + 1)') && str_contains($frontend, '$defaultRequestLoanDays = min(') - && str_contains($bookDetail, 'addDaysToIso(dateStr, defaultRequestLoanDays)') + && str_contains($bookDetail, 'loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays)') + && str_contains($bookDetail, 'addDaysToIso(startIso, defaultDays)') && !str_contains($bookDetail, 'setMonth(endDate.getMonth() + 1)'); // 21. Server-rendered edit fallback follows the same application clock. From 37d8a4e0bce767e834c873efee3c1260b3674496 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:12:06 +0000 Subject: [PATCH 06/26] Explain the capped window on the remaining surfaces; accept a return date from the app Every other place that refuses a window for lack of a free copy now answers with the same LoanWindow figures: admin approval of a pending request (JSON message plus max_end_date), editing a loan's dates (loans list), creating or editing a reservation in the admin area, and a reader moving their own reservation (account page). CapacityService::unavailableDatesInRange() takes the same loan and reservation exclusions as hasFreeCapacity(), so the row being approved, edited or moved is never reported as its own conflict. The user-area loan and reservation forms accept an optional end_date, bounded like the public endpoint (a real day, not before the start, within max_loan_duration_days, else invalid_date / max_duration_exceeded). The Mobile API passes it through on POST /reservations and maps the new refusal, so the Android app can send the day before a scheduled loan the way the website calendar does instead of being refused outright. OpenAPI documents the field and the not_available meta; plugin 1.8.1 -> 1.8.2. Four more database-backed checks: the capped end_date is accepted by the loan form and by the reservation form, an end_date past the maximum is refused, one before the start is refused. --- app/Controllers/LoanApprovalController.php | 11 +++- app/Controllers/PrestitiController.php | 6 +- .../ReservationsAdminController.php | 10 ++- app/Controllers/UserActionsController.php | 62 ++++++++++++++++++- app/Services/CapacityService.php | 17 +++-- app/Views/prenotazioni/crea_prenotazione.php | 2 +- .../prenotazioni/modifica_prenotazione.php | 2 +- app/Views/prestiti/index.php | 9 ++- app/Views/user_dashboard/prenotazioni.php | 2 +- locale/da_DK.json | 3 +- locale/de_DE.json | 3 +- locale/en_US.json | 3 +- locale/fr_FR.json | 3 +- locale/it_IT.json | 3 +- storage/plugins/mobile-api/plugin.json | 2 +- .../src/Controllers/ActionsController.php | 19 +++++- .../src/Controllers/OpenApiController.php | 5 +- tests/loan-window-cap.unit.php | 27 +++++++- 18 files changed, 164 insertions(+), 25 deletions(-) diff --git a/app/Controllers/LoanApprovalController.php b/app/Controllers/LoanApprovalController.php index ed1ea6a53..b3ec0d7bd 100644 --- a/app/Controllers/LoanApprovalController.php +++ b/app/Controllers/LoanApprovalController.php @@ -428,10 +428,17 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R $capacity = new \App\Services\CapacityService($db); if (!$capacity->hasFreeCapacity($libroId, $today, $dataScadenza, excludePrestitoId: $loanId)) { $db->rollback(); + // Name the longest loan still possible from today when a + // later commitment is what blocks the approval, so staff + // can shorten the request instead of waiting for a return. + $blocked = $capacity->unavailableDatesInRange($libroId, $today, $dataScadenza, null, $loanId); + $cap = \App\Support\LoanWindow::cap($today, $blocked); $response->getBody()->write(json_encode([ 'success' => false, - 'message' => __('Nessuna copia disponibile per il periodo richiesto') - ])); + 'message' => $cap === null + ? __('Nessuna copia disponibile per il periodo richiesto') + : \App\Support\LoanWindow::explain($cap['max_duration_days'], $cap['max_end_date'], $cap['first_conflict_date']), + ] + ($cap ?? []))); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index 5ffe1e371..3160fbcea 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -1116,7 +1116,11 @@ public function update(Request $request, Response $response, mysqli $db, int $id foreach ($claimedWindows as [$claimStart, $claimEnd]) { if (!$capacity->hasFreeCapacity($libroId, $claimStart, $claimEnd, excludePrestitoId: $id)) { $db->rollback(); - return $response->withHeader('Location', url('/admin/loans') . '?error=no_copies_available')->withStatus(302); + // The loans list explains the refusal with the longest + // window still possible from the claimed start. + $blocked = $capacity->unavailableDatesInRange($libroId, $claimStart, $claimEnd, null, $id); + $query = http_build_query(['error' => 'no_copies_available'] + \App\Support\LoanWindow::redirectParams($claimStart, $blocked)); + return $response->withHeader('Location', url('/admin/loans') . '?' . $query)->withStatus(302); } } diff --git a/app/Controllers/ReservationsAdminController.php b/app/Controllers/ReservationsAdminController.php index c13b84a77..e58194116 100644 --- a/app/Controllers/ReservationsAdminController.php +++ b/app/Controllers/ReservationsAdminController.php @@ -291,7 +291,9 @@ public function update(Request $request, Response $response, mysqli $db, int $id } if (!$capacity->hasFreeCapacity($libroId, $dataInizioRichiesta, $dataFineRichiesta, excludeReservationId: $id, excludeUserId: $utenteId)) { $db->rollback(); - return $response->withHeader('Location', url('/admin/reservations/edit/' . $id) . '?error=capacity_full')->withStatus(302); + $blocked = $capacity->unavailableDatesInRange($libroId, $dataInizioRichiesta, $dataFineRichiesta, $utenteId, null, $id); + $query = http_build_query(['error' => 'capacity_full'] + \App\Support\LoanWindow::redirectParams($dataInizioRichiesta, $blocked)); + return $response->withHeader('Location', url('/admin/reservations/edit/' . $id) . '?' . $query)->withStatus(302); } } @@ -604,7 +606,11 @@ public function store(Request $request, Response $response, mysqli $db): Respons } if (!$capacity->hasFreeCapacity($libroId, $dataInizioRichiesta, $dataFineRichiesta, excludeUserId: $utenteId)) { $db->rollback(); - return $response->withHeader('Location', url('/admin/reservations/create') . '?error=capacity_full')->withStatus(302); + // The form explains the refusal with the longest window still + // possible from the requested start (LoanWindow). + $blocked = $capacity->unavailableDatesInRange($libroId, $dataInizioRichiesta, $dataFineRichiesta, $utenteId); + $query = http_build_query(['error' => 'capacity_full'] + \App\Support\LoanWindow::redirectParams($dataInizioRichiesta, $blocked)); + return $response->withHeader('Location', url('/admin/reservations/create') . '?' . $query)->withStatus(302); } $stmt = $db->prepare("SELECT COALESCE(MAX(queue_position), 0) + 1 as position FROM prenotazioni WHERE libro_id = ? AND stato = 'attiva'"); diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php index 2dca5a0e6..a284af3b9 100644 --- a/app/Controllers/UserActionsController.php +++ b/app/Controllers/UserActionsController.php @@ -614,7 +614,12 @@ public function changeReservationDate(Request $request, Response $response, mysq $capacity = new \App\Services\CapacityService($db); if (!$capacity->hasFreeCapacity($libroId, $startDate, $endDate, excludeReservationId: $rid, excludeUserId: $uid)) { $db->rollback(); - return $response->withHeader('Location', RouteTranslator::route('reservations') . '?error=not_available')->withStatus(302); + // Say how long a loan from the new date could be (LoanWindow), + // as every other capacity refusal does. excludeUserId leaves + // out the moved row itself, which belongs to this user. + $blocked = $capacity->unavailableDatesInRange($libroId, $startDate, $endDate, $uid, null, $rid); + $query = http_build_query(['error' => 'not_available'] + \App\Support\LoanWindow::redirectParams($startDate, $blocked)); + return $response->withHeader('Location', RouteTranslator::route('reservations') . '?' . $query)->withStatus(302); } // Update reservation with new dates @@ -720,6 +725,20 @@ public function loan(Request $request, Response $response, mysqli $db): Response $loanDays = 30; } $data_scadenza = date('Y-m-d', strtotime($data_prestito . " +{$loanDays} days")); + // Optional return date (Mobile API 1.8.2): the app proposes the day + // before a scheduled loan or reservation when the default would cross + // it, exactly as the website calendar does. Bounded like the public + // endpoint: a real day, not before the start, within the maximum. + $endOverride = $this->requestedEndDate($db, $data, $data_prestito); + if ($endOverride === false) { + return $this->back($response, ['loan_error' => 'invalid_date']); + } + if ($endOverride === 'max_duration_exceeded') { + return $this->back($response, ['loan_error' => 'max_duration_exceeded']); + } + if ($endOverride !== null) { + $data_scadenza = $endOverride; + } // Use transaction + lock to prevent race conditions $db->begin_transaction(); @@ -963,6 +982,17 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo $loanDays = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'loan_duration_days', '30') ?? 30); $loanDays = $loanDays > 0 ? $loanDays : 30; $end = (new \DateTimeImmutable($start))->modify("+{$loanDays} days")->format('Y-m-d'); + // Optional return date (Mobile API 1.8.2), bounded as in loan(). + $endOverride = $this->requestedEndDate($db, $data, $start); + if ($endOverride === false) { + return $this->back($response, ['reserve_error' => 'invalid_date']); + } + if ($endOverride === 'max_duration_exceeded') { + return $this->back($response, ['reserve_error' => 'max_duration_exceeded']); + } + if ($endOverride !== null) { + $end = $endOverride; + } // Start transaction for concurrency control $db->begin_transaction(); @@ -1085,6 +1115,36 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo return $this->back($response, ['reserve_error' => 'db']); } + /** + * The optional `end_date` of a loan or reservation request: null when + * absent, false when it is not a real day or precedes the start, the + * string 'max_duration_exceeded' when it runs past max_loan_duration_days, + * else the Y-m-d date to use instead of the configured default. + * + * @param array $data + * @return string|false|null + */ + private function requestedEndDate(mysqli $db, array $data, string $startDate): string|false|null + { + $raw = $data['end_date'] ?? null; + if (!is_scalar($raw)) { + return $raw === null ? null : false; + } + $end = trim((string) $raw); + if ($end === '') { + return null; + } + if (!\App\Support\DateHelper::isISODateFormat($end) || $end < $startDate) { + return false; + } + $maxDays = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'max_loan_duration_days', '90') ?? 90); + if ($maxDays < 1) { + $maxDays = 90; + } + $days = (int) (new \DateTimeImmutable($startDate))->diff(new \DateTimeImmutable($end))->days; + return $days > $maxDays ? 'max_duration_exceeded' : $end; + } + private function back(Response $response, array $params): Response { $qs = http_build_query($params); diff --git a/app/Services/CapacityService.php b/app/Services/CapacityService.php index a561c99dd..5cc3031be 100644 --- a/app/Services/CapacityService.php +++ b/app/Services/CapacityService.php @@ -224,8 +224,14 @@ public function firstAvailableDate(int $libroId, string $from): ?string * * @return list Y-m-d dates lacking capacity, ascending */ - public function unavailableDatesInRange(int $libroId, string $start, string $end, ?int $excludeUserId = null): array - { + public function unavailableDatesInRange( + int $libroId, + string $start, + string $end, + ?int $excludeUserId = null, + ?int $excludePrestitoId = null, + ?int $excludeReservationId = null + ): array { $days = $this->enumerateDays($start, $end); if ($days === []) { return []; @@ -235,9 +241,12 @@ public function unavailableDatesInRange(int $libroId, string $start, string $end return $days; // nothing lendable → every requested day conflicts } + // The same exclusions hasFreeCapacity() takes, so a refusal explained + // from this list (LoanWindow) never counts the row being approved, + // edited or moved as its own conflict. $intervals = array_merge( - $this->holdingLoanIntervals($libroId, $start, $end, null, $excludeUserId), - $this->activeReservationIntervals($libroId, $start, $end, null, $excludeUserId) + $this->holdingLoanIntervals($libroId, $start, $end, $excludePrestitoId, $excludeUserId), + $this->activeReservationIntervals($libroId, $start, $end, $excludeReservationId, $excludeUserId) ); $conflicts = []; diff --git a/app/Views/prenotazioni/crea_prenotazione.php b/app/Views/prenotazioni/crea_prenotazione.php index 60bebef10..9f920b733 100644 --- a/app/Views/prenotazioni/crea_prenotazione.php +++ b/app/Views/prenotazioni/crea_prenotazione.php @@ -102,7 +102,7 @@

-

+

diff --git a/app/Views/prenotazioni/modifica_prenotazione.php b/app/Views/prenotazioni/modifica_prenotazione.php index 91645c15a..11ec0680b 100644 --- a/app/Views/prenotazioni/modifica_prenotazione.php +++ b/app/Views/prenotazioni/modifica_prenotazione.php @@ -34,7 +34,7 @@ $errorKey = is_scalar($_GET['error']) ? (string) $_GET['error'] : ''; echo match ($errorKey) { 'duplicate' => __('Questo utente ha già un prestito o una prenotazione attiva per questo libro.'), - 'capacity_full' => __('Tutte le copie sono già impegnate (prestiti o prenotazioni) per il periodo richiesto. Scegli un altro periodo.'), + 'capacity_full' => \App\Support\LoanWindow::explainFromParams($_GET) ?? __('Tutte le copie sono già impegnate (prestiti o prenotazioni) per il periodo richiesto. Scegli un altro periodo.'), 'invalid_status' => __('Stato prenotazione non valido: modifica non salvata.'), 'promoted_loan_active' => __('Questa prenotazione è già stata convertita in un prestito ancora aperto: gestisci il prestito dalla pagina Prestiti invece di modificare la prenotazione.'), 'user_not_found', 'user_suspended', 'card_expired' => \App\Support\LoanEligibility::errorMessage($errorKey), diff --git a/app/Views/prestiti/index.php b/app/Views/prestiti/index.php index bec70a634..42db0635c 100644 --- a/app/Views/prestiti/index.php +++ b/app/Views/prestiti/index.php @@ -82,7 +82,14 @@ case 'no_copies_available': // #336: dire solo "nessuna copia" era fuorviante — il vero motivo // è un conflitto con un altro impegno nel periodo richiesto. - echo __('Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.'); + // Quando il conflitto inizia dopo la data di inizio, il + // redirect porta la finestra massima possibile (LoanWindow). + echo htmlspecialchars( + \App\Support\LoanWindow::explainFromParams($_GET) + ?? __('Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.'), + ENT_QUOTES, + 'UTF-8' + ); break; case 'loan_copy_conflict': echo __('Modifica non salvata: la copia assegnata è già impegnata da un altro prestito nel nuovo periodo.'); diff --git a/app/Views/user_dashboard/prenotazioni.php b/app/Views/user_dashboard/prenotazioni.php index 3884ec303..12a3e1c3b 100644 --- a/app/Views/user_dashboard/prenotazioni.php +++ b/app/Views/user_dashboard/prenotazioni.php @@ -499,7 +499,7 @@ function resolveCoverUrl(array $item, string $key = 'copertina_url'): string { $outcomeErrorMessages = [ 'not_found' => __('Elemento non trovato o non più annullabile: lo stato potrebbe essere cambiato nel frattempo.'), 'past_date' => __('La data richiesta è nel passato.'), - 'not_available' => __('Il libro non è disponibile per il periodo richiesto.'), + 'not_available' => \App\Support\LoanWindow::explainFromParams($_GET) ?? __('Il libro non è disponibile per il periodo richiesto.'), 'book_not_found' => __('Libro non trovato.'), ]; // Normalizza prima del lookup: ?error[]=x arriverebbe come array (warning PHP 8). diff --git a/locale/da_DK.json b/locale/da_DK.json index c21695a31..f9335d1ed 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8375,5 +8375,6 @@ "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Administration af digitale vedhæftninger omfatter også åbning af begrænsede vedhæftninger: den, der kan ændre en fils synlighed, kan alligevel åbne den, så de to tilladelser gemmes sammen.", "Diritti esistenti": "Eksisterende rettigheder", "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maksimal lånetid: %1$d dage (aflevering senest %2$s). Fra %3$s er bogen allerede reserveret.", - "Giorno non disponibile (%1$s): %2$s": "Dag ikke tilgængelig (%1$s): %2$s" + "Giorno non disponibile (%1$s): %2$s": "Dag ikke tilgængelig (%1$s): %2$s", + "Il periodo richiesto supera la durata massima consentita.": "Den ønskede periode overstiger den maksimalt tilladte lånetid." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 4e94c3301..38494c106 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8375,5 +8375,6 @@ "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Die Verwaltung digitaler Anhänge umfasst auch das Öffnen geschützter Anhänge: Wer die Sichtbarkeit einer Datei ändern kann, kann sie ohnehin öffnen, daher werden beide Berechtigungen gemeinsam gespeichert.", "Diritti esistenti": "Bisherige Rechte", "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximale Leihdauer: %1$d Tage (Rückgabe bis %2$s). Ab dem %3$s ist das Buch bereits vorgemerkt.", - "Giorno non disponibile (%1$s): %2$s": "Tag nicht verfügbar (%1$s): %2$s" + "Giorno non disponibile (%1$s): %2$s": "Tag nicht verfügbar (%1$s): %2$s", + "Il periodo richiesto supera la durata massima consentita.": "Der gewünschte Zeitraum überschreitet die maximal zulässige Leihdauer." } diff --git a/locale/en_US.json b/locale/en_US.json index 0e67a617e..96b52b125 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8375,5 +8375,6 @@ "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Managing digital attachments includes opening restricted attachments: whoever can change a file's visibility can open it anyway, so the two permissions are saved together.", "Diritti esistenti": "Existing rights", "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximum loan length: %1$d days (return by %2$s). The book is already booked from %3$s.", - "Giorno non disponibile (%1$s): %2$s": "Day not available (%1$s): %2$s" + "Giorno non disponibile (%1$s): %2$s": "Day not available (%1$s): %2$s", + "Il periodo richiesto supera la durata massima consentita.": "The requested period exceeds the maximum allowed loan length." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 7fc7070b2..43eb771f8 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8375,5 +8375,6 @@ "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "La gestion des pièces jointes numériques comprend aussi l’ouverture des pièces jointes réservées : qui peut modifier la visibilité d’un fichier peut de toute façon l’ouvrir, les deux autorisations sont donc enregistrées ensemble.", "Diritti esistenti": "Droits existants", "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durée maximale du prêt : %1$d jours (retour avant le %2$s). À partir du %3$s, le livre est déjà réservé.", - "Giorno non disponibile (%1$s): %2$s": "Jour non disponible (%1$s) : %2$s" + "Giorno non disponibile (%1$s): %2$s": "Jour non disponible (%1$s) : %2$s", + "Il periodo richiesto supera la durata massima consentita.": "La période demandée dépasse la durée maximale de prêt autorisée." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 82e1cfda1..28eec909c 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8375,5 +8375,6 @@ "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.", "Diritti esistenti": "Diritti esistenti", "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.", - "Giorno non disponibile (%1$s): %2$s": "Giorno non disponibile (%1$s): %2$s" + "Giorno non disponibile (%1$s): %2$s": "Giorno non disponibile (%1$s): %2$s", + "Il periodo richiesto supera la durata massima consentita.": "Il periodo richiesto supera la durata massima consentita." } diff --git a/storage/plugins/mobile-api/plugin.json b/storage/plugins/mobile-api/plugin.json index d70fbe56e..1b0bca983 100644 --- a/storage/plugins/mobile-api/plugin.json +++ b/storage/plugins/mobile-api/plugin.json @@ -2,7 +2,7 @@ "name": "mobile-api", "display_name": "Mobile API", "description": "API REST/JSON versionata (/api/v1) per l'app companion mobile di Pinakes: discovery, autenticazione a token per dispositivo, ricerca catalogo, prestiti/prenotazioni, wishlist, profilo, messaggi e notifiche push. Disattivata per default finché non viene abilitata.", - "version": "1.8.1", + "version": "1.8.2", "author": "Fabiodalez", "author_url": "", "plugin_url": "", diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index 630639dbb..4ac5ffe6d 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -206,6 +206,11 @@ public function requestReservation(Request $request, ResponseInterface $response $body = JsonBody::parse($request); $bookId = Input::int($body['book_id'] ?? $body['libro_id'] ?? 0); $desired = isset($body['desired_date']) ? trim(Input::str($body['desired_date'])) : ''; + // Optional return date (Mobile API 1.8.2): the app sends the day before + // a scheduled loan or reservation when the configured duration would + // cross it, as the website calendar does. The core form validates the + // bounds (not before the start, within max_loan_duration_days). + $endDate = isset($body['end_date']) ? trim(Input::str($body['end_date'])) : ''; if ($bookId <= 0) { return ResponseEnvelope::error($response, 'invalid_book', __('Identificativo libro non valido.'), 422); @@ -213,6 +218,9 @@ public function requestReservation(Request $request, ResponseInterface $response if ($desired !== '' && !DateHelper::isISODateFormat($desired)) { return ResponseEnvelope::error($response, 'invalid_date', __('Data richiesta non valida.'), 422); } + if ($endDate !== '' && !DateHelper::isISODateFormat($endDate)) { + return ResponseEnvelope::error($response, 'invalid_date', __('Data richiesta non valida.'), 422); + } // Domain "today" MUST come from DateHelper::today() (app timezone), not // date() (process tz, often UTC): near midnight they disagree by a day. $today = DateHelper::today(); @@ -237,7 +245,11 @@ public function requestReservation(Request $request, ResponseInterface $response } if ($immediate) { - $delegated = $request->withParsedBody(['libro_id' => $bookId]); + $delegatedLoan = ['libro_id' => $bookId]; + if ($endDate !== '') { + $delegatedLoan['end_date'] = $endDate; + } + $delegated = $request->withParsedBody($delegatedLoan); $result = $controller->loan($delegated, new SlimResponse(), $this->db); $outcome = $this->redirectOutcome($result); @@ -277,6 +289,9 @@ public function requestReservation(Request $request, ResponseInterface $response if ($desired !== '') { $delegatedBody['desired_date'] = $desired; } + if ($endDate !== '') { + $delegatedBody['end_date'] = $endDate; + } $delegated = $request->withParsedBody($delegatedBody); $result = $controller->reserve($delegated, new SlimResponse(), $this->db); $outcome = $this->redirectOutcome($result); @@ -1223,6 +1238,8 @@ private function mapActionError(ResponseInterface $response, string $code, array return ResponseEnvelope::error($response, 'past_date', __('La data richiesta è nel passato.'), 422); case 'invalid_date': return ResponseEnvelope::error($response, 'invalid_date', __('Data richiesta non valida.'), 422); + case 'max_duration_exceeded': + return ResponseEnvelope::error($response, 'max_duration_exceeded', __('Il periodo richiesto supera la durata massima consentita.'), 422); case 'invalid': return ResponseEnvelope::error($response, 'invalid_book', __('Identificativo libro non valido.'), 422); default: diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index 0707c63fc..df1166ada 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -734,7 +734,8 @@ private function paths(): array ], '400' => ['$ref' => '#/components/responses/UnprocessableEntity'], '401' => ['$ref' => '#/components/responses/Unauthorized'], - '409' => ['description' => 'Overlap or book unavailable.', 'content' => $json], + '409' => ['description' => 'Overlap or book unavailable. For not_available, when the start day is free but a scheduled loan or reservation begins inside the requested window, error.message names the longest loan still possible and meta carries max_duration_days, max_end_date and first_conflict_date (Mobile API 1.8.2) so the client can propose that return date.', 'content' => $json], + '422' => ['description' => 'Invalid book id or date, or end_date beyond max_loan_duration_days (max_duration_exceeded).', 'content' => $json], '500' => ['$ref' => '#/components/responses/InternalError'], ], ], @@ -1433,7 +1434,7 @@ private function reservationRequestSchema(): array 'book_id' => ['type' => 'integer'], 'desired_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'description' => 'Requested start date. Today or omitted means immediate loan when a copy is free; future dates create reservations.'], 'start_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'deprecated' => true], - 'end_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'deprecated' => true], + 'end_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'description' => 'Optional return date (Mobile API 1.8.2). Omitted, the library\'s loan_duration_days is added to the start. Send the day before the first fully-booked day of GET /catalog/books/{id}/availability when the default would cross it; must not precede the start nor exceed max_loan_duration_days (422 max_duration_exceeded).'], ], ]; } diff --git a/tests/loan-window-cap.unit.php b/tests/loan-window-cap.unit.php index 9d6663e2a..a96ec445a 100644 --- a/tests/loan-window-cap.unit.php +++ b/tests/loan-window-cap.unit.php @@ -279,13 +279,36 @@ json_encode(['blocked' => $blockedDouble, 'response' => $r])); $resetRequester($doubleBook, $requester, $doubleCopies); -// ═════════ 15: the public calendar reads the same figures (view contract) ════ +// ═════════ 16-19: an explicit return date from the app (Mobile API 1.8.2) ════ +$params = $userAction('loan', $singleBook, ['end_date' => $expectedMaxEnd]); +$check(isset($params['loan_request_success']) || isset($params['reserve_success']), + "16 POST /user/loan with end_date = {$expectedMaxEnd} (the day before the scheduled loan) is accepted", + json_encode($params)); +$resetRequester($singleBook, $requester, $singleCopies); + +$params = $userAction('loan', $singleBook, ['end_date' => $addDays($today, $maxDays + 1)]); +$check(($params['loan_error'] ?? null) === 'max_duration_exceeded', + "17 POST /user/loan with an end_date beyond max_loan_duration_days ({$maxDays}) is refused as max_duration_exceeded", + json_encode($params)); + +$params = $userAction('loan', $singleBook, ['end_date' => $addDays($today, -1)]); +$check(($params['loan_error'] ?? null) === 'invalid_date', + '18 POST /user/loan with an end_date before the start is refused as invalid_date', + json_encode($params)); + +$params = $userAction('reserve', $singleBook, ['desired_date' => $today, 'end_date' => $expectedMaxEnd]); +$check(isset($params['reserve_success']), + '19 POST /user/reserve for today with the capped end_date joins the queue instead of being refused', + json_encode($params)); +$resetRequester($singleBook, $requester, $singleCopies); + +// ═════════ 20: the public calendar reads the same figures (view contract) ════ $viewSrc = (string) file_get_contents($root . '/app/Views/frontend/book-detail.php'); $check(str_contains($viewSrc, 'function loanWindowFrom(') && str_contains($viewSrc, "endPicker.set('maxDate', loanWindow.maxEndDate)") && str_contains($viewSrc, 'Durata massima del prestito: %1$d giorni') && str_contains($viewSrc, 'LoanWindow::explainFromParams($_GET)'), - '15 book-detail.php caps the return picker, explains it with the shared message and renders the redirect explanation'); + '20 book-detail.php caps the return picker, explains it with the shared message and renders the redirect explanation'); $cleanup(); $db->close(); From 6644fada864ffed642ad2e5e3063cc08bbea5dbf Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:24:22 +0000 Subject: [PATCH 07/26] Cover every refusal surface and the edge cases; give all calendars the same values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/loan-window-surfaces.unit.php (17 database-backed checks): the admin loan form, the admin edit of a loan's dates (cap measured from the first added day, own loan excluded), the admin approval of a pending request, the admin reservation form on create and on edit (own row excluded), a reader moving their own reservation, the Mobile API with and without end_date (409 with the sentence and the meta figures, 201 when capped, 422 max_duration_exceeded, 422 invalid_date), the availability payload's durations, and a commitment starting the very next day. That last case is the edge the figures must not misreport: a window shorter than one day is no loan. LoanWindow::cap returns null for it, so every surface keeps its generic refusal, and the calendars say so before sending ("Da questa data di inizio non è possibile alcun prestito: dal D il libro è già prenotato. Scegli un'altra data.") instead of proposing a same-day return. The availability payload now carries loan_duration_days (capped by the maximum) and max_loan_duration_days, so every calendar reads the same values from one source: the public book page, the admin loan form (which prefers the payload value when it arrives), the admin reservation form, and the app (Mobile API also exposes max_loan_duration_days). The admin reservation form moves to the application clock for its defaults, gets min attributes, and fetches the selected title's availability for the chosen borrower to stop the proposed deadline the day before a scheduled loan, with the same explanation under the field, instead of being refused by the store afterwards. The book page also names the max_duration_exceeded refusal of the user-area forms. --- app/Controllers/ReservationsController.php | 10 +- app/Support/LoanWindow.php | 9 +- app/Views/frontend/book-detail.php | 19 +- app/Views/prenotazioni/crea_prenotazione.php | 134 ++++-- app/Views/prestiti/crea_prestito.php | 27 +- locale/da_DK.json | 3 +- locale/de_DE.json | 3 +- locale/en_US.json | 3 +- locale/fr_FR.json | 3 +- locale/it_IT.json | 3 +- .../src/Controllers/CatalogController.php | 6 +- .../src/Controllers/OpenApiController.php | 1 + tests/loan-coherence-audit.unit.php | 2 +- tests/loan-window-surfaces.unit.php | 384 ++++++++++++++++++ 14 files changed, 563 insertions(+), 44 deletions(-) create mode 100644 tests/loan-window-surfaces.unit.php diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php index 4234334d2..a694d1c06 100644 --- a/app/Controllers/ReservationsController.php +++ b/app/Controllers/ReservationsController.php @@ -699,7 +699,15 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int $reservationsResult = $stmt->get_result(); $existingReservations = $reservationsResult->fetch_all(MYSQLI_ASSOC); - return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId); + // The durations every calendar proposes with (public book page, admin + // loan and reservation forms, the app): the configured default, capped + // by the maximum the request endpoints enforce, from one source. + $settings = new \App\Models\SettingsRepository($this->db); + $maxDays = max(1, (int) ($settings->get('loans', 'max_loan_duration_days', '90') ?? 90)); + return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId) + [ + 'loan_duration_days' => min($settings->loanDurationDays(), $maxDays), + 'max_loan_duration_days' => $maxDays, + ]; } /** diff --git a/app/Support/LoanWindow.php b/app/Support/LoanWindow.php index cf0617668..4fd0a2231 100644 --- a/app/Support/LoanWindow.php +++ b/app/Support/LoanWindow.php @@ -27,7 +27,8 @@ final class LoanWindow * @param string[] $conflictDates Y-m-d days inside [startDate, endDate] * without a free copy (CapacityService::unavailableDatesInRange()). * @return array{first_conflict_date: string, max_end_date: string, max_duration_days: int}|null - * null when nothing bounds the window, or when the start day itself is blocked. + * null when nothing bounds the window, when the start day itself is blocked, + * or when the next day is (no loan of at least one day is possible). */ public static function cap(string $startDate, array $conflictDates): ?array { @@ -41,6 +42,12 @@ public static function cap(string $startDate, array $conflictDates): ?array } $maxEnd = (new DateTime($firstConflict))->modify('-1 day')->format('Y-m-d'); $maxDays = (int) (new DateTime($startDate))->diff(new DateTime($maxEnd))->days; + // The very next day is blocked: no loan of at least one day exists + // from this start, so there is nothing to propose and the caller + // keeps its generic refusal (the calendars say so before sending). + if ($maxDays < 1) { + return null; + } return [ 'first_conflict_date' => $firstConflict, diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index 219db9dce..9da96cab3 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -688,6 +688,8 @@ class="book-cover-large img-fluid pk-book__img + + @@ -709,7 +711,8 @@ class="book-cover-large img-fluid pk-book__img __('Hai già una prenotazione attiva per questo libro.'), 'invalid_date' => __('Data non valida.'), 'past_date' => __('La data non può essere nel passato.'), - 'not_available' => \App\Support\LoanWindow::explainFromParams($_GET) ?? __('Nessuna copia disponibile per il periodo richiesto.') + 'not_available' => \App\Support\LoanWindow::explainFromParams($_GET) ?? __('Nessuna copia disponibile per il periodo richiesto.'), + 'max_duration_exceeded' => __('Il periodo richiesto supera la durata massima consentita.') ]; // Defense-in-depth: the values are __() translations from // the i18n catalog (app-controlled), and unknown keys fall @@ -1742,6 +1745,12 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { formatDateIT(loanWindow.firstBlocked) ); const maxDurationText = __('Il periodo richiesto supera la durata massima consentita di %d giorni', maxRequestLoanDays); + // The next day is blocked: no loan of at least one day from this start. + const noWindowText = (loanWindow) => __( + 'Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un\'altra data.', + formatDateIT(loanWindow.firstBlocked) + ); + const windowText = (loanWindow) => loanWindow.maxDays < 1 ? noWindowText(loanWindow) : loanLimitText(loanWindow); const blockedDayText = (isoDate, state) => __( 'Giorno non disponibile (%1$s): %2$s', formatDateIT(isoDate), @@ -1763,7 +1772,7 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { endPicker.set('maxDate', loanWindow.maxEndDate); endPicker.setDate(loanWindow.endDate, true); } - setLimitNote(loanWindow.capped ? loanLimitText(loanWindow) : ''); + setLimitNote(loanWindow.capped ? windowText(loanWindow) : ''); return loanWindow; }; // A red or orange day cannot be picked; clicking it must still say @@ -1774,7 +1783,7 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { if (/^\d{4}-\d{2}-\d{2}$/.test(startIso)) { const loanWindow = loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); if (loanWindow.firstBlocked !== null) { - text += ' ' + loanLimitText(loanWindow); + text += ' ' + windowText(loanWindow); } } setLimitNote(text); @@ -1934,6 +1943,10 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { return false; } const loanWindow = loanWindowFrom(startDate, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); + if (loanWindow.firstBlocked !== null && loanWindow.maxDays < 1) { + Swal.showValidationMessage(noWindowText(loanWindow)); + return false; + } const effectiveEnd = endDate || addDaysToIso(startDate, defaultRequestLoanDays); if (effectiveEnd > loanWindow.maxEndDate) { Swal.showValidationMessage(loanWindow.firstBlocked !== null ? loanLimitText(loanWindow) : maxDurationText); diff --git a/app/Views/prenotazioni/crea_prenotazione.php b/app/Views/prenotazioni/crea_prenotazione.php index 9f920b733..6d8e771b3 100644 --- a/app/Views/prenotazioni/crea_prenotazione.php +++ b/app/Views/prenotazioni/crea_prenotazione.php @@ -197,7 +197,8 @@ class="w-full px-6 py-5 text-xl border-2 border-gray-300 rounded-2xl focus:ring-

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

+
@@ -260,30 +263,111 @@ class="inline-flex items-center px-12 py-4 text-lg bg-gray-900 text-white rounde diff --git a/app/Views/prestiti/crea_prestito.php b/app/Views/prestiti/crea_prestito.php index f00a216fc..d8fc1a73b 100644 --- a/app/Views/prestiti/crea_prestito.php +++ b/app/Views/prestiti/crea_prestito.php @@ -408,7 +408,8 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-gray-800 focus:ring-gray-500" copyNotFound: , copyNotAvailable: , borrowerAlreadyReserved: , - loanLimit: + loanLimit: , + noWindow: }; // F043: base della route di disponibilità risolta per il locale attivo, @@ -417,6 +418,9 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-gray-800 focus:ring-gray-500" const API_BOOK_BASE = ; const appToday = ; const defaultLoanDays = ; + // The availability payload of the selected book carries the same value + // read from the settings at request time; when present it wins. + let effectiveLoanDays = defaultLoanDays; function addDaysToIso(isoDate, days) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate || ''); @@ -467,7 +471,7 @@ function formatDisplayDate(iso) { } function applyLoanWindow(startIso, onlyShorten) { - const loanWindow = loanWindowFrom(startIso, defaultLoanDays); + const loanWindow = loanWindowFrom(startIso, effectiveLoanDays); if (fpScadenza) { fpScadenza.set('minDate', startIso); // Availability arriving after a deadline was typed must not replace @@ -479,13 +483,18 @@ function applyLoanWindow(startIso, onlyShorten) { } const hint = document.getElementById('data_scadenza_hint'); if (hint) { - hint.textContent = loanWindow.capped - ? i18n.loanLimit + let text = ''; + if (loanWindow.capped && loanWindow.maxDays < 1) { + // The next day is blocked: no loan of at least one day from this start. + text = i18n.noWindow.replace('%s', formatDisplayDate(loanWindow.firstBlocked)); + } else if (loanWindow.capped) { + text = i18n.loanLimit .replace('%1$d', String(loanWindow.maxDays)) .replace('%2$s', formatDisplayDate(loanWindow.endDate)) - .replace('%3$s', formatDisplayDate(loanWindow.firstBlocked)) - : ''; - hint.classList.toggle('hidden', !loanWindow.capped); + .replace('%3$s', formatDisplayDate(loanWindow.firstBlocked)); + } + hint.textContent = text; + hint.classList.toggle('hidden', text === ''); } } @@ -745,6 +754,10 @@ function fetchBookAvailability(bookId) { if (data.success && data.availability) { const blockedByReservation = data.availability.has_active_reservation === true; + const payloadDays = parseInt(data.availability.loan_duration_days, 10); + if (Number.isFinite(payloadDays) && payloadDays >= 1) { + effectiveLoanDays = payloadDays; + } // Build availabilityByDate map (same structure as frontend) availabilityByDate = {}; if (Array.isArray(data.availability.days)) { diff --git a/locale/da_DK.json b/locale/da_DK.json index f9335d1ed..64a244f3b 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8376,5 +8376,6 @@ "Diritti esistenti": "Eksisterende rettigheder", "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maksimal lånetid: %1$d dage (aflevering senest %2$s). Fra %3$s er bogen allerede reserveret.", "Giorno non disponibile (%1$s): %2$s": "Dag ikke tilgængelig (%1$s): %2$s", - "Il periodo richiesto supera la durata massima consentita.": "Den ønskede periode overstiger den maksimalt tilladte lånetid." + "Il periodo richiesto supera la durata massima consentita.": "Den ønskede periode overstiger den maksimalt tilladte lånetid.", + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Intet lån er muligt fra denne startdato: fra %s er bogen allerede reserveret. Vælg en anden dato." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 38494c106..4ff356dc0 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8376,5 +8376,6 @@ "Diritti esistenti": "Bisherige Rechte", "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximale Leihdauer: %1$d Tage (Rückgabe bis %2$s). Ab dem %3$s ist das Buch bereits vorgemerkt.", "Giorno non disponibile (%1$s): %2$s": "Tag nicht verfügbar (%1$s): %2$s", - "Il periodo richiesto supera la durata massima consentita.": "Der gewünschte Zeitraum überschreitet die maximal zulässige Leihdauer." + "Il periodo richiesto supera la durata massima consentita.": "Der gewünschte Zeitraum überschreitet die maximal zulässige Leihdauer.", + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Ab diesem Startdatum ist keine Ausleihe möglich: ab dem %s ist das Buch bereits vorgemerkt. Wählen Sie ein anderes Datum." } diff --git a/locale/en_US.json b/locale/en_US.json index 96b52b125..69c058e65 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8376,5 +8376,6 @@ "Diritti esistenti": "Existing rights", "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximum loan length: %1$d days (return by %2$s). The book is already booked from %3$s.", "Giorno non disponibile (%1$s): %2$s": "Day not available (%1$s): %2$s", - "Il periodo richiesto supera la durata massima consentita.": "The requested period exceeds the maximum allowed loan length." + "Il periodo richiesto supera la durata massima consentita.": "The requested period exceeds the maximum allowed loan length.", + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "No loan is possible from this start date: the book is already booked from %s. Choose another date." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 43eb771f8..0140efbb5 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8376,5 +8376,6 @@ "Diritti esistenti": "Droits existants", "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durée maximale du prêt : %1$d jours (retour avant le %2$s). À partir du %3$s, le livre est déjà réservé.", "Giorno non disponibile (%1$s): %2$s": "Jour non disponible (%1$s) : %2$s", - "Il periodo richiesto supera la durata massima consentita.": "La période demandée dépasse la durée maximale de prêt autorisée." + "Il periodo richiesto supera la durata massima consentita.": "La période demandée dépasse la durée maximale de prêt autorisée.", + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Aucun prêt n'est possible à partir de cette date : à partir du %s, le livre est déjà réservé. Choisissez une autre date." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 28eec909c..93cb7a33b 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8376,5 +8376,6 @@ "Diritti esistenti": "Diritti esistenti", "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.", "Giorno non disponibile (%1$s): %2$s": "Giorno non disponibile (%1$s): %2$s", - "Il periodo richiesto supera la durata massima consentita.": "Il periodo richiesto supera la durata massima consentita." + "Il periodo richiesto supera la durata massima consentita.": "Il periodo richiesto supera la durata massima consentita.", + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data." } diff --git a/storage/plugins/mobile-api/src/Controllers/CatalogController.php b/storage/plugins/mobile-api/src/Controllers/CatalogController.php index 11e178952..2e934f786 100644 --- a/storage/plugins/mobile-api/src/Controllers/CatalogController.php +++ b/storage/plugins/mobile-api/src/Controllers/CatalogController.php @@ -833,7 +833,11 @@ public function bookAvailability( // configured loan duration (UserActionsController::loan()/reserve()), so // the app can show it before the reader sends the request. $data = self::mapAvailability($avail) - + ['loan_duration_days' => (new \App\Models\SettingsRepository($this->db))->loanDurationDays()]; + + [ + 'loan_duration_days' => (new \App\Models\SettingsRepository($this->db))->loanDurationDays(), + // Upper bound POST /reservations accepts for end_date (Mobile API 1.8.2). + 'max_loan_duration_days' => (int) ($avail['max_loan_duration_days'] ?? 90), + ]; return ResponseEnvelope::success($response, $data, []); } catch (\Throwable $e) { diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index df1166ada..80aed872b 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -611,6 +611,7 @@ private function paths(): array ], ]], 'loan_duration_days' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Days POST /reservations adds to the chosen start to set the return date.'], + 'max_loan_duration_days' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Longest loan POST /reservations accepts through end_date (Mobile API 1.8.2).'], ], ]], ]]], diff --git a/tests/loan-coherence-audit.unit.php b/tests/loan-coherence-audit.unit.php index 61e2604b6..ed1a2651e 100644 --- a/tests/loan-coherence-audit.unit.php +++ b/tests/loan-coherence-audit.unit.php @@ -295,7 +295,7 @@ // reservation; the day arithmetic stays UTC-based (addDaysToIso). $checks['loan calendars use the configured duration without local-time date arithmetic'] = str_contains($loanCreateView, 'const defaultLoanDays =') - && str_contains($loanCreateView, 'loanWindowFrom(startIso, defaultLoanDays)') + && str_contains($loanCreateView, 'loanWindowFrom(startIso, effectiveLoanDays)') && str_contains($loanCreateView, 'addDaysToIso(startIso, defaultDays)') && !str_contains($loanCreateView, 'setMonth(endDate.getMonth() + 1)') && str_contains($frontend, '$defaultRequestLoanDays = min(') diff --git a/tests/loan-window-surfaces.unit.php b/tests/loan-window-surfaces.unit.php new file mode 100644 index 000000000..c84773c9a --- /dev/null +++ b/tests/loan-window-surfaces.unit.php @@ -0,0 +1,384 @@ +set_charset('utf8mb4'); + DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — loan window surfaces suite is mandatory: {$e->getMessage()}\n"); + exit(1); +} + +$run = bin2hex(random_bytes(5)); +$prefix = "ZZ_LWSRF_{$run}"; +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label, string $detail = '') use (&$passed, &$failed): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . ($ok || $detail === '' ? '' : " [{$detail}]") . PHP_EOL; + $ok ? $passed++ : $failed++; +}; + +/** @var int[] $bookIds */ +$bookIds = []; +/** @var int[] $userIds */ +$userIds = []; +$cleanup = static function () use ($db, &$bookIds, &$userIds): void { + foreach ($bookIds as $id) { + $db->query("DELETE FROM prestiti WHERE libro_id = {$id}"); + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$id}"); + $db->query("DELETE FROM copie WHERE libro_id = {$id}"); + $db->query("DELETE FROM libri WHERE id = {$id}"); + } + foreach ($userIds as $id) { + $db->query("DELETE FROM utenti WHERE id = {$id}"); + } +}; +set_exception_handler(static function (Throwable $e) use ($cleanup, $db): void { + try { $cleanup(); } catch (Throwable) {} + fwrite(STDERR, 'FAIL: uncaught ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . PHP_EOL); + $db->close(); + exit(1); +}); + +// ── Sandbox ────────────────────────────────────────────────────────────────── +$mkUser = static function (int $n, string $tipo = 'standard') use ($db, $prefix, &$userIds): int { + $card = "{$prefix}U{$n}"; + $email = strtolower("{$prefix}-u{$n}@example.invalid"); + $cognome = "{$prefix} {$n}"; + $stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) + VALUES (?, 'LoanWindow', ?, ?, 'x', 'attivo', ?, 1)"); + $stmt->bind_param('ssss', $card, $cognome, $email, $tipo); + $stmt->execute(); + $stmt->close(); + $userIds[] = (int) $db->insert_id; + return (int) $db->insert_id; +}; +$mkBook = static function (int $copies, int $n) use ($db, $prefix, &$bookIds): array { + $title = "{$prefix} book {$n}"; + $stmt = $db->prepare("INSERT INTO libri (titolo, copie_totali, copie_disponibili, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())"); + $stmt->bind_param('sii', $title, $copies, $copies); + $stmt->execute(); + $stmt->close(); + $bookId = (int) $db->insert_id; + $bookIds[] = $bookId; + $copyIds = []; + for ($i = 1; $i <= $copies; $i++) { + $code = "{$prefix}-{$bookId}-C{$i}"; + $stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'disponibile')"); + $stmt->bind_param('is', $bookId, $code); + $stmt->execute(); + $stmt->close(); + $copyIds[] = (int) $db->insert_id; + } + return [$bookId, $copyIds]; +}; +$mkLoan = static function (int $bookId, ?int $copyId, int $userId, string $stato, int $attivo, string $from, string $to) use ($db): int { + $stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo, warning_sent, overdue_notification_sent) + VALUES (?, ?, ?, ?, ?, ?, 'diretto', ?, 0, 0)"); + $stmt->bind_param('iiisssi', $bookId, $copyId, $userId, $from, $to, $stato, $attivo); + $stmt->execute(); + $stmt->close(); + return (int) $db->insert_id; +}; +$addDays = static fn (string $ymd, int $days): string => (new DateTimeImmutable($ymd))->modify(($days >= 0 ? '+' : '') . "{$days} days")->format('Y-m-d'); +$redirectParams = static function ($response): array { + $query = parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY); + $params = []; + parse_str(is_string($query) ? $query : '', $params); + return $params; +}; +$jsonBody = static function ($response): array { + $decoded = json_decode((string) $response->getBody(), true); + return is_array($decoded) ? $decoded : []; +}; +/** Drop what a request wrote for one patron on one title, so later checks start clean. */ +$resetPatron = static function (int $bookId, int $userId, array $copyIds) use ($db): void { + $db->query("DELETE FROM prestiti WHERE libro_id = {$bookId} AND utente_id = {$userId}"); + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$bookId} AND utente_id = {$userId}"); + foreach ($copyIds as $copyId) { + $db->query("UPDATE copie SET stato = 'disponibile' WHERE id = {$copyId}"); + } +}; + +$settings = new SettingsRepository($db); +$maxDays = max(1, (int) ($settings->get('loans', 'max_loan_duration_days', '90') ?? 90)); +$defaultDays = min($settings->loanDurationDays(), $maxDays); +$offset = max(2, min(5, $defaultDays - 1)); +$today = DateHelper::today(); +$conflictStart = $addDays($today, $offset); +$conflictEnd = $addDays($today, $offset + 14); +$expectedMaxEnd = $addDays($today, $offset - 1); +$expectedMaxDays = $offset - 1; + +$otherUser = $mkUser(1); +$requester = $mkUser(2); +$admin = $mkUser(3, 'admin'); +[$book, $copies] = $mkBook(1, 1); +$mkLoan($book, $copies[0], $otherUser, 'prenotato', 1, $conflictStart, $conflictEnd); +echo "Sandbox: today {$today}, scheduled loan {$conflictStart}..{$conflictEnd}, default {$defaultDays} days, max {$maxDays} days" . PHP_EOL; + +$asAdmin = static function () use ($admin): void { + $_SESSION['user'] = ['tipo_utente' => 'admin', 'id' => $admin]; +}; +$asRequester = static function () use ($requester, $book): void { + $_SESSION['user'] = ['tipo_utente' => 'standard', 'id' => $requester]; + $_SERVER['HTTP_REFERER'] = "/libro/{$book}"; +}; +$post = static fn (string $uri, array $body) => (new ServerRequestFactory())->createServerRequest('POST', $uri)->withParsedBody($body); + +// ═════════ 1: the admin loan form ════════════════════════════════════════════ +$asAdmin(); +$response = (new PrestitiController())->store($post('/admin/loans/store', [ + 'loan_submission_token' => \App\Support\OneTimeFormToken::issue('loan.create'), + 'utente_id' => (string) $requester, + 'libro_id' => (string) $book, + 'data_prestito' => $today, + 'data_scadenza' => $addDays($today, $offset + 2), +]), new SlimResponse(), $db); +$params = $redirectParams($response); +$check(($params['error'] ?? null) === 'no_copies_available' + && ($params['max_end_date'] ?? null) === $expectedMaxEnd + && (int) ($params['max_loan_days'] ?? -1) === $expectedMaxDays + && ($params['blocked_from'] ?? null) === $conflictStart, + "1 admin create loan crossing the scheduled loan: no_copies_available with max_end_date {$expectedMaxEnd}, max_loan_days {$expectedMaxDays}, blocked_from {$conflictStart}", + $response->getHeaderLine('Location')); +$check(LoanWindow::explainFromParams($params) !== null + && str_contains((string) LoanWindow::explainFromParams($params), (string) $expectedMaxDays), + '2 the admin form renders those parameters as the maximum-duration sentence'); + +// ═════════ 3: editing a loan's dates across the scheduled loan ═══════════════ +$ownLoan = $mkLoan($book, $copies[0], $requester, 'in_corso', 1, $addDays($today, -2), $addDays($today, 1)); +$db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$copies[0]}"); +$asAdmin(); +$response = (new PrestitiController())->update($post("/admin/loans/update/{$ownLoan}", [ + 'utente_id' => (string) $requester, + 'data_prestito' => $addDays($today, -2), + 'data_scadenza' => $addDays($today, $offset + 2), +]), new SlimResponse(), $db, $ownLoan); +$params = $redirectParams($response); +// The added days run from the day after the current due date: the cap is +// measured from there, and the loan being edited is never its own conflict. +$claimStart = $addDays($today, 2); +$check(($params['error'] ?? null) === 'no_copies_available' + && ($params['max_end_date'] ?? null) === $expectedMaxEnd + && ($params['blocked_from'] ?? null) === $conflictStart + && (int) ($params['max_loan_days'] ?? -1) === (int) (new DateTimeImmutable($claimStart))->diff(new DateTimeImmutable($expectedMaxEnd))->days, + '3 admin edit of a loan due date across the scheduled loan: explained from the first added day, own loan excluded', + $response->getHeaderLine('Location')); +$resetPatron($book, $requester, $copies); + +// ═════════ 4: approving a pending request ════════════════════════════════════ +$pending = $mkLoan($book, null, $requester, 'pendente', 0, $today, $addDays($today, $defaultDays)); +$asAdmin(); +$response = (new LoanApprovalController())->approveLoan($post('/admin/loans/approve', ['loan_id' => (string) $pending]), new SlimResponse(), $db); +$body = $jsonBody($response); +$check($response->getStatusCode() === 400 + && ($body['success'] ?? true) === false + && ($body['max_end_date'] ?? null) === $expectedMaxEnd + && ($body['max_duration_days'] ?? null) === $expectedMaxDays + && str_contains((string) ($body['message'] ?? ''), (string) $expectedMaxDays), + '4 approving a pending request whose default window crosses the scheduled loan: 400 JSON with the figures and the sentence', + json_encode($body)); +$resetPatron($book, $requester, $copies); + +// ═════════ 5-7: the admin reservation form, create then edit ═════════════════ +$asAdmin(); +$response = (new ReservationsAdminController())->store($post('/admin/reservations/store', [ + 'libro_id' => (string) $book, + 'utente_id' => (string) $requester, + 'data_prenotazione' => $today, + 'data_scadenza' => $addDays($today, $offset + 2), + 'data_inizio_richiesta' => $today, + 'data_fine_richiesta' => $addDays($today, $offset + 2), +]), new SlimResponse(), $db); +$params = $redirectParams($response); +$check(($params['error'] ?? null) === 'capacity_full' + && ($params['max_end_date'] ?? null) === $expectedMaxEnd + && ($params['blocked_from'] ?? null) === $conflictStart, + '5 admin reservation crossing the scheduled loan: capacity_full with the figures', + $response->getHeaderLine('Location')); + +$response = (new ReservationsAdminController())->store($post('/admin/reservations/store', [ + 'libro_id' => (string) $book, + 'utente_id' => (string) $requester, + 'data_prenotazione' => $today, + 'data_scadenza' => $expectedMaxEnd, + 'data_inizio_richiesta' => $today, + 'data_fine_richiesta' => $expectedMaxEnd, +]), new SlimResponse(), $db); +$reservationRow = $db->query("SELECT id FROM prenotazioni WHERE libro_id = {$book} AND utente_id = {$requester} AND stato = 'attiva' ORDER BY id DESC LIMIT 1")->fetch_assoc(); +$reservationId = (int) ($reservationRow['id'] ?? 0); +$check($reservationId > 0 && !isset($redirectParams($response)['error']), + "6 the same reservation ending {$expectedMaxEnd} is created", + $response->getHeaderLine('Location')); + +$response = (new ReservationsAdminController())->update($post("/admin/reservations/update/{$reservationId}", [ + 'stato' => 'attiva', + 'data_prenotazione' => $today, + 'data_scadenza_prenotazione' => $addDays($today, $offset + 2), + 'data_inizio_richiesta' => $today, + 'data_fine_richiesta' => $addDays($today, $offset + 2), +]), new SlimResponse(), $db, $reservationId); +$params = $redirectParams($response); +$check(($params['error'] ?? null) === 'capacity_full' + && ($params['max_end_date'] ?? null) === $expectedMaxEnd + && ($params['blocked_from'] ?? null) === $conflictStart, + '7 extending that reservation across the scheduled loan: capacity_full with the figures, own row excluded (cap still from the start)', + $response->getHeaderLine('Location')); + +// ═════════ 8: a reader moving their own reservation ══════════════════════════ +$asRequester(); +$response = (new UserActionsController())->changeReservationDate($post('/reservation/change-date', [ + 'reservation_id' => (string) $reservationId, + 'desired_date' => $today, +]), new SlimResponse(), $db); +$params = $redirectParams($response); +$check(($params['error'] ?? null) === 'not_available' + && ($params['blocked_from'] ?? null) === $conflictStart + && ($params['max_end_date'] ?? null) === $expectedMaxEnd, + '8 moving one\'s own reservation to a start whose default window crosses the scheduled loan: not_available with the figures', + $response->getHeaderLine('Location')); +$resetPatron($book, $requester, $copies); + +// ═════════ 9-12: the Mobile API contract ═════════════════════════════════════ +$mobile = static function (array $body) use ($db, $requester, $book, $asRequester, $jsonBody): array { + $asRequester(); + $request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/api/v1/reservations') + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]) + ->withParsedBody(['book_id' => $book] + $body); + $response = (new MobileActionsController($db))->requestReservation($request, new SlimResponse()); + return ['status' => $response->getStatusCode(), 'body' => $jsonBody($response)]; +}; + +$r = $mobile(['desired_date' => $today]); +$check($r['status'] === 409 + && ($r['body']['error']['code'] ?? null) === 'not_available' + && ($r['body']['meta']['max_end_date'] ?? null) === $expectedMaxEnd + && ($r['body']['meta']['max_duration_days'] ?? null) === $expectedMaxDays + && ($r['body']['meta']['first_conflict_date'] ?? null) === $conflictStart + && str_contains((string) ($r['body']['error']['message'] ?? ''), (string) $expectedMaxDays), + '9 Mobile POST /reservations without end_date: 409 not_available, the sentence as error.message, the figures in meta', + json_encode($r)); + +$r = $mobile(['desired_date' => $today, 'end_date' => $expectedMaxEnd]); +$check($r['status'] === 201 && in_array($r['body']['data']['type'] ?? null, ['loan', 'reservation'], true), + "10 Mobile POST /reservations with end_date {$expectedMaxEnd}: 201, the request is accepted", + json_encode($r)); +$resetPatron($book, $requester, $copies); + +$r = $mobile(['desired_date' => $today, 'end_date' => $addDays($today, $maxDays + 1)]); +$check($r['status'] === 422 && ($r['body']['error']['code'] ?? null) === 'max_duration_exceeded', + '11 Mobile end_date beyond max_loan_duration_days: 422 max_duration_exceeded', + json_encode($r)); + +$r = $mobile(['desired_date' => $today, 'end_date' => $addDays($today, -1)]); +$check($r['status'] === 422 && ($r['body']['error']['code'] ?? null) === 'invalid_date', + '12 Mobile end_date before the start: 422 invalid_date', + json_encode($r)); + +// ═════════ 13-14: the durations every calendar reads ═════════════════════════ +$availability = (new ReservationsController($db))->getBookAvailabilityData($book, null, 10, $requester); +$check(is_array($availability) + && ($availability['loan_duration_days'] ?? null) === $defaultDays + && ($availability['max_loan_duration_days'] ?? null) === $maxDays, + "13 the availability payload carries loan_duration_days ({$defaultDays}, capped by the maximum) and max_loan_duration_days ({$maxDays})", + json_encode([$availability['loan_duration_days'] ?? null, $availability['max_loan_duration_days'] ?? null])); + +$blocked = (new CapacityService($db))->unavailableDatesInRange($book, $today, $addDays($today, $defaultDays), $requester); +$check(LoanWindow::redirectParams($today, $blocked) === ['max_loan_days' => $expectedMaxDays, 'max_end_date' => $expectedMaxEnd, 'blocked_from' => $conflictStart], + '14 LoanWindow::redirectParams carries exactly the three figures every redirect uses'); + +// ═════════ 15-17: a commitment starting the very next day ════════════════════ +[$tightBook, $tightCopies] = $mkBook(1, 2); +$mkLoan($tightBook, $tightCopies[0], $otherUser, 'prenotato', 1, $addDays($today, 1), $addDays($today, 10)); +$check(LoanWindow::cap($today, [$addDays($today, 1)]) === null, + '15 LoanWindow::cap: a conflict on the next day leaves no loan of at least one day, so no figures'); + +$asRequester(); +$request = $post("/api/libro/{$tightBook}/reservation", ['start_date' => $today]); +$response = (new ReservationsController($db))->createReservation($request, new SlimResponse(), ['id' => (string) $tightBook]); +$body = $jsonBody($response); +$check($response->getStatusCode() === 400 + && ($body['error'] ?? null) === 'no_capacity' + && !array_key_exists('max_end_date', $body) + && ($body['message'] ?? '') === __('Nessuna copia disponibile nelle date richieste'), + '16 public request from today with the next day booked: generic refusal, no misleading "0 days" figure', + json_encode($body)); + +$r = $mobile(['desired_date' => $today]); +$asRequester(); +$request = (new ServerRequestFactory()) + ->createServerRequest('POST', '/api/v1/reservations') + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]) + ->withParsedBody(['book_id' => $tightBook, 'desired_date' => $today]); +$response = (new MobileActionsController($db))->requestReservation($request, new SlimResponse()); +$body = $jsonBody($response); +$check($response->getStatusCode() === 409 + && ($body['error']['code'] ?? null) === 'not_available' + && !isset($body['meta']['max_end_date']), + '17 Mobile request on that title: 409 not_available with no figures in meta', + json_encode($body)); + +$cleanup(); +$db->close(); + +echo PHP_EOL . "Passed: {$passed} Failed: {$failed}" . PHP_EOL; +exit($failed === 0 ? 0 : 1); From bdd67ef8faec6cad63b70774ef5b75990b5529ee Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:29:46 +0000 Subject: [PATCH 08/26] Fill the calendar explanations on the book page; count from the loan's start; CI fixes The book page overrides window.__ with a key-only dictionary, so the explanations I added through __(template, args) showed the raw Italian template with literal placeholders in every locale. The templates are now translated server-side and filled in JavaScript, as the success range already is, and the preConfirm messages go the same way. Two surfaces measured the maximum from the wrong day. Admin approval checked capacity from today (a fully occupied earlier day means the loan would depend on a preceding return) but the loan starts on data_prestito: the figures now count from there, and a conflict before it gives no figures since shortening cannot help. The loan-date edit counted only the added days: the figures now describe the whole loan from its own start, only for an extension, and the loans list keeps "Modifica non salvata" before the explanation. On the admin loan and reservation forms a deadline this code proposed on its own now follows the subject: switching to a book or borrower without a conflict restores the default instead of keeping the previous book's shortened date, while a date the operator typed is kept; the hint is cleared whenever the availability is cleared, refetched, or the borrower already holds a reservation. A one-day cap read "1 giorni": a singular sentence in all five locales, on the server and in the three calendars. The Playwright spec reads today from the application's timezone, not the database session's. CI: PHPStan flagged a redundant is_string() in LoanWindow; the two database-backed suites wrote a card number longer than codice_tessera allows. The loan-edit check expects the whole-loan duration. --- app/Controllers/LoanApprovalController.php | 9 +++- app/Controllers/PrestitiController.php | 18 ++++--- app/Support/LoanWindow.php | 9 +++- app/Views/frontend/book-detail.php | 39 +++++++------- app/Views/prenotazioni/crea_prenotazione.php | 13 ++++- app/Views/prestiti/crea_prestito.php | 51 ++++++++++++------- app/Views/prestiti/index.php | 4 +- locale/da_DK.json | 3 +- locale/de_DE.json | 3 +- locale/en_US.json | 3 +- locale/fr_FR.json | 3 +- locale/it_IT.json | 3 +- ...n-request-capped-by-scheduled-loan.spec.js | 5 +- tests/loan-window-cap.unit.php | 3 +- tests/loan-window-surfaces.unit.php | 15 +++--- 15 files changed, 123 insertions(+), 58 deletions(-) diff --git a/app/Controllers/LoanApprovalController.php b/app/Controllers/LoanApprovalController.php index b3ec0d7bd..1881d6f3f 100644 --- a/app/Controllers/LoanApprovalController.php +++ b/app/Controllers/LoanApprovalController.php @@ -431,8 +431,15 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R // Name the longest loan still possible from today when a // later commitment is what blocks the approval, so staff // can shorten the request instead of waiting for a return. + // The capacity check starts at today (a fully occupied + // earlier day means the loan would depend on a preceding + // return), but the loan itself starts on data_prestito: + // the figures count from there, and a conflict before it + // cannot be fixed by shortening, so no figures then. $blocked = $capacity->unavailableDatesInRange($libroId, $today, $dataScadenza, null, $loanId); - $cap = \App\Support\LoanWindow::cap($today, $blocked); + $cap = ($blocked !== [] && min($blocked) > $dataPrestito) + ? \App\Support\LoanWindow::cap($dataPrestito, $blocked) + : null; $response->getBody()->write(json_encode([ 'success' => false, 'message' => $cap === null diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index 3160fbcea..3b82be2b3 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -1105,21 +1105,27 @@ public function update(Request $request, Response $response, mysqli $db, int $id // strict above); ±1 day via DateTimeImmutable, no TZ ambiguity. $dayBefore = static fn (string $ymd): string => (new \DateTimeImmutable($ymd))->modify('-1 day')->format('Y-m-d'); $dayAfter = static fn (string $ymd): string => (new \DateTimeImmutable($ymd))->modify('+1 day')->format('Y-m-d'); + // [window, isExtension]: only a later due date can be shortened + // meaningfully, and its figures count from the loan's own start. $claimedWindows = []; if ($newPrestito < $oldPrestito) { - $claimedWindows[] = [$newPrestito, min($dayBefore($oldPrestito), $newScadenza)]; + $claimedWindows[] = [$newPrestito, min($dayBefore($oldPrestito), $newScadenza), false]; } if ($newScadenza > $oldScadenza) { - $claimedWindows[] = [max($dayAfter($oldScadenza), $newPrestito), $newScadenza]; + $claimedWindows[] = [max($dayAfter($oldScadenza), $newPrestito), $newScadenza, true]; } $capacity = new \App\Services\CapacityService($db); - foreach ($claimedWindows as [$claimStart, $claimEnd]) { + foreach ($claimedWindows as [$claimStart, $claimEnd, $isExtension]) { if (!$capacity->hasFreeCapacity($libroId, $claimStart, $claimEnd, excludePrestitoId: $id)) { $db->rollback(); // The loans list explains the refusal with the longest - // window still possible from the claimed start. - $blocked = $capacity->unavailableDatesInRange($libroId, $claimStart, $claimEnd, null, $id); - $query = http_build_query(['error' => 'no_copies_available'] + \App\Support\LoanWindow::redirectParams($claimStart, $blocked)); + // loan still possible from its start (LoanWindow). + $explained = []; + if ($isExtension) { + $blocked = $capacity->unavailableDatesInRange($libroId, $claimStart, $claimEnd, null, $id); + $explained = \App\Support\LoanWindow::redirectParams($newPrestito, $blocked); + } + $query = http_build_query(['error' => 'no_copies_available'] + $explained); return $response->withHeader('Location', url('/admin/loans') . '?' . $query)->withStatus(302); } } diff --git a/app/Support/LoanWindow.php b/app/Support/LoanWindow.php index 4fd0a2231..44a70323b 100644 --- a/app/Support/LoanWindow.php +++ b/app/Support/LoanWindow.php @@ -32,7 +32,7 @@ final class LoanWindow */ public static function cap(string $startDate, array $conflictDates): ?array { - $future = array_filter($conflictDates, static fn ($d): bool => is_string($d) && $d > $startDate); + $future = array_filter($conflictDates, static fn (string $d): bool => $d > $startDate); if ($future === []) { return null; } @@ -62,6 +62,13 @@ public static function cap(string $startDate, array $conflictDates): ?array */ public static function explain(int $maxDays, string $maxEndDate, string $firstConflictDate): string { + if ($maxDays === 1) { + return __( + 'Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.', + self::displayDate($maxEndDate), + self::displayDate($firstConflictDate) + ); + } return __( 'Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.', $maxDays, diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index 9da96cab3..f78f079e2 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -1738,24 +1738,29 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { }; const infoText = __('Le date rosse o arancioni non sono disponibili. La richiesta verrà valutata da un amministratore.'); - const loanLimitText = (loanWindow) => __( - 'Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.', - loanWindow.maxDays, - formatDateIT(loanWindow.maxEndDate), - formatDateIT(loanWindow.firstBlocked) - ); - const maxDurationText = __('Il periodo richiesto supera la durata massima consentita di %d giorni', maxRequestLoanDays); + // This page's __() is a key-only dictionary (see the override above + // the script): the templates are translated server-side and filled + // here, as successRangeTpl is. + const loanLimitTpl = ; + const loanLimitOneTpl = ; + const noWindowTpl = ; + const blockedDayTpl = ; + const maxDurationText = ; + const endBeforeStartText = ; + const loanLimitText = (loanWindow) => loanWindow.maxDays === 1 + ? loanLimitOneTpl + .replace('%1$s', formatDateIT(loanWindow.maxEndDate)) + .replace('%2$s', formatDateIT(loanWindow.firstBlocked)) + : loanLimitTpl + .replace('%1$d', String(loanWindow.maxDays)) + .replace('%2$s', formatDateIT(loanWindow.maxEndDate)) + .replace('%3$s', formatDateIT(loanWindow.firstBlocked)); // The next day is blocked: no loan of at least one day from this start. - const noWindowText = (loanWindow) => __( - 'Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un\'altra data.', - formatDateIT(loanWindow.firstBlocked) - ); + const noWindowText = (loanWindow) => noWindowTpl.replace('%s', formatDateIT(loanWindow.firstBlocked)); const windowText = (loanWindow) => loanWindow.maxDays < 1 ? noWindowText(loanWindow) : loanLimitText(loanWindow); - const blockedDayText = (isoDate, state) => __( - 'Giorno non disponibile (%1$s): %2$s', - formatDateIT(isoDate), - tooltipTexts[state] || tooltipTexts.borrowed - ); + const blockedDayText = (isoDate, state) => blockedDayTpl + .replace('%1$s', formatDateIT(isoDate)) + .replace('%2$s', tooltipTexts[state] || tooltipTexts.borrowed); const setLimitNote = (text) => { const note = document.getElementById('swal-loan-limit'); if (!note) return; @@ -1939,7 +1944,7 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { // the server fills with the full default) would be refused anyway: // say how long the loan can be instead of sending it. if (endDate && endDate < startDate) { - Swal.showValidationMessage(__('La data di fine non può precedere la data di inizio')); + Swal.showValidationMessage(endBeforeStartText); return false; } const loanWindow = loanWindowFrom(startDate, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); diff --git a/app/Views/prenotazioni/crea_prenotazione.php b/app/Views/prenotazioni/crea_prenotazione.php index 6d8e771b3..88880a8c8 100644 --- a/app/Views/prenotazioni/crea_prenotazione.php +++ b/app/Views/prenotazioni/crea_prenotazione.php @@ -274,6 +274,7 @@ class="inline-flex items-center px-12 py-4 text-lg bg-gray-900 text-white rounde let defaultLoanDays = ; const i18n = { loanLimit: , + loanLimitOne: , noWindow: }; const isEnglish = ; @@ -284,6 +285,9 @@ class="inline-flex items-center px-12 py-4 text-lg bg-gray-900 text-white rounde const hintEl = document.getElementById('data_scadenza_hint'); let availabilityByDate = {}; let availabilityRequestId = 0; + // The deadline this code last proposed on its own: a value the operator typed + // is kept, one proposed here follows the new subject. + let autoProposedEnd = null; function addDaysToIso(isoDate, days) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate || ''); @@ -325,13 +329,19 @@ function applyLoanWindow(onlyShorten) { endEl.min = startIso; const loanWindow = loanWindowFrom(startIso); const current = endEl.value || ''; - if (!onlyShorten || current === '' || current < startIso || (loanWindow.capped && current > loanWindow.endDate)) { + const typedByOperator = current !== '' && current !== autoProposedEnd; + if (!onlyShorten || !typedByOperator || current < startIso || (loanWindow.capped && current > loanWindow.endDate)) { endEl.value = loanWindow.endDate; + autoProposedEnd = loanWindow.endDate; } if (!hintEl) return; let text = ''; if (loanWindow.capped && loanWindow.maxDays < 1) { text = i18n.noWindow.replace('%s', formatDisplayDate(loanWindow.firstBlocked)); + } else if (loanWindow.capped && loanWindow.maxDays === 1) { + text = i18n.loanLimitOne + .replace('%1$s', formatDisplayDate(loanWindow.endDate)) + .replace('%2$s', formatDisplayDate(loanWindow.firstBlocked)); } else if (loanWindow.capped) { text = i18n.loanLimit .replace('%1$d', String(loanWindow.maxDays)) @@ -365,6 +375,7 @@ function loadAvailability() { startEl.min = appToday; endEl.min = appToday; + autoProposedEnd = endEl.value || null; startEl.addEventListener('change', () => applyLoanWindow(false)); if (bookEl) bookEl.addEventListener('change', loadAvailability); if (userEl) userEl.addEventListener('change', loadAvailability); diff --git a/app/Views/prestiti/crea_prestito.php b/app/Views/prestiti/crea_prestito.php index d8fc1a73b..d393f47ae 100644 --- a/app/Views/prestiti/crea_prestito.php +++ b/app/Views/prestiti/crea_prestito.php @@ -409,6 +409,7 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-gray-800 focus:ring-gray-500" copyNotAvailable: , borrowerAlreadyReserved: , loanLimit: , + loanLimitOne: , noWindow: }; @@ -470,32 +471,43 @@ function formatDisplayDate(iso) { return isItalian ? parts.reverse().join('/') : `${parts[1]}/${parts[2]}/${parts[0]}`; } + // The deadline this code last proposed on its own. A value the operator + // typed is never replaced by a later availability fetch; one this code + // proposed follows the new subject (another book or borrower may have no + // conflict, or a different one). + let autoProposedEnd = null; + function setLoanLimitHint(text) { + const hint = document.getElementById('data_scadenza_hint'); + if (!hint) return; + hint.textContent = text || ''; + hint.classList.toggle('hidden', !text); + } function applyLoanWindow(startIso, onlyShorten) { const loanWindow = loanWindowFrom(startIso, effectiveLoanDays); if (fpScadenza) { fpScadenza.set('minDate', startIso); - // Availability arriving after a deadline was typed must not replace - // it, only pull it back when it crosses the blocked day. const current = dataScadenzaEl.value || ''; - if (!onlyShorten || current === '' || (loanWindow.capped && current > loanWindow.endDate)) { + const typedByOperator = current !== '' && current !== autoProposedEnd; + if (!onlyShorten || !typedByOperator || (loanWindow.capped && current > loanWindow.endDate)) { fpScadenza.setDate(loanWindow.endDate); + autoProposedEnd = loanWindow.endDate; } } - const hint = document.getElementById('data_scadenza_hint'); - if (hint) { - let text = ''; - if (loanWindow.capped && loanWindow.maxDays < 1) { - // The next day is blocked: no loan of at least one day from this start. - text = i18n.noWindow.replace('%s', formatDisplayDate(loanWindow.firstBlocked)); - } else if (loanWindow.capped) { - text = i18n.loanLimit - .replace('%1$d', String(loanWindow.maxDays)) - .replace('%2$s', formatDisplayDate(loanWindow.endDate)) - .replace('%3$s', formatDisplayDate(loanWindow.firstBlocked)); - } - hint.textContent = text; - hint.classList.toggle('hidden', text === ''); + let text = ''; + if (loanWindow.capped && loanWindow.maxDays < 1) { + // The next day is blocked: no loan of at least one day from this start. + text = i18n.noWindow.replace('%s', formatDisplayDate(loanWindow.firstBlocked)); + } else if (loanWindow.capped && loanWindow.maxDays === 1) { + text = i18n.loanLimitOne + .replace('%1$s', formatDisplayDate(loanWindow.endDate)) + .replace('%2$s', formatDisplayDate(loanWindow.firstBlocked)); + } else if (loanWindow.capped) { + text = i18n.loanLimit + .replace('%1$d', String(loanWindow.maxDays)) + .replace('%2$s', formatDisplayDate(loanWindow.endDate)) + .replace('%3$s', formatDisplayDate(loanWindow.firstBlocked)); } + setLoanLimitHint(text); } // Book availability data @@ -717,6 +729,7 @@ function fetchBookAvailability(bookId) { bookAvailability = { occupiedRanges: [], firstAvailable: null, isAvailableNow: true, blockedByReservation: false }; if (calendarLegend) calendarLegend.classList.add('hidden'); if (dataPrestitoHint) dataPrestitoHint.classList.add('hidden'); + setLoanLimitHint(''); if (fpPrestito) fpPrestito.redraw(); if (fpScadenza) fpScadenza.redraw(); return; @@ -729,6 +742,8 @@ function fetchBookAvailability(bookId) { bookAvailability = { occupiedRanges: [], firstAvailable: null, isAvailableNow: true, blockedByReservation: false }; if (calendarLegend) calendarLegend.classList.add('hidden'); if (dataPrestitoHint) dataPrestitoHint.classList.add('hidden'); + // The previous subject's figures must not outlive it. + setLoanLimitHint(''); if (fpPrestito) fpPrestito.redraw(); if (fpScadenza) fpScadenza.redraw(); @@ -788,6 +803,8 @@ function fetchBookAvailability(bookId) { // known: shorten it now if a commitment begins inside the window. if (dataPrestitoEl.value && !bookAvailability.blockedByReservation) { applyLoanWindow(dataPrestitoEl.value, true); + } else { + setLoanLimitHint(''); } // Update hint if date is already selected diff --git a/app/Views/prestiti/index.php b/app/Views/prestiti/index.php index 42db0635c..d5691ad01 100644 --- a/app/Views/prestiti/index.php +++ b/app/Views/prestiti/index.php @@ -85,8 +85,8 @@ // Quando il conflitto inizia dopo la data di inizio, il // redirect porta la finestra massima possibile (LoanWindow). echo htmlspecialchars( - \App\Support\LoanWindow::explainFromParams($_GET) - ?? __('Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.'), + __('Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.') + . (($loanWindowExplained = \App\Support\LoanWindow::explainFromParams($_GET)) !== null ? ' ' . $loanWindowExplained : ''), ENT_QUOTES, 'UTF-8' ); diff --git a/locale/da_DK.json b/locale/da_DK.json index 64a244f3b..2eb67aa06 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8377,5 +8377,6 @@ "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maksimal lånetid: %1$d dage (aflevering senest %2$s). Fra %3$s er bogen allerede reserveret.", "Giorno non disponibile (%1$s): %2$s": "Dag ikke tilgængelig (%1$s): %2$s", "Il periodo richiesto supera la durata massima consentita.": "Den ønskede periode overstiger den maksimalt tilladte lånetid.", - "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Intet lån er muligt fra denne startdato: fra %s er bogen allerede reserveret. Vælg en anden dato." + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Intet lån er muligt fra denne startdato: fra %s er bogen allerede reserveret. Vælg en anden dato.", + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maksimal lånetid: 1 dag (aflevering senest %1$s). Fra %2$s er bogen allerede reserveret." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 4ff356dc0..171eb9852 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8377,5 +8377,6 @@ "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximale Leihdauer: %1$d Tage (Rückgabe bis %2$s). Ab dem %3$s ist das Buch bereits vorgemerkt.", "Giorno non disponibile (%1$s): %2$s": "Tag nicht verfügbar (%1$s): %2$s", "Il periodo richiesto supera la durata massima consentita.": "Der gewünschte Zeitraum überschreitet die maximal zulässige Leihdauer.", - "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Ab diesem Startdatum ist keine Ausleihe möglich: ab dem %s ist das Buch bereits vorgemerkt. Wählen Sie ein anderes Datum." + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Ab diesem Startdatum ist keine Ausleihe möglich: ab dem %s ist das Buch bereits vorgemerkt. Wählen Sie ein anderes Datum.", + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maximale Leihdauer: 1 Tag (Rückgabe bis %1$s). Ab dem %2$s ist das Buch bereits vorgemerkt." } diff --git a/locale/en_US.json b/locale/en_US.json index 69c058e65..99aca7495 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8377,5 +8377,6 @@ "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Maximum loan length: %1$d days (return by %2$s). The book is already booked from %3$s.", "Giorno non disponibile (%1$s): %2$s": "Day not available (%1$s): %2$s", "Il periodo richiesto supera la durata massima consentita.": "The requested period exceeds the maximum allowed loan length.", - "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "No loan is possible from this start date: the book is already booked from %s. Choose another date." + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "No loan is possible from this start date: the book is already booked from %s. Choose another date.", + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maximum loan length: 1 day (return by %1$s). The book is already booked from %2$s." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 0140efbb5..a924a0cfb 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8377,5 +8377,6 @@ "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durée maximale du prêt : %1$d jours (retour avant le %2$s). À partir du %3$s, le livre est déjà réservé.", "Giorno non disponibile (%1$s): %2$s": "Jour non disponible (%1$s) : %2$s", "Il periodo richiesto supera la durata massima consentita.": "La période demandée dépasse la durée maximale de prêt autorisée.", - "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Aucun prêt n'est possible à partir de cette date : à partir du %s, le livre est déjà réservé. Choisissez une autre date." + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Aucun prêt n'est possible à partir de cette date : à partir du %s, le livre est déjà réservé. Choisissez une autre date.", + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Durée maximale du prêt : 1 jour (retour avant le %1$s). À partir du %2$s, le livre est déjà réservé." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 93cb7a33b..a17e00e2f 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8377,5 +8377,6 @@ "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.": "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.", "Giorno non disponibile (%1$s): %2$s": "Giorno non disponibile (%1$s): %2$s", "Il periodo richiesto supera la durata massima consentita.": "Il periodo richiesto supera la durata massima consentita.", - "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data." + "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.", + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato." } diff --git a/tests/loan-request-capped-by-scheduled-loan.spec.js b/tests/loan-request-capped-by-scheduled-loan.spec.js index f6677ac64..6477fdf30 100644 --- a/tests/loan-request-capped-by-scheduled-loan.spec.js +++ b/tests/loan-request-capped-by-scheduled-loan.spec.js @@ -104,7 +104,10 @@ test.describe('Book page: loan request capped by a scheduled loan', () => { ); if (!bookId) return; copyId = parseInt(dbQuery(`SELECT id FROM copie WHERE libro_id=${bookId} LIMIT 1`) || '0', 10); - today = dbQuery('SELECT CURDATE()'); + // The application's clock (DateHelper::today(): app.timezone, default + // Europe/Rome), not the database session's: near midnight they differ. + const tz = dbQuery("SELECT setting_value FROM system_settings WHERE category='app' AND setting_key='timezone' LIMIT 1") || 'Europe/Rome'; + today = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); // Another patron's scheduled loan: the admin's own commitments never block // the admin, so the borrower must be someone else when one exists. diff --git a/tests/loan-window-cap.unit.php b/tests/loan-window-cap.unit.php index a96ec445a..cf2c5fa02 100644 --- a/tests/loan-window-cap.unit.php +++ b/tests/loan-window-cap.unit.php @@ -86,7 +86,8 @@ // ── Sandbox ────────────────────────────────────────────────────────────────── $mkUser = static function (int $n) use ($db, $prefix, &$userIds): int { - $card = "{$prefix}U{$n}"; + // codice_tessera is a short column: a run tag plus the sequence fits it. + $card = 'LW' . substr($run, 0, 8) . $n; $email = strtolower("{$prefix}-u{$n}@example.invalid"); $cognome = "{$prefix} {$n}"; $stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) diff --git a/tests/loan-window-surfaces.unit.php b/tests/loan-window-surfaces.unit.php index c84773c9a..5117bef8e 100644 --- a/tests/loan-window-surfaces.unit.php +++ b/tests/loan-window-surfaces.unit.php @@ -99,7 +99,8 @@ // ── Sandbox ────────────────────────────────────────────────────────────────── $mkUser = static function (int $n, string $tipo = 'standard') use ($db, $prefix, &$userIds): int { - $card = "{$prefix}U{$n}"; + // codice_tessera is a short column: a run tag plus the sequence fits it. + $card = 'LW' . substr($run, 0, 8) . $n; $email = strtolower("{$prefix}-u{$n}@example.invalid"); $cognome = "{$prefix} {$n}"; $stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) @@ -213,15 +214,17 @@ 'data_scadenza' => $addDays($today, $offset + 2), ]), new SlimResponse(), $db, $ownLoan); $params = $redirectParams($response); -// The added days run from the day after the current due date: the cap is -// measured from there, and the loan being edited is never its own conflict. -$claimStart = $addDays($today, 2); +// Only the added days are checked, but the figures describe the whole loan +// from its own start (today - 2), and the loan being edited is never its own conflict. +$loanStart = $addDays($today, -2); $check(($params['error'] ?? null) === 'no_copies_available' && ($params['max_end_date'] ?? null) === $expectedMaxEnd && ($params['blocked_from'] ?? null) === $conflictStart - && (int) ($params['max_loan_days'] ?? -1) === (int) (new DateTimeImmutable($claimStart))->diff(new DateTimeImmutable($expectedMaxEnd))->days, - '3 admin edit of a loan due date across the scheduled loan: explained from the first added day, own loan excluded', + && (int) ($params['max_loan_days'] ?? -1) === (int) (new DateTimeImmutable($loanStart))->diff(new DateTimeImmutable($expectedMaxEnd))->days, + '3 admin edit of a loan due date across the scheduled loan: explained from the loan\'s own start, own loan excluded', $response->getHeaderLine('Location')); +$check(str_contains((string) LoanWindow::explainFromParams($params), (string) ((int) (new DateTimeImmutable($loanStart))->diff(new DateTimeImmutable($expectedMaxEnd))->days)), + '3b the loans list sentence names that whole-loan duration'); $resetPatron($book, $requester, $copies); // ═════════ 4: approving a pending request ════════════════════════════════════ From c1de9d41201d23952fb47fd18de6adf76d0473bd Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:31:22 +0000 Subject: [PATCH 09/26] Pass both durations through the availability route the calendars fetch The localized GET {api_book}/{id}/availability closure whitelists the payload keys, so loan_duration_days and max_loan_duration_days never left getBookAvailabilityData(): the admin loan and reservation forms kept their rendered default. The route now passes both through, and the surfaces suite pins the closure, since the controller method alone could not catch the drop. --- app/Routes/web.php | 7 ++++++- tests/loan-window-surfaces.unit.php | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/Routes/web.php b/app/Routes/web.php index 5b75cdbab..68fb7bd19 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -2866,7 +2866,12 @@ // reservation on this book — the picker would otherwise show // an all-green calendar that the date-less duplicate guard in // createReservation rejects for every date. - 'has_active_reservation' => $availability['has_active_reservation'] ?? false + 'has_active_reservation' => $availability['has_active_reservation'] ?? false, + // The durations every calendar proposes with (public book + // page, admin loan and reservation forms): the configured + // default, capped by the maximum the request endpoints enforce. + 'loan_duration_days' => $availability['loan_duration_days'] ?? null, + 'max_loan_duration_days' => $availability['max_loan_duration_days'] ?? null ] ]; $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE)); diff --git a/tests/loan-window-surfaces.unit.php b/tests/loan-window-surfaces.unit.php index 5117bef8e..83ff3e4d0 100644 --- a/tests/loan-window-surfaces.unit.php +++ b/tests/loan-window-surfaces.unit.php @@ -345,6 +345,17 @@ "13 the availability payload carries loan_duration_days ({$defaultDays}, capped by the maximum) and max_loan_duration_days ({$maxDays})", json_encode([$availability['loan_duration_days'] ?? null, $availability['max_loan_duration_days'] ?? null])); +// The HTTP route the calendars fetch whitelists the payload keys: the two +// durations must be among them, or the forms silently keep their rendered +// default (the controller method alone cannot catch that). +$routeSrc = (string) file_get_contents($root . '/app/Routes/web.php'); +$routeAt = strpos($routeSrc, "/{id:\\d+}/availability', function"); +$routeSlice = $routeAt === false ? '' : substr($routeSrc, $routeAt, 3000); +$check($routeSlice !== '' + && str_contains($routeSlice, "'loan_duration_days' => \$availability['loan_duration_days']") + && str_contains($routeSlice, "'max_loan_duration_days' => \$availability['max_loan_duration_days']"), + '13b the localized availability route passes both durations through to the calendars'); + $blocked = (new CapacityService($db))->unavailableDatesInRange($book, $today, $addDays($today, $defaultDays), $requester); $check(LoanWindow::redirectParams($today, $blocked) === ['max_loan_days' => $expectedMaxDays, 'max_end_date' => $expectedMaxEnd, 'blocked_from' => $conflictStart], '14 LoanWindow::redirectParams carries exactly the three figures every redirect uses'); From b517d4aa1a7722700ae7ee2ca6d4e0ae5f9e0d7c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:34:08 +0000 Subject: [PATCH 10/26] Capture the run tag in the sandbox user closures of the two loan-window suites $run was not in the closures' use lists, so the card numbers came out as LW1/LW2 (with an undefined-variable warning) and a leftover row from an interrupted run, or a real patron with that card, would make the UNIQUE insert throw. The tag is now captured, as intended. --- tests/loan-window-cap.unit.php | 2 +- tests/loan-window-surfaces.unit.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/loan-window-cap.unit.php b/tests/loan-window-cap.unit.php index cf2c5fa02..6b50b60dd 100644 --- a/tests/loan-window-cap.unit.php +++ b/tests/loan-window-cap.unit.php @@ -85,7 +85,7 @@ }); // ── Sandbox ────────────────────────────────────────────────────────────────── -$mkUser = static function (int $n) use ($db, $prefix, &$userIds): int { +$mkUser = static function (int $n) use ($db, $prefix, $run, &$userIds): int { // codice_tessera is a short column: a run tag plus the sequence fits it. $card = 'LW' . substr($run, 0, 8) . $n; $email = strtolower("{$prefix}-u{$n}@example.invalid"); diff --git a/tests/loan-window-surfaces.unit.php b/tests/loan-window-surfaces.unit.php index 83ff3e4d0..9cce63d58 100644 --- a/tests/loan-window-surfaces.unit.php +++ b/tests/loan-window-surfaces.unit.php @@ -98,7 +98,7 @@ }); // ── Sandbox ────────────────────────────────────────────────────────────────── -$mkUser = static function (int $n, string $tipo = 'standard') use ($db, $prefix, &$userIds): int { +$mkUser = static function (int $n, string $tipo = 'standard') use ($db, $prefix, $run, &$userIds): int { // codice_tessera is a short column: a run tag plus the sequence fits it. $card = 'LW' . substr($run, 0, 8) . $n; $email = strtolower("{$prefix}-u{$n}@example.invalid"); From 4c29ad4abcd226a667f8a46a966062962043cd43 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 14:48:19 +0200 Subject: [PATCH 11/26] Say that authorized users also open a loan-only Digital Library file DigitalAccess::decide() lets administrators, staff and anyone with "open restricted attachments" open a file whatever its visibility, so "Whoever has the book on loan" already means authorized users plus the current borrower. The description in the plugin settings named only administrators, staff and the borrower, which read as if authorized users lost access, and a reader on #480 asked for a way to combine the two. The description now names all four in the five locales, and the plugin README says it is the level to pick for that combination. --- locale/da_DK.json | 2 +- locale/de_DE.json | 2 +- locale/en_US.json | 2 +- locale/fr_FR.json | 2 +- locale/it_IT.json | 2 +- storage/plugins/digital-library/README.md | 2 +- storage/plugins/digital-library/views/settings.php | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/locale/da_DK.json b/locale/da_DK.json index c9ff8b2dd..e17953fd7 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8298,7 +8298,7 @@ "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.": "%1$d filer flyttet, %2$d ikke flyttet: kontrollér mappernes rettigheder.", "%d file spostati nella cartella protetta.": "%d filer flyttet til den beskyttede mappe.", "Accedi per aprirlo": "Log ind for at åbne den", - "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.": "Administratorer, personale og den låner, der har bogen udlånt lige nu.", + "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.": "Administratorer, personale, godkendte brugere og den låner, der har bogen udlånt lige nu.", "Archiviazione dei file": "Opbevaring af filer", "Chi ha il libro in prestito": "Den, der har bogen udlånt", "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.": "Hvem der må åbne e-bøger, lydbøger og dokumenter knyttet til posterne, og hvor filerne opbevares.", diff --git a/locale/de_DE.json b/locale/de_DE.json index 60433b038..3055bc6c9 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8298,7 +8298,7 @@ "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.": "%1$d Dateien verschoben, %2$d nicht verschoben: Ordnerberechtigungen prüfen.", "%d file spostati nella cartella protetta.": "%d Dateien in den geschützten Ordner verschoben.", "Accedi per aprirlo": "Zum Öffnen anmelden", - "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.": "Administratoren, Mitarbeitende und wer dieses Buch gerade ausgeliehen hat.", + "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.": "Administratoren, Mitarbeitende, berechtigte Nutzer und wer dieses Buch gerade ausgeliehen hat.", "Archiviazione dei file": "Dateiablage", "Chi ha il libro in prestito": "Wer das Buch ausgeliehen hat", "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.": "Wer E-Books, Hörbücher und an Titeln angehängte Dokumente öffnen darf und wo die Dateien liegen.", diff --git a/locale/en_US.json b/locale/en_US.json index 114f36767..eca7e26c3 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8298,7 +8298,7 @@ "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.": "%1$d files moved, %2$d not moved: check the folder permissions.", "%d file spostati nella cartella protetta.": "%d files moved to the protected folder.", "Accedi per aprirlo": "Sign in to open it", - "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.": "Administrators, staff and the reader who currently has that book on loan.", + "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.": "Administrators, staff, authorized users and whoever currently has that book on loan.", "Archiviazione dei file": "File storage", "Chi ha il libro in prestito": "Whoever has the book on loan", "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.": "Who may open the eBooks, audiobooks and documents attached to records, and where the files are kept.", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index de529e362..43e8d17a0 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8298,7 +8298,7 @@ "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.": "%1$d fichiers déplacés, %2$d non déplacés : vérifiez les permissions des dossiers.", "%d file spostati nella cartella protetta.": "%d fichiers déplacés dans le dossier protégé.", "Accedi per aprirlo": "Connectez-vous pour l’ouvrir", - "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.": "Administrateurs, personnel et le lecteur qui a ce livre en prêt.", + "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.": "Administrateurs, personnel, utilisateurs autorisés et le lecteur qui a ce livre en prêt.", "Archiviazione dei file": "Stockage des fichiers", "Chi ha il libro in prestito": "Qui a le livre en prêt", "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.": "Qui peut ouvrir les livres numériques, livres audio et documents joints aux notices, et où les fichiers sont conservés.", diff --git a/locale/it_IT.json b/locale/it_IT.json index 1c73a5189..01eae6012 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8298,7 +8298,7 @@ "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.": "%1$d file spostati, %2$d non spostati: controlla i permessi delle cartelle.", "%d file spostati nella cartella protetta.": "%d file spostati nella cartella protetta.", "Accedi per aprirlo": "Accedi per aprirlo", - "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.": "Amministratori, staff e l’utente che ha in corso il prestito di quel libro.", + "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.": "Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.", "Archiviazione dei file": "Archiviazione dei file", "Chi ha il libro in prestito": "Chi ha il libro in prestito", "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.": "Chi può aprire eBook, audiolibri e documenti allegati alle schede, e dove sono conservati i file.", diff --git a/storage/plugins/digital-library/README.md b/storage/plugins/digital-library/README.md index 3da54cc35..d3d4049c2 100644 --- a/storage/plugins/digital-library/README.md +++ b/storage/plugins/digital-library/README.md @@ -24,7 +24,7 @@ Ogni allegato ha una visibilità, scelta nella scheda del libro accanto all'URL: - **Chiunque**: anche senza accedere; - **Utenti registrati**: solo chi ha effettuato l'accesso; - **Amministratori, staff e utenti autorizzati** (chi ha il permesso di aprire gli allegati riservati, per ruolo o individualmente): il file non compare nella scheda pubblica per gli altri; -- **Chi ha il libro in prestito**: amministratori, staff e l'utente con un prestito in corso di quel libro. +- **Chi ha il libro in prestito**: amministratori, staff, utenti autorizzati e l'utente con un prestito in corso di quel libro, solo finché il prestito è attivo. È il livello da scegliere per dare il file sia agli utenti autorizzati sia a chi prende in prestito il libro. Nella scheda pubblica un file riservato agli utenti registrati o a chi ha il prestito compare chiuso, con il motivo (si può nascondere dalle impostazioni). L'API pubblica e OAI-PMH, che non conoscono l'utente, ricevono solo i file aperti a chiunque. L'app riceve per ogni file `visibility` e `access`, e un link firmato valido 24-25 ore per i file che l'utente può aprire; il link smette di funzionare subito se l'utente cambia password, e il pulsante «Revoca tutti i link dell’app» nelle impostazioni del plugin li revoca per tutti. Un file caricato dall'app viene allegato direttamente alla scheda, con la visibilità predefinita. diff --git a/storage/plugins/digital-library/views/settings.php b/storage/plugins/digital-library/views/settings.php index b9734a6ea..6fc286be0 100644 --- a/storage/plugins/digital-library/views/settings.php +++ b/storage/plugins/digital-library/views/settings.php @@ -68,7 +68,7 @@ 'public' => [__('Chiunque'), __('Chiunque visiti la scheda, anche senza accedere.')], 'users' => [__('Utenti registrati'), __('Solo chi ha effettuato l’accesso.')], 'staff' => [__('Amministratori, staff e utenti autorizzati'), __('Il file non compare nella scheda pubblica.')], - 'loan' => [__('Chi ha il libro in prestito'), __('Amministratori, staff e l’utente che ha in corso il prestito di quel libro.')], + 'loan' => [__('Chi ha il libro in prestito'), __('Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.')], ]; ?> From e661f847736b32937317864cc605d352e8be8ae5 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:49:02 +0000 Subject: [PATCH 12/26] Renewals: say until when a renewal is still possible, and renew until that day A renewal that crossed a scheduled loan or a reservation on every copy was refused with "another loan or reservation occupies the period" on every surface, with no way to renew for the days that were free. LoanWindow::renewalCap() bounds the extension at the first blocked day after the current due date: the new due date can be at most the day before, max_extension_days counts from the current due date (0 when the very next day is blocked: no renewal is possible, and the sentence says so). CapacityService::firstCopyCommitmentAfter() names the day a loan's own copy is needed by a later loan, the case the book-level gate cannot see with two or more copies. PrestitiController::renew() carries loan_id and the figures on both refusals (capacity and own-copy overlap) and accepts an optional new_due_date, bounded after the current due date and within the standard renewal, so the loans list and the book page offer "Rinnova fino al D" in one click under the explanation. bulkExtend() names the loan that stopped the batch and how far it could go. The Mobile API's staff renew accepts new_due_date and both staff refusals carry the figures in meta with the sentence as the message; NCIP RenewItem's ProblemDetail says until when the renewal is possible. Five locales. tests/loan-renewal-window.unit.php: the helper (bounded, one day, none, validated parameters), the admin refusal and its figures, new_due_date accepted and bounded, the own-copy case with a second copy free, the bulk refusal, and the views, Mobile API and NCIP contracts. --- app/Controllers/PrestitiController.php | 56 +++- app/Services/CapacityService.php | 25 ++ app/Support/LoanWindow.php | 94 ++++++ app/Views/libri/scheda_libro.php | 23 +- app/Views/prestiti/index.php | 34 ++- locale/da_DK.json | 7 +- locale/de_DE.json | 7 +- locale/en_US.json | 7 +- locale/fr_FR.json | 7 +- locale/it_IT.json | 7 +- .../StaffCirculationController.php | 34 ++- .../src/Support/CirculationOutcome.php | 19 +- .../src/Support/StaffCirculationOpenApi.php | 8 +- .../plugins/ncip-server/NcipServerPlugin.php | 28 +- tests/loan-renewal-window.unit.php | 282 ++++++++++++++++++ 15 files changed, 617 insertions(+), 21 deletions(-) create mode 100644 tests/loan-renewal-window.unit.php diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index 3b82be2b3..fd6f78125 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -1992,6 +1992,7 @@ public function bulkExtend(Request $request, Response $response, mysqli $db): Re // Before-snapshot inside the lock, so the audit diff shows the // pre-extension due date (same event renew() records). $activityBefore = \App\Support\ActivityLog::loadLoanSnapshot($db, (int) $loan['id']); + $conflict = []; $applied = $this->applyBulkLoanExtension( $loan, $days, @@ -1999,13 +2000,16 @@ public function bulkExtend(Request $request, Response $response, mysqli $db): Re $capacity, $copyOverlap, $update, - $expectedBookByLoan + $expectedBookByLoan, + $conflict ); if ($applied === null) { $copyOverlap->close(); $update->close(); $db->rollback(); - return $response->withHeader('Location', $backUrl . '?error=bulk_extend_conflict')->withStatus(302); + // Which loan stopped the batch, and how far it could go (LoanWindow). + $query = http_build_query(['error' => 'bulk_extend_conflict', 'loan_id' => (int) $loan['id']] + $conflict); + return $response->withHeader('Location', $backUrl . '?' . $query)->withStatus(302); } if ($applied > 0) { \App\Support\ActivityLog::recordLoanEvent($db, (int) $loan['id'], 'loan.renewed', $activityBefore, source: 'manual'); @@ -2050,6 +2054,8 @@ public function bulkExtend(Request $request, Response $response, mysqli $db): Re * * @param array $loan locked prestiti row * @param array $expectedBookByLoan loan id -> book id snapshot taken before locking + * @param array $conflict Out: on a null return, the + * longest extension still possible (LoanWindow::renewalRedirectParams()). * @return int|null affected row count (0/1), or null on a capacity/copy conflict (caller rolls back) */ private function applyBulkLoanExtension( @@ -2059,7 +2065,8 @@ private function applyBulkLoanExtension( \App\Services\CapacityService $capacity, \mysqli_stmt $copyOverlap, \mysqli_stmt $update, - array $expectedBookByLoan + array $expectedBookByLoan, + array &$conflict = [] ): ?int { $loanId = (int) $loan['id']; $bookId = (int) $loan['libro_id']; @@ -2088,7 +2095,10 @@ private function applyBulkLoanExtension( // previously scanned the whole loan window while capacity scanned the // extension window: two different intervals for one decision (CodeRabbit). $extensionStart = $dueDate->modify('+1 day')->format('Y-m-d'); + $currentDue = $dueDate->format('Y-m-d'); if (!$capacity->hasFreeCapacity($bookId, $extensionStart, $newDueDate, excludePrestitoId: $loanId)) { + $blocked = $capacity->unavailableDatesInRange($bookId, $extensionStart, $newDueDate, null, $loanId); + $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked); return null; } @@ -2097,6 +2107,9 @@ private function applyBulkLoanExtension( $copyOverlap->bind_param('iisss', $copyId, $loanId, $newDueDate, $today, $extensionStart); $copyOverlap->execute(); if ((bool) $copyOverlap->get_result()->fetch_row()) { + // The loan's own copy is needed by a later loan: name that day. + $copyFrom = $capacity->firstCopyCommitmentAfter($copyId, $loanId, $currentDue); + $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $copyFrom !== null ? [$copyFrom] : []); return null; } } @@ -2208,6 +2221,20 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // Calculate proposed new due date for conflict checking $currentDueDate = $loan['data_scadenza']; $proposedNewDueDate = date('Y-m-d', strtotime($currentDueDate . " +{$renewDays} days")); + // Optional shorter renewal: the day before a scheduled loan or a + // reservation, as the refusal below proposes ("Rinnova fino al D"). + // A real day, after the current due date, never past the standard + // renewal. + $requestedDue = trim((string) ($data['new_due_date'] ?? '')); + if ($requestedDue !== '') { + if (!\App\Support\DateHelper::isISODateFormat($requestedDue) + || $requestedDue <= (string) $currentDueDate + || $requestedDue > $proposedNewDueDate) { + $errorUrl = $redirectTo ?? url('/admin/loans'); + $separator = strpos($errorUrl, '?') === false ? '?' : '&'; + return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_date_format'))->withStatus(302); + } + } $libroId = (int) $loan['libro_id']; // Start transaction BEFORE conflict check to prevent race conditions @@ -2279,6 +2306,16 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // Use the locked values for calculations (in case they changed) $currentDueDate = $lockedLoan['data_scadenza']; $proposedNewDueDate = date('Y-m-d', strtotime($currentDueDate . " +{$renewDays} days")); + if ($requestedDue !== '') { + // Re-bound under the lock: the due date may have moved meanwhile. + if ($requestedDue <= (string) $currentDueDate || $requestedDue > $proposedNewDueDate) { + $db->rollback(); + $errorUrl = $redirectTo ?? url('/admin/loans'); + $separator = strpos($errorUrl, '?') === false ? '?' : '&'; + return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_date_format'))->withStatus(302); + } + $proposedNewDueDate = $requestedDue; + } $currentRenewals = (int) $lockedLoan['renewals']; // Refresh libro_id from locked loan to ensure consistency (la riga `libri` è // già stata lockata sopra con lo stesso id all'inizio della transazione). @@ -2296,11 +2333,18 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $extensionEnd = $proposedNewDueDate; $capacity = new \App\Services\CapacityService($db); + // A refusal names the longest renewal still possible (LoanWindow), + // and which loan it is about, so the page can offer "renew until D". + $explainRefusal = static function (array $blocked) use ($id, $currentDueDate): string { + return http_build_query(['error' => 'extension_conflicts', 'loan_id' => $id] + + \App\Support\LoanWindow::renewalRedirectParams((string) $currentDueDate, $blocked)); + }; if (!$capacity->hasFreeCapacity($libroId, $extensionStart, $extensionEnd, excludePrestitoId: $id)) { $db->rollback(); + $blocked = $capacity->unavailableDatesInRange($libroId, $extensionStart, $extensionEnd, null, $id); $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; - return $response->withHeader('Location', url($errorUrl . $separator . 'error=extension_conflicts'))->withStatus(302); + return $response->withHeader('Location', url($errorUrl . $separator . $explainRefusal($blocked)))->withStatus(302); } // Overlap-check sulla COPIA del prestito rinnovato (M5c): il check di @@ -2329,9 +2373,11 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $copyOvlStmt->close(); if ($hasCopyOverlap) { $db->rollback(); + // The loan's own copy is needed by a later loan: name that day. + $copyFrom = $capacity->firstCopyCommitmentAfter($copiaId, $id, (string) $currentDueDate); $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; - return $response->withHeader('Location', url($errorUrl . $separator . 'error=extension_conflicts'))->withStatus(302); + return $response->withHeader('Location', url($errorUrl . $separator . $explainRefusal($copyFrom !== null ? [$copyFrom] : [])))->withStatus(302); } } diff --git a/app/Services/CapacityService.php b/app/Services/CapacityService.php index 5cc3031be..7f36ccdb5 100644 --- a/app/Services/CapacityService.php +++ b/app/Services/CapacityService.php @@ -264,6 +264,31 @@ public function unavailableDatesInRange( return $conflicts; } + /** + * The start of the first commitment on one physical copy that begins after + * $afterDate, excluding one loan: the same rows the per-copy overlap checks + * of renew(), bulkExtend() and NCIP RenewItem count (holding states, and a + * reservation-conversion pending that already holds the copy). With two + * or more copies the book-level gate can pass while the loan's own copy is + * needed by a later loan; this names the day it is needed from. + */ + public function firstCopyCommitmentAfter(int $copiaId, int $excludePrestitoId, string $afterDate): ?string + { + $stmt = $this->db->prepare( + "SELECT MIN(data_prestito) AS first_start FROM prestiti + WHERE copia_id = ? AND id <> ? AND data_prestito > ? + AND ((attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) + OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL))" + ); + $stmt->bind_param('iis', $copiaId, $excludePrestitoId, $afterDate); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + $first = $row['first_start'] ?? null; + + return is_string($first) && $first !== '' ? substr($first, 0, 10) : null; + } + /** * @return list Y-m-d days from $start to $end inclusive. */ diff --git a/app/Support/LoanWindow.php b/app/Support/LoanWindow.php index 44a70323b..e89221c93 100644 --- a/app/Support/LoanWindow.php +++ b/app/Support/LoanWindow.php @@ -120,6 +120,100 @@ public static function explainFromParams(array $params): ?string return self::explain((int) $days, $end, $from); } + /** + * The longest renewal still possible when a scheduled loan or a reservation + * on every copy begins inside the extension window. The extension adds the + * days after $currentDue; the first blocked day among them bounds it: the + * new due date can be at most the day before, max_extension_days counts + * from the current due date (0 when the very next day is blocked: no + * renewal is possible, and the sentence says so). + * + * @param string[] $conflictDates Y-m-d days inside (currentDue, proposedDue] + * without a free copy, or the first day of a + * later commitment on the loan's own copy. + * @return array{first_conflict_date: string, max_due_date: string, max_extension_days: int}|null + * null when nothing bounds the extension. + */ + public static function renewalCap(string $currentDue, array $conflictDates): ?array + { + $future = array_filter($conflictDates, static fn (string $d): bool => $d > $currentDue); + if ($future === []) { + return null; + } + $firstConflict = min($future); + $maxDue = (new DateTime($firstConflict))->modify('-1 day')->format('Y-m-d'); + $maxDays = (int) (new DateTime($currentDue))->diff(new DateTime($maxDue))->days; + + return [ + 'first_conflict_date' => $firstConflict, + 'max_due_date' => $maxDue, + 'max_extension_days' => $maxDays, + ]; + } + + /** The explanation for a bounded renewal, in the current locale. */ + public static function explainRenewal(int $maxDays, string $maxDueDate, string $firstConflictDate): string + { + if ($maxDays < 1) { + return __('Rinnovo non possibile: dal %s il libro è già prenotato.', self::displayDate($firstConflictDate)); + } + if ($maxDays === 1) { + return __( + 'Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.', + self::displayDate($maxDueDate), + self::displayDate($firstConflictDate) + ); + } + return __( + 'Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.', + $maxDays, + self::displayDate($maxDueDate), + self::displayDate($firstConflictDate) + ); + } + + /** + * Redirect parameters that carry a renewal cap through a 302. Empty when + * nothing bounds the extension. + * + * @param string[] $conflictDates + * @return array{max_extension_days?: int, max_due_date?: string, blocked_from?: string} + */ + public static function renewalRedirectParams(string $currentDue, array $conflictDates): array + { + $cap = self::renewalCap($currentDue, $conflictDates); + if ($cap === null) { + return []; + } + return [ + 'max_extension_days' => $cap['max_extension_days'], + 'max_due_date' => $cap['max_due_date'], + 'blocked_from' => $cap['first_conflict_date'], + ]; + } + + /** + * The renewal explanation rebuilt from redirect parameters, or null when + * they are absent or malformed (query strings are user-editable). + * + * @param array $params + */ + public static function explainRenewalFromParams(array $params): ?string + { + $days = $params['max_extension_days'] ?? null; + $due = $params['max_due_date'] ?? null; + $from = $params['blocked_from'] ?? null; + if (!is_scalar($days) || !is_string($due) || !is_string($from)) { + return null; + } + if (preg_match('/^\d{1,4}$/', (string) $days) !== 1 + || !DateHelper::isISODateFormat($due) + || !DateHelper::isISODateFormat($from)) { + return null; + } + return self::explainRenewal((int) $days, $due, $from); + } + private static function displayDate(string $ymd): string { $format = str_starts_with(I18n::getLocale(), 'en') ? 'm-d-Y' : 'd-m-Y'; diff --git a/app/Views/libri/scheda_libro.php b/app/Views/libri/scheda_libro.php index dd0b51d59..d7fec5fdc 100644 --- a/app/Views/libri/scheda_libro.php +++ b/app/Views/libri/scheda_libro.php @@ -72,7 +72,28 @@ case 'extension_conflicts': // #336: renew() emette 'extension_conflicts' — il vecchio switch conosceva // solo 'renewal_conflict' (mai emesso) e mostrava il messaggio generico. - echo __('Impossibile rinnovare: un altro prestito o prenotazione occupa il periodo richiesto.'); + // Quando un impegno successivo limita il rinnovo, il redirect porta + // fino a quando è ancora possibile (LoanWindow): lo diciamo e lo + // proponiamo con un clic. + $renewalExplained = \App\Support\LoanWindow::explainRenewalFromParams($_GET); + echo htmlspecialchars( + __('Impossibile rinnovare: un altro prestito o prenotazione occupa il periodo richiesto.') + . ($renewalExplained !== null ? ' ' . $renewalExplained : ''), + ENT_QUOTES, + 'UTF-8' + ); + $renewalLoanId = is_scalar($_GET['loan_id'] ?? null) ? (int) $_GET['loan_id'] : 0; + if ($renewalExplained !== null && $renewalLoanId > 0 && (int) ($_GET['max_extension_days'] ?? 0) >= 1) { + $renewalMaxDue = (string) $_GET['max_due_date']; + ?> +
+ + + + +
+ 0 && (int) ($_GET['max_extension_days'] ?? 0) >= 1) { + $renewalMaxDue = (string) $_GET['max_due_date']; + ?> +
+ + + +
+ ; + if (detail) message += ' ' + detail; } Swal.fire({ icon: 'error', title: t('Errore'), diff --git a/locale/da_DK.json b/locale/da_DK.json index 2eb67aa06..82232ab71 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8378,5 +8378,10 @@ "Giorno non disponibile (%1$s): %2$s": "Dag ikke tilgængelig (%1$s): %2$s", "Il periodo richiesto supera la durata massima consentita.": "Den ønskede periode overstiger den maksimalt tilladte lånetid.", "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Intet lån er muligt fra denne startdato: fra %s er bogen allerede reserveret. Vælg en anden dato.", - "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maksimal lånetid: 1 dag (aflevering senest %1$s). Fra %2$s er bogen allerede reserveret." + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maksimal lånetid: 1 dag (aflevering senest %1$s). Fra %2$s er bogen allerede reserveret.", + "Rinnovo non possibile: dal %s il libro è già prenotato.": "Ingen forlængelse er mulig: fra %s er bogen allerede reserveret.", + "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.": "Forlængelse mulig med højst 1 dag (indtil %1$s): fra %2$s er bogen allerede reserveret.", + "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.": "Forlængelse mulig med højst %1$d dage (indtil %2$s): fra %3$s er bogen allerede reserveret.", + "Rinnova fino al %s": "Forlæng indtil %s", + "Prestito n. %1$d: %2$s": "Lån nr. %1$d: %2$s" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 171eb9852..de34c07d4 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8378,5 +8378,10 @@ "Giorno non disponibile (%1$s): %2$s": "Tag nicht verfügbar (%1$s): %2$s", "Il periodo richiesto supera la durata massima consentita.": "Der gewünschte Zeitraum überschreitet die maximal zulässige Leihdauer.", "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Ab diesem Startdatum ist keine Ausleihe möglich: ab dem %s ist das Buch bereits vorgemerkt. Wählen Sie ein anderes Datum.", - "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maximale Leihdauer: 1 Tag (Rückgabe bis %1$s). Ab dem %2$s ist das Buch bereits vorgemerkt." + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maximale Leihdauer: 1 Tag (Rückgabe bis %1$s). Ab dem %2$s ist das Buch bereits vorgemerkt.", + "Rinnovo non possibile: dal %s il libro è già prenotato.": "Keine Verlängerung möglich: ab dem %s ist das Buch bereits vorgemerkt.", + "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.": "Verlängerung um höchstens 1 Tag möglich (bis %1$s): ab dem %2$s ist das Buch bereits vorgemerkt.", + "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.": "Verlängerung um höchstens %1$d Tage möglich (bis %2$s): ab dem %3$s ist das Buch bereits vorgemerkt.", + "Rinnova fino al %s": "Bis %s verlängern", + "Prestito n. %1$d: %2$s": "Ausleihe Nr. %1$d: %2$s" } diff --git a/locale/en_US.json b/locale/en_US.json index 99aca7495..56748ab93 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8378,5 +8378,10 @@ "Giorno non disponibile (%1$s): %2$s": "Day not available (%1$s): %2$s", "Il periodo richiesto supera la durata massima consentita.": "The requested period exceeds the maximum allowed loan length.", "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "No loan is possible from this start date: the book is already booked from %s. Choose another date.", - "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maximum loan length: 1 day (return by %1$s). The book is already booked from %2$s." + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Maximum loan length: 1 day (return by %1$s). The book is already booked from %2$s.", + "Rinnovo non possibile: dal %s il libro è già prenotato.": "No renewal is possible: the book is already booked from %s.", + "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.": "Renewal possible by at most 1 day (until %1$s): the book is already booked from %2$s.", + "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.": "Renewal possible by at most %1$d days (until %2$s): the book is already booked from %3$s.", + "Rinnova fino al %s": "Renew until %s", + "Prestito n. %1$d: %2$s": "Loan no. %1$d: %2$s" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index a924a0cfb..a249d820a 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8378,5 +8378,10 @@ "Giorno non disponibile (%1$s): %2$s": "Jour non disponible (%1$s) : %2$s", "Il periodo richiesto supera la durata massima consentita.": "La période demandée dépasse la durée maximale de prêt autorisée.", "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Aucun prêt n'est possible à partir de cette date : à partir du %s, le livre est déjà réservé. Choisissez une autre date.", - "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Durée maximale du prêt : 1 jour (retour avant le %1$s). À partir du %2$s, le livre est déjà réservé." + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Durée maximale du prêt : 1 jour (retour avant le %1$s). À partir du %2$s, le livre est déjà réservé.", + "Rinnovo non possibile: dal %s il libro è già prenotato.": "Aucune prolongation possible : à partir du %s, le livre est déjà réservé.", + "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.": "Prolongation possible d'au plus 1 jour (jusqu'au %1$s) : à partir du %2$s, le livre est déjà réservé.", + "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.": "Prolongation possible d'au plus %1$d jours (jusqu'au %2$s) : à partir du %3$s, le livre est déjà réservé.", + "Rinnova fino al %s": "Prolonger jusqu'au %s", + "Prestito n. %1$d: %2$s": "Prêt n° %1$d : %2$s" } diff --git a/locale/it_IT.json b/locale/it_IT.json index a17e00e2f..60708a0ba 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8378,5 +8378,10 @@ "Giorno non disponibile (%1$s): %2$s": "Giorno non disponibile (%1$s): %2$s", "Il periodo richiesto supera la durata massima consentita.": "Il periodo richiesto supera la durata massima consentita.", "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.": "Da questa data di inizio non è possibile alcun prestito: dal %s il libro è già prenotato. Scegli un'altra data.", - "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato." + "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.": "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.", + "Rinnovo non possibile: dal %s il libro è già prenotato.": "Rinnovo non possibile: dal %s il libro è già prenotato.", + "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.": "Rinnovo possibile al massimo di 1 giorno (fino al %1$s): dal %2$s il libro è già prenotato.", + "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.": "Rinnovo possibile al massimo di %1$d giorni (fino al %2$s): dal %3$s il libro è già prenotato.", + "Rinnova fino al %s": "Rinnova fino al %s", + "Prestito n. %1$d: %2$s": "Prestito n. %1$d: %2$s" } diff --git a/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php b/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php index cd42ab3a2..34153c2f5 100644 --- a/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php +++ b/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php @@ -408,16 +408,26 @@ public function renew(ServerRequestInterface $request, ResponseInterface $respon if ($this->reader->loanRow($id) === null) { return $this->notFound($response); } + // Optional shorter renewal (Mobile API 1.8.2): the day before a later + // commitment, as a not_available refusal proposes in its meta. The + // core bounds it (after the current due date, within the standard + // renewal). + $json = JsonBody::parse($request); + $newDue = isset($json['new_due_date']) ? trim((string) $json['new_due_date']) : ''; + if ($newDue !== '' && !\App\Support\DateHelper::isISODateFormat($newDue)) { + return $this->invalid($response, 'new_due_date', __('Data richiesta non valida.')); + } + $body = $newDue !== '' ? ['new_due_date' => $newDue] : []; $db = $this->db; $outcome = Delegation::run(static fn (): ResponseInterface => (new PrestitiController())->renew( - Delegation::request('POST', '/admin/loans/renew/' . $id, []), + Delegation::request('POST', '/admin/loans/renew/' . $id, $body), new SlimResponse(), $db, $id )); $error = $outcome->errorCode(); if ($error !== null) { - return CirculationOutcome::error($response, CirculationOutcome::RENEW, $error, 'staff loan renew'); + return CirculationOutcome::error($response, CirculationOutcome::RENEW, $error, 'staff loan renew', self::windowDetails($outcome)); } if ($outcome->queryValue('renewed') !== '1') { SecureLogger::error('[MobileApi] staff loan renew: unexpected outcome HTTP ' . $outcome->status); @@ -453,7 +463,7 @@ public function bulkExtend(ServerRequestInterface $request, ResponseInterface $r )); $error = $outcome->errorCode(); if ($error !== null) { - return CirculationOutcome::error($response, CirculationOutcome::BULK, $error, 'staff bulk extend'); + return CirculationOutcome::error($response, CirculationOutcome::BULK, $error, 'staff bulk extend', self::windowDetails($outcome)); } return ResponseEnvelope::success($response, [ @@ -695,6 +705,24 @@ private function notFound(ResponseInterface $response, ?string $message = null): return ResponseEnvelope::error($response, 'not_found', $message ?? __('Prestito non trovato.'), 404); } + /** + * The renewal-window figures a core redirect may carry beside its error + * code (LoanWindow::renewalRedirectParams(), plus the loan they are about). + * + * @return array + */ + private static function windowDetails(\App\Plugins\MobileApi\Support\DelegationOutcome $outcome): array + { + $details = []; + foreach (['max_extension_days', 'max_due_date', 'blocked_from', 'loan_id'] as $key) { + $value = $outcome->queryValue($key); + if ($value !== null) { + $details[$key] = $value; + } + } + return $details; + } + private function invalid(ResponseInterface $response, string $field, string $message): ResponseInterface { return ResponseEnvelope::error($response, 'validation_error', $message, 422, ['field' => $field]); diff --git a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php index 7f1432a35..74a0df5f2 100644 --- a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php +++ b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php @@ -110,8 +110,12 @@ private function __construct() * Error envelope for a core `?error=` code, using one of the tables above. * * @param array $table + * @param array $details Redirect parameters beside the + * error code: a renewal or extension refused for a later commitment + * carries the longest renewal still possible (LoanWindow), surfaced + * as the message and as meta so the app can offer "renew until D". */ - public static function error(ResponseInterface $response, array $table, string $coreCode, string $context): ResponseInterface + public static function error(ResponseInterface $response, array $table, string $coreCode, string $context, array $details = []): ResponseInterface { $entry = $table[$coreCode] ?? null; if ($entry === null) { @@ -129,8 +133,19 @@ public static function error(ResponseInterface $response, array $table, string $ if ($withReason) { $meta['reason'] = $coreCode; } + $message = self::message($coreCode); + $explained = \App\Support\LoanWindow::explainRenewalFromParams($details); + if ($explained !== null) { + $message = $explained; + $meta['max_extension_days'] = (int) $details['max_extension_days']; + $meta['max_due_date'] = $details['max_due_date']; + $meta['first_conflict_date'] = $details['blocked_from']; + if (isset($details['loan_id']) && is_numeric($details['loan_id'])) { + $meta['loan_id'] = (int) $details['loan_id']; + } + } - return ResponseEnvelope::error($response, $code, self::message($coreCode), $status, $meta); + return ResponseEnvelope::error($response, $code, $message, $status, $meta); } /** diff --git a/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php b/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php index 74bc66742..af759e992 100644 --- a/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php +++ b/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php @@ -111,7 +111,7 @@ private static function paths(): array ])], '/staff/loans/bulk-extend' => ['post' => O::op($t, 'staffBulkExtend', 'Push the due date of several out-loans by N days', [ '200' => O::ok('Extended.', O::object(['extended' => ['type' => 'integer'], 'days' => ['type' => 'integer']])), - ] + O::invalid() + O::conflict('`not_available`: a capacity conflict rolled back the whole batch.'), [ + ] + O::invalid() + O::conflict('`not_available`: a capacity conflict rolled back the whole batch. meta names the loan that stopped it (`loan_id`) and, when a later commitment bounds it, the longest extension still possible (`max_extension_days`, `max_due_date`, `first_conflict_date`, Mobile API 1.8.2).'), [ 'requestBody' => O::inlineBody(['ids' => ['type' => 'array', 'maxItems' => 500, 'items' => ['type' => 'integer']], 'days' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 365]], ['ids', 'days']), ])], '/staff/loans/bulk-recall' => ['post' => O::op($t, 'staffBulkRecall', 'Send the overdue reminder to several loans (5/min)', [ @@ -134,9 +134,11 @@ private static function paths(): array '/staff/loans/{id}/return' => ['post' => O::op($t, 'staffReturnLoan', 'Record the return with the copy outcome', ['200' => $loan] + O::notFound() + $returnErrors, $loanId + [ 'requestBody' => O::inlineBody($returnBody), ])], - '/staff/loans/{id}/renew' => ['post' => O::op($t, 'staffRenewLoan', 'Renew by the configured loan duration', [ + '/staff/loans/{id}/renew' => ['post' => O::op($t, 'staffRenewLoan', 'Renew by the configured loan duration, or until an earlier date', [ '200' => $loan, - ] + O::notFound() + O::conflict('`loan_not_active`, `loan_overdue`, `max_renewals`, `not_available`, `not_eligible`.'), $loanId)], + ] + O::notFound() + O::invalid() + O::conflict('`loan_not_active`, `loan_overdue`, `max_renewals`, `not_available`, `not_eligible`. For `not_available`, when a later commitment bounds the renewal, the message names the longest renewal still possible and meta carries `max_extension_days`, `max_due_date` and `first_conflict_date` (Mobile API 1.8.2): send that date as `new_due_date` to renew until it.'), $loanId + [ + 'requestBody' => O::inlineBody(['new_due_date' => ['type' => 'string', 'format' => 'date', 'description' => 'Optional. Renew until this day instead of the configured duration: after the current due date, not past the standard renewal.']], []), + ])], '/staff/loans/{id}/recall' => ['post' => O::op($t, 'staffRecallLoan', 'Send the overdue reminder now (10/min)', ['200' => $sent] + O::notFound(), $loanId)], '/staff/loans/{id}/receipt.pdf' => ['get' => O::op($t, 'staffLoanReceipt', 'Loan receipt PDF (30/min)', [ '200' => O::file('application/pdf', 'Content-Disposition: attachment; filename="prestito_{id}_{Ymd}.pdf".'), diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index 34027c1fb..bf2c3f12a 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -95,6 +95,15 @@ class NcipServerPlugin private \mysqli $db; private ?int $pluginId = null; + /** + * Set by extendLoan() when a later commitment bounds the renewal: the + * longest renewal still possible (LoanWindow::renewalCap()), so the + * RenewItem Problem can say until when instead of a bare "not renewable". + * + * @var array{first_conflict_date: string, max_due_date: string, max_extension_days: int}|null + */ + private ?array $renewalCap = null; + /** Partner attivo risolto dal FromAgencyId del messaggio corrente (per il log transazioni). */ private ?int $currentPartnerId = null; @@ -1091,9 +1100,19 @@ private function handleRenewItem( default => 'temporary-processing-failure', }; SecureLogger::error('[NcipServer] extendLoan failed: ' . $failureReason); + // ProblemDetail is free text for the partner's operator: when a later + // commitment bounds the renewal, say until when it is still possible. + $detail = 'Failed to extend loan'; + $cap = $this->renewalCap; + if ($cap !== null) { + $detail .= $cap['max_extension_days'] < 1 + ? ': no renewal is possible, every copy is booked from ' . $cap['first_conflict_date'] + : ': renewal possible at most until ' . $cap['max_due_date'] . ' (' . $cap['max_extension_days'] + . ' day' . ($cap['max_extension_days'] === 1 ? '' : 's') . '), every copy is booked from ' . $cap['first_conflict_date']; + } return $this->xmlResponse( $response, - $this->buildProblem('Failed to extend loan', $problemType) + $this->buildProblem($detail, $problemType) ); } @@ -2679,6 +2698,7 @@ private function extendLoan( ): ?string { $failureReason = 'db_error'; + $this->renewalCap = null; if ($expectedBookId <= 0 || $expectedUserId <= 0) { $failureReason = 'identity_changed'; return null; @@ -2767,6 +2787,9 @@ private function extendLoan( if (!$capacity->hasFreeCapacity($expectedBookId, $extensionStart, $newDueDate, excludePrestitoId: $loanId)) { $this->db->rollback(); $failureReason = 'no_capacity'; + // Same figures the web renew() redirect carries (LoanWindow). + $blocked = $capacity->unavailableDatesInRange($expectedBookId, $extensionStart, $newDueDate, null, $loanId); + $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked); return null; } @@ -2791,6 +2814,9 @@ private function extendLoan( if ($hasOverlap) { $this->db->rollback(); $failureReason = 'overlap'; + // The loan's own copy is needed by a later loan: name that day. + $copyFrom = $capacity->firstCopyCommitmentAfter($copyId, $loanId, $currentDue); + $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $copyFrom !== null ? [$copyFrom] : []); return null; } } diff --git a/tests/loan-renewal-window.unit.php b/tests/loan-renewal-window.unit.php new file mode 100644 index 000000000..a46acb868 --- /dev/null +++ b/tests/loan-renewal-window.unit.php @@ -0,0 +1,282 @@ +set_charset('utf8mb4'); + DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — renewal window suite is mandatory: {$e->getMessage()}\n"); + exit(1); +} + +$run = bin2hex(random_bytes(5)); +$prefix = "ZZ_LWRNW_{$run}"; +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label, string $detail = '') use (&$passed, &$failed): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . ($ok || $detail === '' ? '' : " [{$detail}]") . PHP_EOL; + $ok ? $passed++ : $failed++; +}; + +/** @var int[] $bookIds */ +$bookIds = []; +/** @var int[] $userIds */ +$userIds = []; +$cleanup = static function () use ($db, &$bookIds, &$userIds): void { + foreach ($bookIds as $id) { + $db->query("DELETE FROM prestiti WHERE libro_id = {$id}"); + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$id}"); + $db->query("DELETE FROM copie WHERE libro_id = {$id}"); + $db->query("DELETE FROM libri WHERE id = {$id}"); + } + foreach ($userIds as $id) { + $db->query("DELETE FROM utenti WHERE id = {$id}"); + } +}; +set_exception_handler(static function (Throwable $e) use ($cleanup, $db): void { + try { $cleanup(); } catch (Throwable) {} + fwrite(STDERR, 'FAIL: uncaught ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . PHP_EOL); + $db->close(); + exit(1); +}); + +// ── Sandbox ────────────────────────────────────────────────────────────────── +$mkUser = static function (int $n, string $tipo = 'standard') use ($db, $prefix, $run, &$userIds): int { + // codice_tessera is a short column: a run tag plus the sequence fits it. + $card = 'LR' . substr($run, 0, 8) . $n; + $email = strtolower("{$prefix}-u{$n}@example.invalid"); + $cognome = "{$prefix} {$n}"; + $stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata) + VALUES (?, 'LoanWindow', ?, ?, 'x', 'attivo', ?, 1)"); + $stmt->bind_param('ssss', $card, $cognome, $email, $tipo); + $stmt->execute(); + $stmt->close(); + $userIds[] = (int) $db->insert_id; + return (int) $db->insert_id; +}; +$mkBook = static function (int $copies, int $n) use ($db, $prefix, &$bookIds): array { + $title = "{$prefix} book {$n}"; + $stmt = $db->prepare("INSERT INTO libri (titolo, copie_totali, copie_disponibili, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())"); + $stmt->bind_param('sii', $title, $copies, $copies); + $stmt->execute(); + $stmt->close(); + $bookId = (int) $db->insert_id; + $bookIds[] = $bookId; + $copyIds = []; + for ($i = 1; $i <= $copies; $i++) { + $code = "{$prefix}-{$bookId}-C{$i}"; + $stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'disponibile')"); + $stmt->bind_param('is', $bookId, $code); + $stmt->execute(); + $stmt->close(); + $copyIds[] = (int) $db->insert_id; + } + return [$bookId, $copyIds]; +}; +$mkLoan = static function (int $bookId, ?int $copyId, int $userId, string $stato, string $from, string $to) use ($db): int { + $stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo, renewals, warning_sent, overdue_notification_sent) + VALUES (?, ?, ?, ?, ?, ?, 'diretto', 1, 0, 0, 0)"); + $stmt->bind_param('iiisss', $bookId, $copyId, $userId, $from, $to, $stato); + $stmt->execute(); + $stmt->close(); + return (int) $db->insert_id; +}; +$addDays = static fn (string $ymd, int $days): string => (new DateTimeImmutable($ymd))->modify(($days >= 0 ? '+' : '') . "{$days} days")->format('Y-m-d'); +$redirectParams = static function ($response): array { + $query = parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY); + $params = []; + parse_str(is_string($query) ? $query : '', $params); + return $params; +}; +$loanRow = static fn (int $id): array => $db->query("SELECT data_scadenza, renewals FROM prestiti WHERE id = {$id}")->fetch_assoc() ?: []; + +$settings = new SettingsRepository($db); +$renewDays = $settings->loanDurationDays(); +$maxRenewals = (int) ($settings->get('loans', 'max_renewals', '3') ?? 3); +if ($maxRenewals === 0) { + echo "SKIP: loans.max_renewals is 0 on this installation: renew() refuses every renewal before any window is computed." . PHP_EOL; + exit(0); +} +$today = DateHelper::today(); +// The borrower's loan is due in two days; the other patron's scheduled loan +// starts a few days later, inside the standard renewal. +$dueDate = $addDays($today, 2); +$offset = max(2, min(5, $renewDays - 1)); +$conflictStart = $addDays($dueDate, $offset); +$expectedMaxDue = $addDays($conflictStart, -1); +$expectedMaxDays = $offset - 1; + +$borrower = $mkUser(1); +$otherUser = $mkUser(2); +$admin = $mkUser(3, 'admin'); +[$book, $copies] = $mkBook(1, 1); +$ownLoan = $mkLoan($book, $copies[0], $borrower, 'in_corso', $addDays($today, -10), $dueDate); +$db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$copies[0]}"); +$mkLoan($book, $copies[0], $otherUser, 'prenotato', $conflictStart, $addDays($conflictStart, 14)); +echo "Sandbox: today {$today}, own loan due {$dueDate}, scheduled loan from {$conflictStart}, renewal {$renewDays} days" . PHP_EOL; + +$asAdmin = static function () use ($admin): void { + $_SESSION['user'] = ['tipo_utente' => 'admin', 'id' => $admin]; +}; +$renew = static function (int $loanId, array $body = []) use ($db, $asAdmin, $redirectParams): array { + $asAdmin(); + $request = (new ServerRequestFactory())->createServerRequest('POST', "/admin/loans/renew/{$loanId}")->withParsedBody($body); + $response = (new PrestitiController())->renew($request, new SlimResponse(), $db, $loanId); + return $redirectParams($response); +}; + +// ═════════ 1-3: the helper ═══════════════════════════════════════════════════ +$cap = LoanWindow::renewalCap($dueDate, [$conflictStart, $addDays($conflictStart, 1)]); +$check($cap !== null + && $cap['first_conflict_date'] === $conflictStart + && $cap['max_due_date'] === $expectedMaxDue + && $cap['max_extension_days'] === $expectedMaxDays, + "1 LoanWindow::renewalCap bounds the renewal to {$expectedMaxDays} days ending {$expectedMaxDue}", + json_encode($cap)); +$next = LoanWindow::renewalCap($dueDate, [$addDays($dueDate, 1)]); +$check($next !== null && $next['max_extension_days'] === 0 && $next['max_due_date'] === $dueDate + && LoanWindow::renewalCap($dueDate, []) === null + && LoanWindow::renewalCap($dueDate, [$addDays($dueDate, -3)]) === null, + '2 a conflict on the very next day leaves 0 days; nothing bounds an empty or earlier list'); +$sentence = LoanWindow::explainRenewal($expectedMaxDays, $expectedMaxDue, $conflictStart); +$none = LoanWindow::explainRenewal(0, $dueDate, $addDays($dueDate, 1)); +$check(str_contains($sentence, (string) $expectedMaxDays) + && $none !== $sentence && str_contains($none, substr($addDays($dueDate, 1), 0, 4)) && !str_contains($none, ' 0 ') + && str_contains(LoanWindow::explainRenewal(1, $dueDate, $addDays($dueDate, 2)), '1 ') + && LoanWindow::explainRenewalFromParams(['max_extension_days' => (string) $expectedMaxDays, 'max_due_date' => $expectedMaxDue, 'blocked_from' => $conflictStart]) === $sentence + && LoanWindow::explainRenewalFromParams(['max_extension_days' => 'x', 'max_due_date' => $expectedMaxDue, 'blocked_from' => $conflictStart]) === null, + '3 the sentence names the days (plural, singular, none) and is rebuilt from validated redirect parameters only', + $sentence); + +// ═════════ 4-7: the admin renewal ════════════════════════════════════════════ +$params = $renew($ownLoan); +$check(($params['error'] ?? null) === 'extension_conflicts' + && (int) ($params['loan_id'] ?? 0) === $ownLoan + && (int) ($params['max_extension_days'] ?? -1) === $expectedMaxDays + && ($params['max_due_date'] ?? null) === $expectedMaxDue + && ($params['blocked_from'] ?? null) === $conflictStart, + '4 renew() across the scheduled loan: extension_conflicts with loan_id, max_extension_days, max_due_date, blocked_from', + json_encode($params)); +$before = $loanRow($ownLoan); +$check(($before['data_scadenza'] ?? null) === $dueDate && (int) ($before['renewals'] ?? -1) === 0, + '5 the refused renewal changed nothing'); + +$params = $renew($ownLoan, ['new_due_date' => $addDays($dueDate, -1)]); +$params2 = $renew($ownLoan, ['new_due_date' => $addDays($dueDate, $renewDays + 1)]); +$params3 = $renew($ownLoan, ['new_due_date' => 'not-a-date']); +$check(($params['error'] ?? null) === 'invalid_date_format' + && ($params2['error'] ?? null) === 'invalid_date_format' + && ($params3['error'] ?? null) === 'invalid_date_format', + '6 new_due_date on or before the current due date, past the standard renewal, or malformed is refused', + json_encode([$params, $params2, $params3])); + +$params = $renew($ownLoan, ['new_due_date' => $expectedMaxDue]); +$after = $loanRow($ownLoan); +$check(($params['renewed'] ?? null) === '1' + && ($after['data_scadenza'] ?? null) === $expectedMaxDue + && (int) ($after['renewals'] ?? 0) === 1, + "7 renew() with new_due_date {$expectedMaxDue} (the day before the scheduled loan) renews until that day", + json_encode([$params, $after])); +// Back to the original due date for the checks below. +$db->query("UPDATE prestiti SET data_scadenza = '{$dueDate}', renewals = 0 WHERE id = {$ownLoan}"); + +// ═════════ 8: a later loan on the loan's own copy (second copy free) ═════════ +[$twoBook, $twoCopies] = $mkBook(2, 2); +$twoLoan = $mkLoan($twoBook, $twoCopies[0], $borrower, 'in_corso', $addDays($today, -10), $dueDate); +$db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$twoCopies[0]}"); +$mkLoan($twoBook, $twoCopies[0], $otherUser, 'prenotato', $conflictStart, $addDays($conflictStart, 14)); +$copyFrom = (new CapacityService($db))->firstCopyCommitmentAfter($twoCopies[0], $twoLoan, $dueDate); +$params = $renew($twoLoan); +$check($copyFrom === $conflictStart + && ($params['error'] ?? null) === 'extension_conflicts' + && ($params['blocked_from'] ?? null) === $conflictStart + && ($params['max_due_date'] ?? null) === $expectedMaxDue, + '8 two copies, the loan\'s own copy needed by a later loan: the refusal names the day the copy is needed from', + json_encode(['copyFrom' => $copyFrom, 'params' => $params])); + +// ═════════ 9: the bulk extension ═════════════════════════════════════════════ +$asAdmin(); +$request = (new ServerRequestFactory())->createServerRequest('POST', '/admin/loans/bulk-extend') + ->withParsedBody(['ids' => [(string) $ownLoan], 'days' => (string) ($offset + 2)]); +$response = (new PrestitiController())->bulkExtend($request, new SlimResponse(), $db); +$params = $redirectParams($response); +$check(($params['error'] ?? null) === 'bulk_extend_conflict' + && (int) ($params['loan_id'] ?? 0) === $ownLoan + && (int) ($params['max_extension_days'] ?? -1) === $expectedMaxDays + && ($params['max_due_date'] ?? null) === $expectedMaxDue + && ($params['blocked_from'] ?? null) === $conflictStart, + '9 bulkExtend across the scheduled loan: bulk_extend_conflict names the loan and the longest extension still possible', + $response->getHeaderLine('Location')); +$check(($loanRow($ownLoan)['data_scadenza'] ?? null) === $dueDate, '10 the refused batch changed nothing'); + +// ═════════ 11-13: the other surfaces read the same figures (source contracts) +$indexSrc = (string) file_get_contents($root . '/app/Views/prestiti/index.php'); +$bookSrc = (string) file_get_contents($root . '/app/Views/libri/scheda_libro.php'); +$check(str_contains($indexSrc, 'LoanWindow::explainRenewalFromParams($_GET)') + && str_contains($indexSrc, 'name="new_due_date"') + && str_contains($bookSrc, 'LoanWindow::explainRenewalFromParams($_GET)') + && str_contains($bookSrc, 'name="new_due_date"'), + '11 the loans list and the book page explain the refusal and offer "renew until D" with new_due_date'); +$outcomeSrc = (string) file_get_contents($root . '/storage/plugins/mobile-api/src/Support/CirculationOutcome.php'); +$staffSrc = (string) file_get_contents($root . '/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php'); +$check(str_contains($outcomeSrc, 'LoanWindow::explainRenewalFromParams($details)') + && str_contains($outcomeSrc, "\$meta['max_due_date']") + && str_contains($staffSrc, "'new_due_date'") + && substr_count($staffSrc, 'self::windowDetails($outcome)') === 2, + '12 the Mobile API carries the figures in meta on renew and bulk extend, and accepts new_due_date'); +$ncipSrc = (string) file_get_contents($root . '/storage/plugins/ncip-server/NcipServerPlugin.php'); +$check(str_contains($ncipSrc, 'LoanWindow::renewalCap($currentDue, $blocked)') + && str_contains($ncipSrc, 'renewal possible at most until'), + '13 NCIP RenewItem names until when the renewal is still possible in its ProblemDetail'); + +$cleanup(); +$db->close(); + +echo PHP_EOL . "Passed: {$passed} Failed: {$failed}" . PHP_EOL; +exit($failed === 0 ? 0 : 1); From bc55e3e0e227b58bff0db75cec84ef75732b6e81 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:53:11 +0000 Subject: [PATCH 13/26] Keep the #336 claimed-window literals, derive the extension flag in the loop The #336 source pin expects the two claimed windows of PrestitiController::update() written exactly as the set difference. Restore those literals and compute whether a window is the extension (the only one whose figures are worth explaining) inside the capacity loop instead of as a third tuple element. --- app/Controllers/PrestitiController.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index fd6f78125..0c86ba3f5 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -1105,17 +1105,18 @@ public function update(Request $request, Response $response, mysqli $db, int $id // strict above); ±1 day via DateTimeImmutable, no TZ ambiguity. $dayBefore = static fn (string $ymd): string => (new \DateTimeImmutable($ymd))->modify('-1 day')->format('Y-m-d'); $dayAfter = static fn (string $ymd): string => (new \DateTimeImmutable($ymd))->modify('+1 day')->format('Y-m-d'); - // [window, isExtension]: only a later due date can be shortened - // meaningfully, and its figures count from the loan's own start. $claimedWindows = []; if ($newPrestito < $oldPrestito) { - $claimedWindows[] = [$newPrestito, min($dayBefore($oldPrestito), $newScadenza), false]; + $claimedWindows[] = [$newPrestito, min($dayBefore($oldPrestito), $newScadenza)]; } if ($newScadenza > $oldScadenza) { - $claimedWindows[] = [max($dayAfter($oldScadenza), $newPrestito), $newScadenza, true]; + $claimedWindows[] = [max($dayAfter($oldScadenza), $newPrestito), $newScadenza]; } $capacity = new \App\Services\CapacityService($db); - foreach ($claimedWindows as [$claimStart, $claimEnd, $isExtension]) { + foreach ($claimedWindows as [$claimStart, $claimEnd]) { + // Only a later due date can be shortened meaningfully, and its + // figures count from the loan's own start. + $isExtension = $claimEnd === $newScadenza && $claimStart > $oldScadenza; if (!$capacity->hasFreeCapacity($libroId, $claimStart, $claimEnd, excludePrestitoId: $id)) { $db->rollback(); // The loans list explains the refusal with the longest From 745f5398a8690dae1e1f6e2bcbb363566f0f592d Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:53:11 +0000 Subject: [PATCH 14/26] Keep the #336 claimed-window literals, derive the extension flag in the loop The #336 source pin expects the two claimed windows of PrestitiController::update() written exactly as the set difference. Restore those literals and compute whether a window is the extension (the only one whose figures are worth explaining) inside the capacity loop instead of as a third tuple element. --- app/Controllers/PrestitiController.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index 3b82be2b3..5c3ba4769 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -1105,17 +1105,18 @@ public function update(Request $request, Response $response, mysqli $db, int $id // strict above); ±1 day via DateTimeImmutable, no TZ ambiguity. $dayBefore = static fn (string $ymd): string => (new \DateTimeImmutable($ymd))->modify('-1 day')->format('Y-m-d'); $dayAfter = static fn (string $ymd): string => (new \DateTimeImmutable($ymd))->modify('+1 day')->format('Y-m-d'); - // [window, isExtension]: only a later due date can be shortened - // meaningfully, and its figures count from the loan's own start. $claimedWindows = []; if ($newPrestito < $oldPrestito) { - $claimedWindows[] = [$newPrestito, min($dayBefore($oldPrestito), $newScadenza), false]; + $claimedWindows[] = [$newPrestito, min($dayBefore($oldPrestito), $newScadenza)]; } if ($newScadenza > $oldScadenza) { - $claimedWindows[] = [max($dayAfter($oldScadenza), $newPrestito), $newScadenza, true]; + $claimedWindows[] = [max($dayAfter($oldScadenza), $newPrestito), $newScadenza]; } $capacity = new \App\Services\CapacityService($db); - foreach ($claimedWindows as [$claimStart, $claimEnd, $isExtension]) { + foreach ($claimedWindows as [$claimStart, $claimEnd]) { + // Only a later due date can be shortened meaningfully, and its + // figures count from the loan's own start. + $isExtension = $claimEnd === $newScadenza && $claimStart > $oldScadenza; if (!$capacity->hasFreeCapacity($libroId, $claimStart, $claimEnd, excludePrestitoId: $id)) { $db->rollback(); // The loans list explains the refusal with the longest From f157cca50b7644fb8baf4e3434cc40da902487b1 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 14:59:25 +0200 Subject: [PATCH 15/26] Honor the request duration maximum across web and Mobile API --- app/Controllers/FrontendController.php | 4 +- app/Controllers/ReservationsController.php | 17 ++--- app/Controllers/UserActionsController.php | 17 +---- app/Models/SettingsRepository.php | 13 ++++ .../src/Controllers/CatalogController.php | 2 +- tests/loan-coherence-audit.unit.php | 2 +- tests/loan-window-surfaces.unit.php | 73 ++++++++++++++++++- 7 files changed, 98 insertions(+), 30 deletions(-) diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index d1c6c19eb..b96c18bbd 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -911,8 +911,8 @@ static function () use ($db, $book_id): array { // default to max_loan_duration_days, so expose the same effective value // to the view instead of hardcoding one calendar month in JavaScript. $loanSettings = new \App\Models\SettingsRepository($db); - $maxRequestDays = max(1, (int) ($loanSettings->get('loans', 'max_loan_duration_days', '90') ?? 90)); - $defaultRequestLoanDays = min($loanSettings->loanDurationDays(), $maxRequestDays); + $maxRequestDays = $loanSettings->maxLoanDurationDays(); + $defaultRequestLoanDays = $loanSettings->requestLoanDurationDays(); // Check whether the BIBFRAME Linked Data plugin is active. // Done before template include so the view can use $bibframePluginActive. diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php index a694d1c06..eb30a05e3 100644 --- a/app/Controllers/ReservationsController.php +++ b/app/Controllers/ReservationsController.php @@ -297,18 +297,12 @@ public function createReservation($request, $response, $args) // Configurable duration cap (anti-DoS): applies both to an explicit // end_date and to the default end computed from loan_duration_days. - $maxDurationDays = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'max_loan_duration_days', '90') ?? 90); - if ($maxDurationDays < 1) { - $maxDurationDays = 90; - } + $settings = new \App\Models\SettingsRepository($this->db); + $maxDurationDays = $settings->maxLoanDurationDays(); // If no end date specified, set it using the configured loan duration (fallback: 30 days) if (!$endDate) { - $loanDays = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'loan_duration_days', '30') ?? 30); - if ($loanDays < 1) { - $loanDays = 30; - } - $loanDays = min($loanDays, $maxDurationDays); + $loanDays = $settings->requestLoanDurationDays(); $endDateTime = new DateTime($startDate); $endDateTime->modify("+{$loanDays} days"); $endDate = $endDateTime->format('Y-m-d'); @@ -703,10 +697,9 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int // loan and reservation forms, the app): the configured default, capped // by the maximum the request endpoints enforce, from one source. $settings = new \App\Models\SettingsRepository($this->db); - $maxDays = max(1, (int) ($settings->get('loans', 'max_loan_duration_days', '90') ?? 90)); return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId) + [ - 'loan_duration_days' => min($settings->loanDurationDays(), $maxDays), - 'max_loan_duration_days' => $maxDays, + 'loan_duration_days' => $settings->requestLoanDurationDays(), + 'max_loan_duration_days' => $settings->maxLoanDurationDays(), ]; } diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php index a284af3b9..ae6b51eae 100644 --- a/app/Controllers/UserActionsController.php +++ b/app/Controllers/UserActionsController.php @@ -561,8 +561,7 @@ public function changeReservationDate(Request $request, Response $response, mysq $uid = (int) $user['id']; $startDate = $date; - $loanDays = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'loan_duration_days', '30') ?? 30); - $loanDays = $loanDays > 0 ? $loanDays : 30; + $loanDays = (new \App\Models\SettingsRepository($db))->requestLoanDurationDays(); $endDate = (new \DateTimeImmutable($date))->modify("+{$loanDays} days")->format('Y-m-d'); $db->begin_transaction(); @@ -719,11 +718,7 @@ public function loan(Request $request, Response $response, mysqli $db): Response // library runs in a local zone, so a raw date('Y-m-d') near midnight // would date the request to the wrong day. $data_prestito = \App\Support\DateHelper::today(); - // Read configured loan duration; fallback to 30 days - $loanDays = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'loan_duration_days', '30') ?? 30); - if ($loanDays < 1) { - $loanDays = 30; - } + $loanDays = (new \App\Models\SettingsRepository($db))->requestLoanDurationDays(); $data_scadenza = date('Y-m-d', strtotime($data_prestito . " +{$loanDays} days")); // Optional return date (Mobile API 1.8.2): the app proposes the day // before a scheduled loan or reservation when the default would cross @@ -979,8 +974,7 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo // Calculate date range for availability check $start = ($desired !== '') ? $desired : $today; - $loanDays = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'loan_duration_days', '30') ?? 30); - $loanDays = $loanDays > 0 ? $loanDays : 30; + $loanDays = (new \App\Models\SettingsRepository($db))->requestLoanDurationDays(); $end = (new \DateTimeImmutable($start))->modify("+{$loanDays} days")->format('Y-m-d'); // Optional return date (Mobile API 1.8.2), bounded as in loan(). $endOverride = $this->requestedEndDate($db, $data, $start); @@ -1137,10 +1131,7 @@ private function requestedEndDate(mysqli $db, array $data, string $startDate): s if (!\App\Support\DateHelper::isISODateFormat($end) || $end < $startDate) { return false; } - $maxDays = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'max_loan_duration_days', '90') ?? 90); - if ($maxDays < 1) { - $maxDays = 90; - } + $maxDays = (new \App\Models\SettingsRepository($db))->maxLoanDurationDays(); $days = (int) (new \DateTimeImmutable($startDate))->diff(new \DateTimeImmutable($end))->days; return $days > $maxDays ? 'max_duration_exceeded' : $end; } diff --git a/app/Models/SettingsRepository.php b/app/Models/SettingsRepository.php index ad3e65471..20764a0a1 100644 --- a/app/Models/SettingsRepository.php +++ b/app/Models/SettingsRepository.php @@ -117,6 +117,19 @@ public function loanDurationDays(): int return $days >= 1 ? $days : 30; } + /** Maximum duration of a reader's request, including an explicit end date. */ + public function maxLoanDurationDays(): int + { + $days = (int) ($this->get('loans', 'max_loan_duration_days', '90') ?? 90); + return $days >= 1 ? $days : 90; + } + + /** Default for reader requests and their calendars; staff loans retain their own duration. */ + public function requestLoanDurationDays(): int + { + return min($this->loanDurationDays(), $this->maxLoanDurationDays()); + } + /** * @return array */ diff --git a/storage/plugins/mobile-api/src/Controllers/CatalogController.php b/storage/plugins/mobile-api/src/Controllers/CatalogController.php index 2e934f786..bf4fab312 100644 --- a/storage/plugins/mobile-api/src/Controllers/CatalogController.php +++ b/storage/plugins/mobile-api/src/Controllers/CatalogController.php @@ -834,7 +834,7 @@ public function bookAvailability( // the app can show it before the reader sends the request. $data = self::mapAvailability($avail) + [ - 'loan_duration_days' => (new \App\Models\SettingsRepository($this->db))->loanDurationDays(), + 'loan_duration_days' => (int) $avail['loan_duration_days'], // Upper bound POST /reservations accepts for end_date (Mobile API 1.8.2). 'max_loan_duration_days' => (int) ($avail['max_loan_duration_days'] ?? 90), ]; diff --git a/tests/loan-coherence-audit.unit.php b/tests/loan-coherence-audit.unit.php index ed1a2651e..18a899e57 100644 --- a/tests/loan-coherence-audit.unit.php +++ b/tests/loan-coherence-audit.unit.php @@ -298,7 +298,7 @@ && str_contains($loanCreateView, 'loanWindowFrom(startIso, effectiveLoanDays)') && str_contains($loanCreateView, 'addDaysToIso(startIso, defaultDays)') && !str_contains($loanCreateView, 'setMonth(endDate.getMonth() + 1)') - && str_contains($frontend, '$defaultRequestLoanDays = min(') + && str_contains($frontend, '$defaultRequestLoanDays = $loanSettings->requestLoanDurationDays()') && str_contains($bookDetail, 'loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays)') && str_contains($bookDetail, 'addDaysToIso(startIso, defaultDays)') && !str_contains($bookDetail, 'setMonth(endDate.getMonth() + 1)'); diff --git a/tests/loan-window-surfaces.unit.php b/tests/loan-window-surfaces.unit.php index 9cce63d58..5a2a6e36b 100644 --- a/tests/loan-window-surfaces.unit.php +++ b/tests/loan-window-surfaces.unit.php @@ -29,6 +29,7 @@ use App\Controllers\UserActionsController; use App\Models\SettingsRepository; use App\Plugins\MobileApi\Controllers\ActionsController as MobileActionsController; +use App\Plugins\MobileApi\Controllers\CatalogController as MobileCatalogController; use App\Plugins\MobileApi\Support\AppAuthMiddleware; use App\Services\CapacityService; use App\Support\DateHelper; @@ -43,6 +44,7 @@ require_once $root . '/storage/plugins/mobile-api/src/Support/ResponseEnvelope.php'; require_once $root . '/storage/plugins/mobile-api/src/Support/Input.php'; require_once $root . '/storage/plugins/mobile-api/src/Controllers/ActionsController.php'; +require_once $root . '/storage/plugins/mobile-api/src/Controllers/CatalogController.php'; mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); @@ -79,7 +81,16 @@ $bookIds = []; /** @var int[] $userIds */ $userIds = []; -$cleanup = static function () use ($db, &$bookIds, &$userIds): void { +$savedLoanSettings = []; +$cleanup = static function () use ($db, &$bookIds, &$userIds, &$savedLoanSettings): void { + $settings = new SettingsRepository($db); + foreach ($savedLoanSettings as $key => $value) { + if ($value === null) { + $settings->delete('loans', $key); + } else { + $settings->set('loans', $key, $value); + } + } foreach ($bookIds as $id) { $db->query("DELETE FROM prestiti WHERE libro_id = {$id}"); $db->query("DELETE FROM prenotazioni WHERE libro_id = {$id}"); @@ -391,6 +402,66 @@ '17 Mobile request on that title: 409 not_available with no figures in meta', json_encode($body)); +// A lower configured maximum must govern every default, including Mobile API +// requests without end_date. A commitment at +10 must not reject a valid 7-day +// request just because the configured standard duration is 30 days. +foreach (['loan_duration_days', 'max_loan_duration_days'] as $key) { + $savedLoanSettings[$key] = $settings->get('loans', $key); +} +$settings->set('loans', 'loan_duration_days', '30'); +$settings->set('loans', 'max_loan_duration_days', '7'); +[$boundedBook, $boundedCopies] = $mkBook(1, 3); +$mkLoan($boundedBook, $boundedCopies[0], $otherUser, 'prenotato', 1, $addDays($today, 10), $addDays($today, 25)); +$expectedDue = $addDays($today, 7); +$createdDue = static function () use ($db, $boundedBook, $requester): ?string { + $row = $db->query("SELECT data_scadenza AS due FROM prestiti WHERE libro_id = {$boundedBook} AND utente_id = {$requester} + UNION ALL SELECT data_fine_richiesta AS due FROM prenotazioni WHERE libro_id = {$boundedBook} AND utente_id = {$requester}")->fetch_assoc(); + return $row['due'] ?? null; +}; +$availability = (new ReservationsController($db))->getBookAvailabilityData($boundedBook, null, 40, $requester); +$request = (new ServerRequestFactory())->createServerRequest('GET', "/api/v1/catalog/books/{$boundedBook}/availability") + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]); +$response = (new MobileCatalogController($db))->bookAvailability($request, new SlimResponse(), $boundedBook); +$mobileAvailability = $jsonBody($response)['data'] ?? []; +$check(($availability['loan_duration_days'] ?? null) === 7 + && ($mobileAvailability['loan_duration_days'] ?? null) === 7 + && ($mobileAvailability['max_loan_duration_days'] ?? null) === 7, + '18 web and Mobile availability advertise the same bounded 7-day default', json_encode($mobileAvailability)); + +foreach (['loan', 'reserve'] as $action) { + $asRequester(); + $response = (new UserActionsController())->$action($post("/user/{$action}", ['libro_id' => $boundedBook]), new SlimResponse(), $db); + $check(!isset($redirectParams($response)["{$action}_error"]) && $createdDue() === $expectedDue, + "19 {$action} without end_date persists the configured maximum instead of the longer default", $response->getHeaderLine('Location')); + $resetPatron($boundedBook, $requester, $boundedCopies); +} +$asRequester(); +$request = $post("/api/libro/{$boundedBook}/reservation", ['start_date' => $today]); +$response = (new ReservationsController($db))->createReservation($request, new SlimResponse(), ['id' => (string) $boundedBook]); +$check(($jsonBody($response)['success'] ?? false) && $createdDue() === $expectedDue, + '20 public calendar requests use the same bounded default'); +$resetPatron($boundedBook, $requester, $boundedCopies); + +foreach ([$today, $addDays($today, 1)] as $start) { + $asRequester(); + $request = $post('/api/v1/reservations', ['book_id' => $boundedBook, 'desired_date' => $start]) + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]); + $response = (new MobileActionsController($db))->requestReservation($request, new SlimResponse()); + $check($response->getStatusCode() === 201 && $createdDue() === $addDays($start, 7), + "21 Mobile request from {$start} without end_date succeeds with the bounded default", (string) $response->getBody()); + $resetPatron($boundedBook, $requester, $boundedCopies); +} +$asRequester(); +$request = $post('/api/v1/reservations', ['book_id' => $boundedBook, 'desired_date' => $today, 'end_date' => $addDays($today, 9)]) + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]); +$response = (new MobileActionsController($db))->requestReservation($request, new SlimResponse()); +$check($response->getStatusCode() === 422 && ($jsonBody($response)['error']['code'] ?? '') === 'max_duration_exceeded' && $createdDue() === null, + '22 an explicit return past the same maximum is still refused without writing a request'); +$settings->set('loans', 'loan_duration_days', '0'); +$settings->set('loans', 'max_loan_duration_days', '0'); +$check($settings->maxLoanDurationDays() === 90 && $settings->requestLoanDurationDays() === 30, + '23 invalid durations retain the existing 30-day default and 90-day maximum fallbacks'); + $cleanup(); $db->close(); From 3ec158203ade6107f35e8bd798b005215700338e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 15:05:23 +0200 Subject: [PATCH 16/26] Propose renewal dates that satisfy both capacity and copy commitments --- app/Controllers/PrestitiController.php | 32 ++++----- app/Services/CapacityService.php | 22 +++++++ .../StaffCirculationController.php | 6 +- .../src/Support/CirculationOutcome.php | 1 + .../plugins/ncip-server/NcipServerPlugin.php | 8 +-- tests/loan-extension-281.unit.php | 3 +- tests/loan-renewal-window.unit.php | 66 +++++++++++++++++++ tests/mobile-api-circulation.unit.php | 1 + 8 files changed, 117 insertions(+), 22 deletions(-) diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index 0c86ba3f5..b4b7a1cfc 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -2097,20 +2097,20 @@ private function applyBulkLoanExtension( // extension window: two different intervals for one decision (CodeRabbit). $extensionStart = $dueDate->modify('+1 day')->format('Y-m-d'); $currentDue = $dueDate->format('Y-m-d'); + $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; if (!$capacity->hasFreeCapacity($bookId, $extensionStart, $newDueDate, excludePrestitoId: $loanId)) { - $blocked = $capacity->unavailableDatesInRange($bookId, $extensionStart, $newDueDate, null, $loanId); + $blocked = $capacity->renewalConflictDates($bookId, $loanId, $copyId, $currentDue, $newDueDate); $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked); return null; } - $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; if ($copyId !== null) { $copyOverlap->bind_param('iisss', $copyId, $loanId, $newDueDate, $today, $extensionStart); $copyOverlap->execute(); if ((bool) $copyOverlap->get_result()->fetch_row()) { // The loan's own copy is needed by a later loan: name that day. - $copyFrom = $capacity->firstCopyCommitmentAfter($copyId, $loanId, $currentDue); - $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $copyFrom !== null ? [$copyFrom] : []); + $blocked = $capacity->renewalConflictDates($bookId, $loanId, $copyId, $currentDue, $newDueDate); + $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked); return null; } } @@ -2226,15 +2226,15 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // reservation, as the refusal below proposes ("Rinnova fino al D"). // A real day, after the current due date, never past the standard // renewal. - $requestedDue = trim((string) ($data['new_due_date'] ?? '')); - if ($requestedDue !== '') { - if (!\App\Support\DateHelper::isISODateFormat($requestedDue) + $rawDue = $data['new_due_date'] ?? ''; + $requestedDue = is_string($rawDue) ? trim($rawDue) : ''; + if (!is_string($rawDue) || ($requestedDue !== '' && ( + !\App\Support\DateHelper::isISODateFormat($requestedDue) || $requestedDue <= (string) $currentDueDate - || $requestedDue > $proposedNewDueDate) { - $errorUrl = $redirectTo ?? url('/admin/loans'); - $separator = strpos($errorUrl, '?') === false ? '?' : '&'; - return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_date_format'))->withStatus(302); - } + || $requestedDue > $proposedNewDueDate))) { + $errorUrl = $redirectTo ?? url('/admin/loans'); + $separator = strpos($errorUrl, '?') === false ? '?' : '&'; + return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_date_format'))->withStatus(302); } $libroId = (int) $loan['libro_id']; @@ -2332,6 +2332,7 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // that day and bounced renewals over commitments that only touch it. $extensionStart = date('Y-m-d', strtotime($currentDueDate . ' +1 day')); $extensionEnd = $proposedNewDueDate; + $copiaId = $lockedLoan['copia_id'] !== null ? (int) $lockedLoan['copia_id'] : null; $capacity = new \App\Services\CapacityService($db); // A refusal names the longest renewal still possible (LoanWindow), @@ -2341,8 +2342,8 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) + \App\Support\LoanWindow::renewalRedirectParams((string) $currentDueDate, $blocked)); }; if (!$capacity->hasFreeCapacity($libroId, $extensionStart, $extensionEnd, excludePrestitoId: $id)) { + $blocked = $capacity->renewalConflictDates($libroId, $id, $copiaId, (string) $currentDueDate, $extensionEnd); $db->rollback(); - $blocked = $capacity->unavailableDatesInRange($libroId, $extensionStart, $extensionEnd, null, $id); $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; return $response->withHeader('Location', url($errorUrl . $separator . $explainRefusal($blocked)))->withStatus(302); @@ -2355,7 +2356,6 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // escludendo il prestito corrente: senza questo check l'UPDATE farebbe // scattare il SIGNAL del trigger → catch-all → errore generico invece // del conflitto di estensione dedicato. - $copiaId = $lockedLoan['copia_id'] !== null ? (int) $lockedLoan['copia_id'] : null; if ($copiaId !== null) { $copyOvlStmt = $db->prepare(" SELECT 1 FROM prestiti @@ -2373,12 +2373,12 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $hasCopyOverlap = (bool) $copyOvlStmt->get_result()->fetch_row(); $copyOvlStmt->close(); if ($hasCopyOverlap) { + $blocked = $capacity->renewalConflictDates($libroId, $id, $copiaId, (string) $currentDueDate, $extensionEnd); $db->rollback(); // The loan's own copy is needed by a later loan: name that day. - $copyFrom = $capacity->firstCopyCommitmentAfter($copiaId, $id, (string) $currentDueDate); $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; - return $response->withHeader('Location', url($errorUrl . $separator . $explainRefusal($copyFrom !== null ? [$copyFrom] : [])))->withStatus(302); + return $response->withHeader('Location', url($errorUrl . $separator . $explainRefusal($blocked)))->withStatus(302); } } diff --git a/app/Services/CapacityService.php b/app/Services/CapacityService.php index 7f36ccdb5..3c4e98ce5 100644 --- a/app/Services/CapacityService.php +++ b/app/Services/CapacityService.php @@ -289,6 +289,28 @@ public function firstCopyCommitmentAfter(int $copiaId, int $excludePrestitoId, s return is_string($first) && $first !== '' ? substr($first, 0, 10) : null; } + /** + * Both limits on a renewal: book capacity and commitments on its assigned + * copy. A capacity refusal can occur later than the copy's own conflict; + * the proposed shorter renewal must respect the earlier of the two. + * + * @return list Conflicting days inside (currentDue, proposedDue]. + */ + public function renewalConflictDates(int $libroId, int $loanId, ?int $copiaId, string $currentDue, string $proposedDue): array + { + $extensionStart = (new \DateTimeImmutable($currentDue))->modify('+1 day')->format('Y-m-d'); + $blocked = $this->unavailableDatesInRange($libroId, $extensionStart, $proposedDue, null, $loanId); + if ($copiaId !== null) { + $copyFrom = $this->firstCopyCommitmentAfter($copiaId, $loanId, $currentDue); + if ($copyFrom !== null && $copyFrom <= $proposedDue) { + $blocked[] = $copyFrom; + } + } + $blocked = array_values(array_unique($blocked)); + sort($blocked, SORT_STRING); + return $blocked; + } + /** * @return list Y-m-d days from $start to $end inclusive. */ diff --git a/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php b/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php index 34153c2f5..64e916b5b 100644 --- a/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php +++ b/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php @@ -413,7 +413,11 @@ public function renew(ServerRequestInterface $request, ResponseInterface $respon // core bounds it (after the current due date, within the standard // renewal). $json = JsonBody::parse($request); - $newDue = isset($json['new_due_date']) ? trim((string) $json['new_due_date']) : ''; + $rawDue = $json['new_due_date'] ?? ''; + if (!is_string($rawDue)) { + return $this->invalid($response, 'new_due_date', __('Data richiesta non valida.')); + } + $newDue = trim($rawDue); if ($newDue !== '' && !\App\Support\DateHelper::isISODateFormat($newDue)) { return $this->invalid($response, 'new_due_date', __('Data richiesta non valida.')); } diff --git a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php index 74a0df5f2..bd036dcbb 100644 --- a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php +++ b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php @@ -61,6 +61,7 @@ final class CirculationOutcome /** ⑤ renew (PrestitiController::renew). */ public const RENEW = [ + 'invalid_date_format' => [422, 'validation_error', 'new_due_date', false], 'loan_not_found' => [404, 'not_found', null, false], 'loan_not_active' => [409, 'loan_not_active', null, true], 'loan_not_picked_up' => [409, 'loan_not_active', null, true], diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index bf2c3f12a..054a4a44c 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -2782,18 +2782,18 @@ private function extendLoan( // [currentDue, newDue] un RenewItem falliva dove il rinnovo web // identico riusciva (giorno di confine contato due volte). $extensionStart = (new \DateTimeImmutable($currentDue))->modify('+1 day')->format('Y-m-d'); + $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; $capacity = new \App\Services\CapacityService($this->db); if (!$capacity->hasFreeCapacity($expectedBookId, $extensionStart, $newDueDate, excludePrestitoId: $loanId)) { + $blocked = $capacity->renewalConflictDates($expectedBookId, $loanId, $copyId, $currentDue, $newDueDate); $this->db->rollback(); $failureReason = 'no_capacity'; // Same figures the web renew() redirect carries (LoanWindow). - $blocked = $capacity->unavailableDatesInRange($expectedBookId, $extensionStart, $newDueDate, null, $loanId); $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked); return null; } - $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; if ($copyId !== null) { $applicationToday = \App\Support\DateHelper::today(); $overlap = $this->db->prepare( @@ -2812,11 +2812,11 @@ private function extendLoan( $hasOverlap = (bool) $overlap->get_result()->fetch_row(); $overlap->close(); if ($hasOverlap) { + $blocked = $capacity->renewalConflictDates($expectedBookId, $loanId, $copyId, $currentDue, $newDueDate); $this->db->rollback(); $failureReason = 'overlap'; // The loan's own copy is needed by a later loan: name that day. - $copyFrom = $capacity->firstCopyCommitmentAfter($copyId, $loanId, $currentDue); - $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $copyFrom !== null ? [$copyFrom] : []); + $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked); return null; } } diff --git a/tests/loan-extension-281.unit.php b/tests/loan-extension-281.unit.php index a9ed724dd..e37d59c8c 100644 --- a/tests/loan-extension-281.unit.php +++ b/tests/loan-extension-281.unit.php @@ -217,7 +217,8 @@ $check($bookLockPos !== false && $loanLockPos !== false && $bookLockPos < $loanLockPos, 'bulk path locks books before loans in deterministic order'); $check(str_contains($bulkSource, 'hasFreeCapacity(') && str_contains($bulkSource, 'excludePrestitoId: $loanId'), 'every proposed window passes through canonical CapacityService'); $check(str_contains($bulkSource, 'copia_id = ? AND id <> ?'), 'bulk path rejects overlap on the assigned physical copy'); -$check(str_contains($bulkSource, '?error=bulk_extend_conflict') && str_contains($bulkSource, '$db->rollback();'), 'capacity/copy conflict has a dedicated all-or-nothing response'); +$check(str_contains($bulkSource, "http_build_query(['error' => 'bulk_extend_conflict', 'loan_id' =>") + && str_contains($bulkSource, '$db->rollback();'), 'capacity/copy conflict has a dedicated all-or-nothing response with the affected loan'); $check(!str_contains($bulkSource, 'DATE_ADD(data_scadenza'), 'unsafe set-based extension is absent'); $cleanup(); diff --git a/tests/loan-renewal-window.unit.php b/tests/loan-renewal-window.unit.php index a46acb868..70103288b 100644 --- a/tests/loan-renewal-window.unit.php +++ b/tests/loan-renewal-window.unit.php @@ -24,14 +24,22 @@ use App\Controllers\PrestitiController; use App\Models\SettingsRepository; +use App\Plugins\MobileApi\Controllers\StaffCirculationController; +use App\Plugins\NcipServer\NcipServerPlugin; use App\Services\CapacityService; use App\Support\DateHelper; +use App\Support\HookManager; use App\Support\LoanWindow; use Slim\Psr7\Factory\ServerRequestFactory; use Slim\Psr7\Response as SlimResponse; $root = dirname(__DIR__); require $root . '/vendor/autoload.php'; +foreach (['ResponseEnvelope', 'CirculationOutcome', 'StaffLoanReader', 'StaffPaging', 'JsonBody', 'Delegation'] as $support) { + require_once $root . '/storage/plugins/mobile-api/src/Support/' . $support . '.php'; +} +require_once $root . '/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php'; +require_once $root . '/storage/plugins/ncip-server/NcipServerPlugin.php'; mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); @@ -275,6 +283,64 @@ && str_contains($ncipSrc, 'renewal possible at most until'), '13 NCIP RenewItem names until when the renewal is still possible in its ProblemDetail'); +// Exercise the API boundary, not only the presence of source strings: a +// syntactically valid but out-of-range date used to become an unmapped 500. +$staff = new StaffCirculationController($db); +$staffRenew = static function (int $id, array $body = []) use ($staff, $asAdmin): array { + $asAdmin(); + $request = (new ServerRequestFactory())->createServerRequest('POST', "/api/v1/staff/loans/{$id}/renew") + ->withParsedBody($body); + $response = $staff->renew($request, new SlimResponse(), $id); + return [$response->getStatusCode(), json_decode((string) $response->getBody(), true)]; +}; +foreach ([$dueDate, $addDays($dueDate, -1), $addDays($dueDate, $renewDays + 1), 'bad-date', ['date' => $dueDate]] as $invalidDue) { + [$status, $body] = $staffRenew($ownLoan, ['new_due_date' => $invalidDue]); + $check($status === 422 && ($body['error']['code'] ?? '') === 'validation_error' + && ($body['meta']['field'] ?? '') === 'new_due_date' + && $loanRow($ownLoan) === $before, + '14 invalid renewal dates return 422 for new_due_date without changing the loan', json_encode([$status, $body])); +} + +// The assigned copy becomes busy BEFORE the whole book is full. A suggested +// renewal that only considered the later book-wide conflict would fail again +// when submitted. All surfaces must propose the earlier, actually usable day. +$laterConflict = $addDays($conflictStart, 3); +$mkLoan($twoBook, $twoCopies[1], $admin, 'prenotato', $laterConflict, $addDays($laterConflict, 14)); +$capacity = new CapacityService($db); +$blocked = $capacity->renewalConflictDates($twoBook, $twoLoan, $twoCopies[0], $dueDate, $addDays($dueDate, $renewDays)); +$check(min($blocked) === $conflictStart && in_array($laterConflict, $blocked, true) + && $capacity->renewalConflictDates($twoBook, $twoLoan, $twoCopies[0], $dueDate, $addDays($dueDate, 1)) === [], + '15 renewal conflict dates combine the copy and book limits inside the proposed extension'); +$params = $renew($twoLoan); +$check(($params['blocked_from'] ?? '') === $conflictStart && ($params['max_due_date'] ?? '') === $expectedMaxDue, + '16 web renewal proposes the earlier copy limit when both gates conflict', json_encode($params)); +[$status, $body] = $staffRenew($twoLoan); +$check($status === 409 && ($body['meta']['max_due_date'] ?? '') === $expectedMaxDue + && ($body['meta']['first_conflict_date'] ?? '') === $conflictStart, + '17 Mobile renewal names the same earlier copy limit', json_encode([$status, $body])); +$asAdmin(); +$request = (new ServerRequestFactory())->createServerRequest('POST', '/api/v1/staff/loans/bulk-extend') + ->withParsedBody(['ids' => [$twoLoan], 'days' => $renewDays]); +$response = $staff->bulkExtend($request, new SlimResponse()); +$body = json_decode((string) $response->getBody(), true); +$check($response->getStatusCode() === 409 && ($body['meta']['max_due_date'] ?? '') === $expectedMaxDue + && ($body['meta']['loan_id'] ?? 0) === $twoLoan, + '18 Mobile bulk extension proposes the earlier copy limit and names the loan', json_encode($body)); + +$db->query("UPDATE prestiti SET origine = 'ncip' WHERE id = {$twoLoan}"); +$ncip = new NcipServerPlugin($db, new HookManager($db)); +$extend = new ReflectionMethod(NcipServerPlugin::class, 'extendLoan'); +$failureReason = null; +$ncipResult = $extend->invokeArgs($ncip, [$twoLoan, $twoBook, $borrower, &$failureReason]); +$ncipCap = (new ReflectionProperty(NcipServerPlugin::class, 'renewalCap'))->getValue($ncip); +$check($ncipResult === null && $failureReason === 'no_capacity' && ($ncipCap['max_due_date'] ?? '') === $expectedMaxDue + && ($ncipCap['first_conflict_date'] ?? '') === $conflictStart, + '19 NCIP computes the same earlier copy limit', json_encode([$failureReason, $ncipCap])); +$db->query("UPDATE prestiti SET origine = 'diretto' WHERE id = {$twoLoan}"); +[$status, $body] = $staffRenew($twoLoan, ['new_due_date' => $expectedMaxDue]); +$check($status === 200 && ($loanRow($twoLoan)['data_scadenza'] ?? '') === $expectedMaxDue, + '20 the proposed shorter renewal succeeds on the next request', json_encode([$status, $body])); + $cleanup(); $db->close(); diff --git a/tests/mobile-api-circulation.unit.php b/tests/mobile-api-circulation.unit.php index 879ed5cf7..35e020d83 100644 --- a/tests/mobile-api-circulation.unit.php +++ b/tests/mobile-api-circulation.unit.php @@ -109,6 +109,7 @@ ['RENEW', 'loan_not_picked_up', 409, 'loan_not_active', null, 'loan_not_picked_up'], ['RENEW', 'loan_overdue', 409, 'loan_overdue', null, null], ['RENEW', 'max_renewals', 409, 'max_renewals', null, null], + ['RENEW', 'invalid_date_format', 422, 'validation_error', 'new_due_date', null], ['RENEW', 'extension_conflicts', 409, 'not_available', null, 'extension_conflicts'], ['BULK', 'bulk_extend_conflict', 409, 'not_available', null, 'bulk_extend_conflict'], ['BULK', 'bulk_extend_invalid', 422, 'validation_error', 'ids', 'bulk_extend_invalid'], From cd976b3cdf1dbdc6ff97cb35fc6df93bf6f3513f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 15:16:10 +0200 Subject: [PATCH 17/26] Address renewal review findings for dates and overdue extensions --- app/Controllers/PrestitiController.php | 16 +++--- app/Support/LoanWindow.php | 17 ++++-- app/Views/libri/scheda_libro.php | 8 ++- app/Views/prestiti/index.php | 5 +- locale/da_DK.json | 3 +- locale/de_DE.json | 3 +- locale/en_US.json | 3 +- locale/fr_FR.json | 3 +- locale/it_IT.json | 3 +- .../src/Support/CirculationOutcome.php | 2 + .../plugins/ncip-server/NcipServerPlugin.php | 7 ++- tests/loan-renewal-window.unit.php | 53 +++++++++++++++++-- tests/mobile-api-circulation.unit.php | 1 + 13 files changed, 100 insertions(+), 24 deletions(-) diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index b4b7a1cfc..61edb4414 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -2100,7 +2100,7 @@ private function applyBulkLoanExtension( $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; if (!$capacity->hasFreeCapacity($bookId, $extensionStart, $newDueDate, excludePrestitoId: $loanId)) { $blocked = $capacity->renewalConflictDates($bookId, $loanId, $copyId, $currentDue, $newDueDate); - $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked); + $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked, $base->format('Y-m-d')); return null; } @@ -2110,7 +2110,7 @@ private function applyBulkLoanExtension( if ((bool) $copyOverlap->get_result()->fetch_row()) { // The loan's own copy is needed by a later loan: name that day. $blocked = $capacity->renewalConflictDates($bookId, $loanId, $copyId, $currentDue, $newDueDate); - $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked); + $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked, $base->format('Y-m-d')); return null; } } @@ -2228,14 +2228,16 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // renewal. $rawDue = $data['new_due_date'] ?? ''; $requestedDue = is_string($rawDue) ? trim($rawDue) : ''; - if (!is_string($rawDue) || ($requestedDue !== '' && ( - !\App\Support\DateHelper::isISODateFormat($requestedDue) - || $requestedDue <= (string) $currentDueDate - || $requestedDue > $proposedNewDueDate))) { + if (!is_string($rawDue) || ($requestedDue !== '' && !\App\Support\DateHelper::isISODateFormat($requestedDue))) { $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_date_format'))->withStatus(302); } + if ($requestedDue !== '' && ($requestedDue <= (string) $currentDueDate || $requestedDue > $proposedNewDueDate)) { + $errorUrl = $redirectTo ?? url('/admin/loans'); + $separator = strpos($errorUrl, '?') === false ? '?' : '&'; + return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_renewal_date'))->withStatus(302); + } $libroId = (int) $loan['libro_id']; // Start transaction BEFORE conflict check to prevent race conditions @@ -2313,7 +2315,7 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $db->rollback(); $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; - return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_date_format'))->withStatus(302); + return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_renewal_date'))->withStatus(302); } $proposedNewDueDate = $requestedDue; } diff --git a/app/Support/LoanWindow.php b/app/Support/LoanWindow.php index e89221c93..33b55a942 100644 --- a/app/Support/LoanWindow.php +++ b/app/Support/LoanWindow.php @@ -131,10 +131,12 @@ public static function explainFromParams(array $params): ?string * @param string[] $conflictDates Y-m-d days inside (currentDue, proposedDue] * without a free copy, or the first day of a * later commitment on the loan's own copy. + * @param string|null $extensionBase Bulk extensions of overdue loans count + * their days from today, not the past due date. * @return array{first_conflict_date: string, max_due_date: string, max_extension_days: int}|null * null when nothing bounds the extension. */ - public static function renewalCap(string $currentDue, array $conflictDates): ?array + public static function renewalCap(string $currentDue, array $conflictDates, ?string $extensionBase = null): ?array { $future = array_filter($conflictDates, static fn (string $d): bool => $d > $currentDue); if ($future === []) { @@ -142,7 +144,11 @@ public static function renewalCap(string $currentDue, array $conflictDates): ?ar } $firstConflict = min($future); $maxDue = (new DateTime($firstConflict))->modify('-1 day')->format('Y-m-d'); - $maxDays = (int) (new DateTime($currentDue))->diff(new DateTime($maxDue))->days; + $base = max($currentDue, $extensionBase ?? $currentDue); + // An existing conflict before today's bulk-extension base leaves no + // extension possible; never propose a due date in the past. + $maxDue = max($base, $maxDue); + $maxDays = (int) (new DateTime($base))->diff(new DateTime($maxDue))->days; return [ 'first_conflict_date' => $firstConflict, @@ -179,9 +185,9 @@ public static function explainRenewal(int $maxDays, string $maxDueDate, string $ * @param string[] $conflictDates * @return array{max_extension_days?: int, max_due_date?: string, blocked_from?: string} */ - public static function renewalRedirectParams(string $currentDue, array $conflictDates): array + public static function renewalRedirectParams(string $currentDue, array $conflictDates, ?string $extensionBase = null): array { - $cap = self::renewalCap($currentDue, $conflictDates); + $cap = self::renewalCap($currentDue, $conflictDates, $extensionBase); if ($cap === null) { return []; } @@ -214,7 +220,8 @@ public static function explainRenewalFromParams(array $params): ?string return self::explainRenewal((int) $days, $due, $from); } - private static function displayDate(string $ymd): string + /** Format dates consistently in the explanation and its renewal action. */ + public static function displayDate(string $ymd): string { $format = str_starts_with(I18n::getLocale(), 'en') ? 'm-d-Y' : 'd-m-Y'; return (new DateTime($ymd))->format($format); diff --git a/app/Views/libri/scheda_libro.php b/app/Views/libri/scheda_libro.php index d7fec5fdc..4ec0d721c 100644 --- a/app/Views/libri/scheda_libro.php +++ b/app/Views/libri/scheda_libro.php @@ -68,6 +68,12 @@ case 'max_renewals': echo __('Numero massimo di rinnovi raggiunto per questo prestito.'); break; + case 'invalid_date_format': + echo __('Errore: formato data non valido. Inserisci le date nel formato YYYY-MM-DD.'); + break; + case 'invalid_renewal_date': + echo __('La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.'); + break; case 'renewal_conflict': case 'extension_conflicts': // #336: renew() emette 'extension_conflicts' — il vecchio switch conosceva @@ -90,7 +96,7 @@ - + " class="inline-block ml-2"> - + [422, 'validation_error', 'new_due_date', false], + 'invalid_renewal_date' => [422, 'validation_error', 'new_due_date', false], 'loan_not_found' => [404, 'not_found', null, false], 'loan_not_active' => [409, 'loan_not_active', null, true], 'loan_not_picked_up' => [409, 'loan_not_active', null, true], @@ -232,6 +233,7 @@ public static function message(string $coreCode): string return match ($coreCode) { 'invalid_date_format', 'invalid_date' => __('Formato data non valido. Usa AAAA-MM-GG.'), + 'invalid_renewal_date' => __('La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.'), 'invalid_dates' => __('La data di scadenza deve essere successiva alla data di inizio.'), 'missing_fields', 'missing_data' => __('Utente e libro sono obbligatori.'), 'expired_window' => __('Il periodo indicato è già terminato.'), diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index 054a4a44c..8caf1842d 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -1105,10 +1105,13 @@ private function handleRenewItem( $detail = 'Failed to extend loan'; $cap = $this->renewalCap; if ($cap !== null) { + // The limiting commitment may concern only the assigned copy, + // even when the book also reaches full capacity on a later day. + $conflictDetail = 'a conflicting loan or reservation blocks the extension from ' . $cap['first_conflict_date']; $detail .= $cap['max_extension_days'] < 1 - ? ': no renewal is possible, every copy is booked from ' . $cap['first_conflict_date'] + ? ': no renewal is possible; ' . $conflictDetail : ': renewal possible at most until ' . $cap['max_due_date'] . ' (' . $cap['max_extension_days'] - . ' day' . ($cap['max_extension_days'] === 1 ? '' : 's') . '), every copy is booked from ' . $cap['first_conflict_date']; + . ' day' . ($cap['max_extension_days'] === 1 ? '' : 's') . '); ' . $conflictDetail; } return $this->xmlResponse( $response, diff --git a/tests/loan-renewal-window.unit.php b/tests/loan-renewal-window.unit.php index 70103288b..4b6850d57 100644 --- a/tests/loan-renewal-window.unit.php +++ b/tests/loan-renewal-window.unit.php @@ -29,6 +29,7 @@ use App\Services\CapacityService; use App\Support\DateHelper; use App\Support\HookManager; +use App\Support\I18n; use App\Support\LoanWindow; use Slim\Psr7\Factory\ServerRequestFactory; use Slim\Psr7\Response as SlimResponse; @@ -218,8 +219,8 @@ $params = $renew($ownLoan, ['new_due_date' => $addDays($dueDate, -1)]); $params2 = $renew($ownLoan, ['new_due_date' => $addDays($dueDate, $renewDays + 1)]); $params3 = $renew($ownLoan, ['new_due_date' => 'not-a-date']); -$check(($params['error'] ?? null) === 'invalid_date_format' - && ($params2['error'] ?? null) === 'invalid_date_format' +$check(($params['error'] ?? null) === 'invalid_renewal_date' + && ($params2['error'] ?? null) === 'invalid_renewal_date' && ($params3['error'] ?? null) === 'invalid_date_format', '6 new_due_date on or before the current due date, past the standard renewal, or malformed is refused', json_encode([$params, $params2, $params3])); @@ -268,8 +269,12 @@ $bookSrc = (string) file_get_contents($root . '/app/Views/libri/scheda_libro.php'); $check(str_contains($indexSrc, 'LoanWindow::explainRenewalFromParams($_GET)') && str_contains($indexSrc, 'name="new_due_date"') + && str_contains($indexSrc, 'LoanWindow::displayDate($renewalMaxDue)') + && str_contains($indexSrc, "case 'invalid_renewal_date':") && str_contains($bookSrc, 'LoanWindow::explainRenewalFromParams($_GET)') - && str_contains($bookSrc, 'name="new_due_date"'), + && str_contains($bookSrc, 'name="new_due_date"') + && str_contains($bookSrc, 'LoanWindow::displayDate($renewalMaxDue)') + && str_contains($bookSrc, "case 'invalid_renewal_date':"), '11 the loans list and the book page explain the refusal and offer "renew until D" with new_due_date'); $outcomeSrc = (string) file_get_contents($root . '/storage/plugins/mobile-api/src/Support/CirculationOutcome.php'); $staffSrc = (string) file_get_contents($root . '/storage/plugins/mobile-api/src/Controllers/StaffCirculationController.php'); @@ -295,8 +300,12 @@ }; foreach ([$dueDate, $addDays($dueDate, -1), $addDays($dueDate, $renewDays + 1), 'bad-date', ['date' => $dueDate]] as $invalidDue) { [$status, $body] = $staffRenew($ownLoan, ['new_due_date' => $invalidDue]); + $expectedMessage = is_string($invalidDue) && DateHelper::isISODateFormat($invalidDue) + ? __('La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.') + : null; $check($status === 422 && ($body['error']['code'] ?? '') === 'validation_error' && ($body['meta']['field'] ?? '') === 'new_due_date' + && ($expectedMessage === null || ($body['error']['message'] ?? '') === $expectedMessage) && $loanRow($ownLoan) === $before, '14 invalid renewal dates return 422 for new_due_date without changing the loan', json_encode([$status, $body])); } @@ -336,11 +345,49 @@ $check($ncipResult === null && $failureReason === 'no_capacity' && ($ncipCap['max_due_date'] ?? '') === $expectedMaxDue && ($ncipCap['first_conflict_date'] ?? '') === $conflictStart, '19 NCIP computes the same earlier copy limit', json_encode([$failureReason, $ncipCap])); +$handleRenew = new ReflectionMethod(NcipServerPlugin::class, 'handleRenewItem'); +$xml = new SimpleXMLElement("{$twoBook}{$borrower}"); +$response = $handleRenew->invoke($ncip, (new ServerRequestFactory())->createServerRequest('POST', '/ncip'), new SlimResponse(), $xml, ['tipo_utente' => 'admin']); +$problem = (string) $response->getBody(); +$check(str_contains($problem, 'a conflicting loan or reservation blocks the extension from ' . $conflictStart) + && !str_contains($problem, 'every copy is booked'), + '19b NCIP describes the limiting commitment without claiming every copy is unavailable', $problem); $db->query("UPDATE prestiti SET origine = 'diretto' WHERE id = {$twoLoan}"); [$status, $body] = $staffRenew($twoLoan, ['new_due_date' => $expectedMaxDue]); $check($status === 200 && ($loanRow($twoLoan)['data_scadenza'] ?? '') === $expectedMaxDue, '20 the proposed shorter renewal succeeds on the next request', json_encode([$status, $body])); +// Bulk extends an overdue loan from today, so its reported number of days +// must use that same base rather than include the overdue gap. +[$lateBook, $lateCopies] = $mkBook(1, 3); +$lateDue = $addDays($today, -5); +$lateLoan = $mkLoan($lateBook, null, $borrower, 'in_ritardo', $addDays($today, -15), $lateDue); +$laterLoan = $mkLoan($lateBook, $lateCopies[0], $otherUser, 'prenotato', $addDays($today, 4), $addDays($today, 20)); +foreach ([4 => 3, 1 => 0, -1 => 0] as $conflictOffset => $expectedDays) { + $blockedFrom = $addDays($today, $conflictOffset); + $db->query("UPDATE prestiti SET data_prestito = '{$blockedFrom}' WHERE id = {$laterLoan}"); + $asAdmin(); + $request = (new ServerRequestFactory())->createServerRequest('POST', '/api/v1/staff/loans/bulk-extend') + ->withParsedBody(['ids' => [$lateLoan], 'days' => 10]); + $response = $staff->bulkExtend($request, new SlimResponse()); + $body = json_decode((string) $response->getBody(), true); + $check($response->getStatusCode() === 409 && ($body['meta']['max_extension_days'] ?? -1) === $expectedDays + && ($body['meta']['max_due_date'] ?? '') === $addDays($today, $expectedDays) + && ($body['meta']['first_conflict_date'] ?? '') === $blockedFrom + && ($loanRow($lateLoan)['data_scadenza'] ?? '') === $lateDue, + '21 overdue bulk caps count from today and never propose a past due date', json_encode($body)); +} +$previousLocale = I18n::getLocale(); +foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK'] as $locale) { + I18n::setLocale($locale); + $date = LoanWindow::displayDate('2030-10-25'); + $expected = $locale === 'en_US' ? '10-25-2030' : '25-10-2030'; + $check($date === $expected && str_contains(__('Rinnova fino al %s', $date), $expected) + && str_contains(LoanWindow::explainRenewal(3, '2030-10-25', '2030-10-26'), $expected), + "22 renewal action and explanation share the date format in {$locale}"); +} +I18n::setLocale($previousLocale); + $cleanup(); $db->close(); diff --git a/tests/mobile-api-circulation.unit.php b/tests/mobile-api-circulation.unit.php index 35e020d83..f2f401c8d 100644 --- a/tests/mobile-api-circulation.unit.php +++ b/tests/mobile-api-circulation.unit.php @@ -110,6 +110,7 @@ ['RENEW', 'loan_overdue', 409, 'loan_overdue', null, null], ['RENEW', 'max_renewals', 409, 'max_renewals', null, null], ['RENEW', 'invalid_date_format', 422, 'validation_error', 'new_due_date', null], + ['RENEW', 'invalid_renewal_date', 422, 'validation_error', 'new_due_date', null], ['RENEW', 'extension_conflicts', 409, 'not_available', null, 'extension_conflicts'], ['BULK', 'bulk_extend_conflict', 409, 'not_available', null, 'bulk_extend_conflict'], ['BULK', 'bulk_extend_invalid', 422, 'validation_error', 'ids', 'bulk_extend_invalid'], From 7c435285dfc3f0f69e2bff9549e02559b684be8c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 15:22:21 +0200 Subject: [PATCH 18/26] Preserve staff defaults and bound capacity refusal work --- app/Controllers/LoanApprovalController.php | 2 +- app/Controllers/PrestitiController.php | 4 +- .../ReservationsAdminController.php | 4 +- app/Services/CapacityService.php | 45 +++++++++++++++ app/Views/prenotazioni/crea_prenotazione.php | 4 +- app/Views/prestiti/crea_prestito.php | 10 +--- .../src/Controllers/ActionsController.php | 3 + tests/loan-window-surfaces.unit.php | 55 ++++++++++++++++++- 8 files changed, 111 insertions(+), 16 deletions(-) diff --git a/app/Controllers/LoanApprovalController.php b/app/Controllers/LoanApprovalController.php index 1881d6f3f..b1f672982 100644 --- a/app/Controllers/LoanApprovalController.php +++ b/app/Controllers/LoanApprovalController.php @@ -436,7 +436,7 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R // return), but the loan itself starts on data_prestito: // the figures count from there, and a conflict before it // cannot be fixed by shortening, so no figures then. - $blocked = $capacity->unavailableDatesInRange($libroId, $today, $dataScadenza, null, $loanId); + $blocked = array_filter([$capacity->firstUnavailableDateInRange($libroId, $today, $dataScadenza, null, $loanId)]); $cap = ($blocked !== [] && min($blocked) > $dataPrestito) ? \App\Support\LoanWindow::cap($dataPrestito, $blocked) : null; diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index 5c3ba4769..c9f4719a2 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -31,7 +31,7 @@ class PrestitiController */ private function noCapacityRedirect($response, \App\Services\CapacityService $capacity, int $libroId, string $start, string $end) { - $blocked = $capacity->unavailableDatesInRange($libroId, $start, $end); + $blocked = array_filter([$capacity->firstUnavailableDateInRange($libroId, $start, $end)]); $query = http_build_query(['error' => 'no_copies_available'] + \App\Support\LoanWindow::redirectParams($start, $blocked)); return $response->withHeader('Location', url('/admin/loans/create') . '?' . $query)->withStatus(302); } @@ -1123,7 +1123,7 @@ public function update(Request $request, Response $response, mysqli $db, int $id // loan still possible from its start (LoanWindow). $explained = []; if ($isExtension) { - $blocked = $capacity->unavailableDatesInRange($libroId, $claimStart, $claimEnd, null, $id); + $blocked = array_filter([$capacity->firstUnavailableDateInRange($libroId, $claimStart, $claimEnd, null, $id)]); $explained = \App\Support\LoanWindow::redirectParams($newPrestito, $blocked); } $query = http_build_query(['error' => 'no_copies_available'] + $explained); diff --git a/app/Controllers/ReservationsAdminController.php b/app/Controllers/ReservationsAdminController.php index e58194116..9c89c3aba 100644 --- a/app/Controllers/ReservationsAdminController.php +++ b/app/Controllers/ReservationsAdminController.php @@ -291,7 +291,7 @@ public function update(Request $request, Response $response, mysqli $db, int $id } if (!$capacity->hasFreeCapacity($libroId, $dataInizioRichiesta, $dataFineRichiesta, excludeReservationId: $id, excludeUserId: $utenteId)) { $db->rollback(); - $blocked = $capacity->unavailableDatesInRange($libroId, $dataInizioRichiesta, $dataFineRichiesta, $utenteId, null, $id); + $blocked = array_filter([$capacity->firstUnavailableDateInRange($libroId, $dataInizioRichiesta, $dataFineRichiesta, $utenteId, null, $id)]); $query = http_build_query(['error' => 'capacity_full'] + \App\Support\LoanWindow::redirectParams($dataInizioRichiesta, $blocked)); return $response->withHeader('Location', url('/admin/reservations/edit/' . $id) . '?' . $query)->withStatus(302); } @@ -608,7 +608,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons $db->rollback(); // The form explains the refusal with the longest window still // possible from the requested start (LoanWindow). - $blocked = $capacity->unavailableDatesInRange($libroId, $dataInizioRichiesta, $dataFineRichiesta, $utenteId); + $blocked = array_filter([$capacity->firstUnavailableDateInRange($libroId, $dataInizioRichiesta, $dataFineRichiesta, $utenteId)]); $query = http_build_query(['error' => 'capacity_full'] + \App\Support\LoanWindow::redirectParams($dataInizioRichiesta, $blocked)); return $response->withHeader('Location', url('/admin/reservations/create') . '?' . $query)->withStatus(302); } diff --git a/app/Services/CapacityService.php b/app/Services/CapacityService.php index 5cc3031be..0c2428469 100644 --- a/app/Services/CapacityService.php +++ b/app/Services/CapacityService.php @@ -264,6 +264,51 @@ public function unavailableDatesInRange( return $conflicts; } + /** + * First fully occupied day, without enumerating the requested calendar. + * Staff can request very long ranges: refusal details only need the first + * conflict, so their cost must depend on commitments rather than days. + */ + public function firstUnavailableDateInRange( + int $libroId, + string $start, + string $end, + ?int $excludeUserId = null, + ?int $excludePrestitoId = null, + ?int $excludeReservationId = null + ): ?string { + if ($end < $start) { + return null; + } + $total = $this->totalCopies($libroId); + if ($total <= 0) { + return $start; + } + $intervals = array_merge( + $this->holdingLoanIntervals($libroId, $start, $end, $excludePrestitoId, $excludeUserId), + $this->activeReservationIntervals($libroId, $start, $end, $excludeReservationId, $excludeUserId) + ); + $events = []; + foreach ($intervals as [$from, $to]) { + $events[$from] = ($events[$from] ?? 0) + 1; + // No release event is needed beyond the requested horizon. This + // also avoids computing a five-digit year after 9999-12-31. + if ($to < $end) { + $after = $this->nextDay($to); + $events[$after] = ($events[$after] ?? 0) - 1; + } + } + ksort($events, SORT_STRING); + $occupied = 0; + foreach ($events as $day => $delta) { + $occupied += $delta; + if ($occupied >= $total) { + return $day; + } + } + return null; + } + /** * @return list Y-m-d days from $start to $end inclusive. */ diff --git a/app/Views/prenotazioni/crea_prenotazione.php b/app/Views/prenotazioni/crea_prenotazione.php index 88880a8c8..61a2e9d53 100644 --- a/app/Views/prenotazioni/crea_prenotazione.php +++ b/app/Views/prenotazioni/crea_prenotazione.php @@ -271,7 +271,7 @@ class="inline-flex items-center px-12 py-4 text-lg bg-gray-900 text-white rounde // refused by the store (capacity_full) after the fact. const API_BOOK_BASE = ; const appToday = ; - let defaultLoanDays = ; + const defaultLoanDays = ; const i18n = { loanLimit: , loanLimitOne: , @@ -364,8 +364,6 @@ function loadAvailability() { .then((data) => { if (requestId !== availabilityRequestId) return; if (data && data.success && data.availability) { - const payloadDays = parseInt(data.availability.loan_duration_days, 10); - if (Number.isFinite(payloadDays) && payloadDays >= 1) defaultLoanDays = payloadDays; (data.availability.days || []).forEach((day) => { if (day && day.date) availabilityByDate[day.date] = day; }); } applyLoanWindow(true); diff --git a/app/Views/prestiti/crea_prestito.php b/app/Views/prestiti/crea_prestito.php index d393f47ae..acc43c7ce 100644 --- a/app/Views/prestiti/crea_prestito.php +++ b/app/Views/prestiti/crea_prestito.php @@ -419,9 +419,9 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-gray-800 focus:ring-gray-500" const API_BOOK_BASE = ; const appToday = ; const defaultLoanDays = ; - // The availability payload of the selected book carries the same value - // read from the settings at request time; when present it wins. - let effectiveLoanDays = defaultLoanDays; + // Staff use the configured duration; availability.loan_duration_days is + // the reader default, which may be shortened by the reader's maximum. + const effectiveLoanDays = defaultLoanDays; function addDaysToIso(isoDate, days) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate || ''); @@ -769,10 +769,6 @@ function fetchBookAvailability(bookId) { if (data.success && data.availability) { const blockedByReservation = data.availability.has_active_reservation === true; - const payloadDays = parseInt(data.availability.loan_duration_days, 10); - if (Number.isFinite(payloadDays) && payloadDays >= 1) { - effectiveLoanDays = payloadDays; - } // Build availabilityByDate map (same structure as frontend) availabilityByDate = {}; if (Array.isArray(data.availability.days)) { diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index 4ac5ffe6d..2a3c7b443 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -210,6 +210,9 @@ public function requestReservation(Request $request, ResponseInterface $response // a scheduled loan or reservation when the configured duration would // cross it, as the website calendar does. The core form validates the // bounds (not before the start, within max_loan_duration_days). + if (isset($body['end_date']) && !is_scalar($body['end_date'])) { + return ResponseEnvelope::error($response, 'invalid_date', __('Data richiesta non valida.'), 422); + } $endDate = isset($body['end_date']) ? trim(Input::str($body['end_date'])) : ''; if ($bookId <= 0) { diff --git a/tests/loan-window-surfaces.unit.php b/tests/loan-window-surfaces.unit.php index 5a2a6e36b..3753fc00b 100644 --- a/tests/loan-window-surfaces.unit.php +++ b/tests/loan-window-surfaces.unit.php @@ -411,7 +411,7 @@ $settings->set('loans', 'loan_duration_days', '30'); $settings->set('loans', 'max_loan_duration_days', '7'); [$boundedBook, $boundedCopies] = $mkBook(1, 3); -$mkLoan($boundedBook, $boundedCopies[0], $otherUser, 'prenotato', 1, $addDays($today, 10), $addDays($today, 25)); +$boundedScheduledLoan = $mkLoan($boundedBook, $boundedCopies[0], $otherUser, 'prenotato', 1, $addDays($today, 10), $addDays($today, 25)); $expectedDue = $addDays($today, 7); $createdDue = static function () use ($db, $boundedBook, $requester): ?string { $row = $db->query("SELECT data_scadenza AS due FROM prestiti WHERE libro_id = {$boundedBook} AND utente_id = {$requester} @@ -457,6 +457,59 @@ $response = (new MobileActionsController($db))->requestReservation($request, new SlimResponse()); $check($response->getStatusCode() === 422 && ($jsonBody($response)['error']['code'] ?? '') === 'max_duration_exceeded' && $createdDue() === null, '22 an explicit return past the same maximum is still refused without writing a request'); +foreach ([[], ['date' => $expectedDue], new stdClass()] as $invalidEnd) { + $request = $post('/api/v1/reservations', ['book_id' => $boundedBook, 'desired_date' => $today, 'end_date' => $invalidEnd]) + ->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $requester]); + $response = (new MobileActionsController($db))->requestReservation($request, new SlimResponse()); + $check($response->getStatusCode() === 422 && ($jsonBody($response)['error']['code'] ?? '') === 'invalid_date' && $createdDue() === null, + '22b a non-scalar end_date is refused instead of silently creating a default-duration request'); +} + +// The admin form and store use staff duration, not the capped reader default +// carried by the shared availability endpoint. +[$staffBook, $staffCopies] = $mkBook(1, 4); +$asAdmin(); +$response = (new PrestitiController())->store($post('/admin/loans/store', [ + 'loan_submission_token' => \App\Support\OneTimeFormToken::issue('loan.create'), + 'utente_id' => $requester, 'libro_id' => $staffBook, 'data_prestito' => $today, +]), new SlimResponse(), $db); +$staffDue = $db->query("SELECT data_scadenza FROM prestiti WHERE libro_id = {$staffBook} AND utente_id = {$requester}")->fetch_assoc(); +$staffLoanView = (string) file_get_contents($root . '/app/Views/prestiti/crea_prestito.php'); +$staffReservationView = (string) file_get_contents($root . '/app/Views/prenotazioni/crea_prenotazione.php'); +$check(($staffDue['data_scadenza'] ?? '') === $addDays($today, 30) + && !str_contains($staffLoanView, 'parseInt(data.availability.loan_duration_days') + && !str_contains($staffReservationView, 'parseInt(data.availability.loan_duration_days'), + '22c staff defaults retain the configured 30 days when reader requests are capped at 7', $response->getHeaderLine('Location')); +$resetPatron($staffBook, $requester, $staffCopies); + +// Refusing a huge staff range must not build millions of calendar days. +$capacity = new CapacityService($db); +$check($capacity->firstUnavailableDateInRange($boundedBook, $today, '9999-12-31') === $addDays($today, 10) + && $capacity->firstUnavailableDateInRange($boundedBook, $today, '9999-12-31', null, $boundedScheduledLoan) === null + && $capacity->firstUnavailableDateInRange($boundedBook, $addDays($today, 30), $today) === null, + '22d first-conflict lookup handles huge ranges, exclusions and inverted windows without enumerating dates'); +$asAdmin(); +$response = (new PrestitiController())->store($post('/admin/loans/store', [ + 'loan_submission_token' => \App\Support\OneTimeFormToken::issue('loan.create'), + 'utente_id' => $requester, 'libro_id' => $boundedBook, + 'data_prestito' => $today, 'data_scadenza' => '9999-12-31', +]), new SlimResponse(), $db); +$params = $redirectParams($response); +$check(($params['error'] ?? '') === 'no_copies_available' && ($params['max_end_date'] ?? '') === $addDays($today, 9), + '22e staff refusal of a range ending in year 9999 reports the same first conflict', $response->getHeaderLine('Location')); +[$adjacentBook, $adjacentCopies] = $mkBook(2, 5); +$mkLoan($adjacentBook, $adjacentCopies[0], $otherUser, 'prenotato', 1, $addDays($today, 5), $addDays($today, 15)); +$mkLoan($adjacentBook, $adjacentCopies[1], $requester, 'prenotato', 1, $addDays($today, 1), $addDays($today, 4)); +$check($capacity->firstUnavailableDateInRange($adjacentBook, $today, '9999-12-31') === null, + '22f adjacent commitments on different copies do not create a false full day'); +$overlapLoan = $mkLoan($adjacentBook, $adjacentCopies[1], $admin, 'prenotato', 1, $addDays($today, 10), $addDays($today, 12)); +$allBlocked = $capacity->unavailableDatesInRange($adjacentBook, $today, $addDays($today, 20)); +$check($capacity->firstUnavailableDateInRange($adjacentBook, $today, '9999-12-31') === ($allBlocked[0] ?? null) + && $capacity->firstUnavailableDateInRange($adjacentBook, $today, '9999-12-31', null, $overlapLoan) === null, + '22g first-conflict sweep agrees with per-day capacity and honors loan exclusions'); +[$zeroBook] = $mkBook(0, 6); +$check($capacity->firstUnavailableDateInRange($zeroBook, $today, '9999-12-31') === $today, + '22h zero lendable copies block the start without building a huge date list'); $settings->set('loans', 'loan_duration_days', '0'); $settings->set('loans', 'max_loan_duration_days', '0'); $check($settings->maxLoanDurationDays() === 90 && $settings->requestLoanDurationDays() === 30, From c8bb1359dd1f4f4c86f4c071a4a02c1f3a096bc4 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 14:09:53 +0000 Subject: [PATCH 19/26] Seed the scheduled loan's borrower in the capped-loan browser spec The spec borrowed the first non-admin user it found and fell back to the admin. The CI database holds only the admin unless another spec's user happens to exist at that moment, so the seeded loan sometimes landed on the admin's own account: the capacity check excluded it and both tests hit the duplicate-loan refusal instead. The spec now creates its own patron and removes them afterwards. --- ...n-request-capped-by-scheduled-loan.spec.js | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/loan-request-capped-by-scheduled-loan.spec.js b/tests/loan-request-capped-by-scheduled-loan.spec.js index 6477fdf30..904eaf80d 100644 --- a/tests/loan-request-capped-by-scheduled-loan.spec.js +++ b/tests/loan-request-capped-by-scheduled-loan.spec.js @@ -83,7 +83,12 @@ test.describe('Book page: loan request capped by a scheduled loan', () => { let bookId = 0; let copyId = 0; let seededLoanId = 0; + let borrowerId = 0; let today = ''; + const RUN = Date.now().toString(36).slice(-6); + const BORROWER_EMAIL = `lw-cap-${RUN}@example.invalid`; + // password_hash() output contains no quote characters. + const BORROWER_HASH = execFileSync('php', ['-r', "echo password_hash('LoanWindow1!', PASSWORD_DEFAULT);"], { encoding: 'utf-8' }).trim(); let defaultDays = 0; test.beforeAll(() => { @@ -110,13 +115,15 @@ test.describe('Book page: loan request capped by a scheduled loan', () => { today = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); // Another patron's scheduled loan: the admin's own commitments never block - // the admin, so the borrower must be someone else when one exists. - const borrowerId = parseInt( - dbQuery(`SELECT id FROM utenti WHERE email<>'${ADMIN_EMAIL.replace(/'/g, "''")}' ORDER BY id LIMIT 1`) - || dbQuery(`SELECT id FROM utenti WHERE email='${ADMIN_EMAIL.replace(/'/g, "''")}' LIMIT 1`) - || '0', - 10, + // the admin (and a second loan on the same book is refused as a duplicate), + // so the borrower is a patron this spec creates. The fresh CI database + // holds only the admin, and other specs' users come and go. + dbQuery(`DELETE FROM utenti WHERE email='${BORROWER_EMAIL}'`); + dbQuery( + `INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, email_verificata, tipo_utente, created_at) ` + + `VALUES ('LWC${RUN}', 'Loan', 'Window', '${BORROWER_EMAIL}', '${BORROWER_HASH}', 'attivo', 1, 'standard', NOW())`, ); + borrowerId = parseInt(dbQuery(`SELECT id FROM utenti WHERE email='${BORROWER_EMAIL}' LIMIT 1`) || '0', 10); if (!borrowerId) { bookId = 0; return; } const loanStart = addDays(today, CONFLICT_OFFSET); @@ -134,6 +141,10 @@ test.describe('Book page: loan request capped by a scheduled loan', () => { dbQuery(`DELETE FROM prestiti WHERE libro_id=${bookId} AND utente_id=(SELECT id FROM utenti WHERE email='${ADMIN_EMAIL.replace(/'/g, "''")}' LIMIT 1) AND data_prestito>='${today}'`); dbQuery(`UPDATE copie SET stato='disponibile' WHERE id=${copyId}`); } + if (borrowerId) { + dbQuery(`DELETE FROM prestiti WHERE utente_id=${borrowerId}`); + dbQuery(`DELETE FROM utenti WHERE id=${borrowerId}`); + } }); // Runs first: the calendar test below ends by creating the admin's own loan. From 1b2c156bcf683ecf8568860d89d63a92209d96f1 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 17:22:55 +0200 Subject: [PATCH 20/26] Tell administrators when the server's PHP lacks the CVE-2026-109618/109619 fixes PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 fixed two unauthenticated remote code execution flaws on 2026-10-21. They are in the runtime, so no Pinakes release can fix them, and an administrator has no reason to look at the PHP version until it is too late. PhpSecurityAdvisory::check() compares PHP_VERSION with the first fixed release of its branch (a branch older than every patched one is reported against the oldest fix, a newer one is not), and a partial shows the result to administrators at the top of the dashboard and of the updates page: the running and the required version, that the fix comes from the hosting panel or the system packages, the interim session.upload_progress.enabled = 0, and that some distributions backport fixes without changing the version. Staff, readers and visitors see nothing. php.ini.recommended turns session upload progress off, which Pinakes does not use. Nothing is written into an installation's own .user.ini or public/.htaccess: the updater does not preserve a public/.user.ini, so shipping one would overwrite settings the operator made. --- app/Support/PhpSecurityAdvisory.php | 63 +++++++++++++++ app/Views/admin/updates.php | 2 + app/Views/dashboard/index.php | 2 + app/Views/partials/php-security-advisory.php | 26 +++++++ locale/da_DK.json | 6 +- locale/de_DE.json | 6 +- locale/en_US.json | 6 +- locale/fr_FR.json | 6 +- locale/it_IT.json | 6 +- php.ini.recommended | 8 ++ tests/php-security-advisory.unit.php | 80 ++++++++++++++++++++ 11 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 app/Support/PhpSecurityAdvisory.php create mode 100644 app/Views/partials/php-security-advisory.php create mode 100644 tests/php-security-advisory.unit.php diff --git a/app/Support/PhpSecurityAdvisory.php b/app/Support/PhpSecurityAdvisory.php new file mode 100644 index 000000000..63084c6bd --- /dev/null +++ b/app/Support/PhpSecurityAdvisory.php @@ -0,0 +1,63 @@ + branch => first release with the fixes */ + public const FIXED = [ + '8.2' => '8.2.35', + '8.3' => '8.3.36', + '8.4' => '8.4.27', + '8.5' => '8.5.12', + ]; + + /** @var list */ + public const CVES = ['CVE-2026-109618', 'CVE-2026-109619']; + + public const ADVISORY_URL = 'https://www.php.net/'; + + /** + * The release the server should move to, or null when $version has the fixes. + * + * @return array{current: string, fixed: string}|null + */ + public static function check(string $version = PHP_VERSION): ?array + { + if (preg_match('/^(\d+)\.(\d+)\.(\d+)/', $version, $m) !== 1) { + return null; + } + $branch = $m[1] . '.' . $m[2]; + $release = $branch . '.' . $m[3]; + $fixed = self::FIXED[$branch] ?? null; + if ($fixed === null) { + // Not a patched branch: either newer than all of them (fine) or older, + // where the PHP project publishes no fix at all. + $oldest = array_values(self::FIXED)[0]; + return version_compare($release, $oldest, '<') ? ['current' => $version, 'fixed' => $oldest] : null; + } + return version_compare($release, $fixed, '<') ? ['current' => $version, 'fixed' => $fixed] : null; + } +} diff --git a/app/Views/admin/updates.php b/app/Views/admin/updates.php index a22b6b5b6..9892fcf5c 100644 --- a/app/Views/admin/updates.php +++ b/app/Views/admin/updates.php @@ -49,6 +49,8 @@ class="inline-flex items-center px-6 py-3 bg-gray-100 text-gray-700 rounded-xl h unset($workspaceHeader); ?> + +
diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php index 61c51f8b1..7f5a246ec 100644 --- a/app/Views/dashboard/index.php +++ b/app/Views/dashboard/index.php @@ -33,6 +33,8 @@ unset($workspaceHeader); ?> + +
diff --git a/app/Views/partials/php-security-advisory.php b/app/Views/partials/php-security-advisory.php new file mode 100644 index 000000000..2bf8f9131 --- /dev/null +++ b/app/Views/partials/php-security-advisory.php @@ -0,0 +1,26 @@ + + diff --git a/locale/da_DK.json b/locale/da_DK.json index c9ff8b2dd..04be60e8b 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8373,5 +8373,9 @@ "Revoca tutti i link dell’app": "Tilbagekald alle appens links", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Hvis den samme fil også er vedhæftet andre poster, gælder den mest åbne synlighed blandt disse poster.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Administration af digitale vedhæftninger omfatter også åbning af begrænsede vedhæftninger: den, der kan ændre en fils synlighed, kan alligevel åbne den, så de to tilladelser gemmes sammen.", - "Diritti esistenti": "Eksisterende rettigheder" + "Diritti esistenti": "Eksisterende rettigheder", + "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s har kritiske sårbarheder: opdater til %s eller nyere", + "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "To fejl i PHP (%s) gør det muligt at køre kode på serveren uden at logge ind. De afhænger ikke af Pinakes, men af miljøet: de rettes ved at opdatere PHP fra hostingpanelet eller med systemets pakker, og ingen Pinakes-opdatering kan gøre det for dig.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Indtil du kan opdatere, mindsker session.upload_progress.enabled = 0 i PHP-konfigurationen risikoen uden at fjerne den. Nogle distributioner anvender rettelserne uden at ændre versionsnummeret: spørg i så fald din udbyder.", + "Avviso di sicurezza di PHP": "PHP-sikkerhedsmeddelelse" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 60433b038..df9f89815 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8373,5 +8373,9 @@ "Revoca tutti i link dell’app": "Alle App-Links widerrufen", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Ist dieselbe Datei auch an andere Datensätze angehängt, gilt die offenste Sichtbarkeit unter diesen Datensätzen.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Die Verwaltung digitaler Anhänge umfasst auch das Öffnen geschützter Anhänge: Wer die Sichtbarkeit einer Datei ändern kann, kann sie ohnehin öffnen, daher werden beide Berechtigungen gemeinsam gespeichert.", - "Diritti esistenti": "Bisherige Rechte" + "Diritti esistenti": "Bisherige Rechte", + "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s hat kritische Sicherheitslücken: aktualisieren Sie auf %s oder neuer", + "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Zwei Lücken in PHP (%s) erlauben es, ohne Anmeldung Code auf dem Server auszuführen. Sie hängen nicht von Pinakes ab, sondern von der Umgebung: Behoben werden sie durch ein PHP-Update im Hosting-Panel oder über die Systempakete, und kein Pinakes-Update kann das ersetzen.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Bis Sie aktualisieren können, verringert session.upload_progress.enabled = 0 in der PHP-Konfiguration das Risiko, beseitigt es aber nicht. Manche Distributionen spielen die Korrekturen ein, ohne die Versionsnummer zu ändern: Fragen Sie in diesem Fall Ihren Anbieter.", + "Avviso di sicurezza di PHP": "PHP-Sicherheitshinweis" } diff --git a/locale/en_US.json b/locale/en_US.json index 114f36767..4a3f19fea 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8373,5 +8373,9 @@ "Revoca tutti i link dell’app": "Revoke all app links", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "If the same file is also attached to other records, the most open visibility among those records applies.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Managing digital attachments includes opening restricted attachments: whoever can change a file's visibility can open it anyway, so the two permissions are saved together.", - "Diritti esistenti": "Existing rights" + "Diritti esistenti": "Existing rights", + "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s has critical vulnerabilities: update it to %s or later", + "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Two PHP flaws (%s) let anyone run code on the server without signing in. They do not depend on Pinakes but on the environment: they are fixed by updating PHP from the hosting panel or the system packages, and no Pinakes update can do it for you.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Until you can update, setting session.upload_progress.enabled = 0 in the PHP configuration lowers the risk without removing it. Some distributions apply the fixes without changing the version number: in that case check with your provider.", + "Avviso di sicurezza di PHP": "PHP security advisory" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index de529e362..9bf449322 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8373,5 +8373,9 @@ "Revoca tutti i link dell’app": "Révoquer tous les liens de l’application", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Si le même fichier est aussi joint à d’autres notices, la visibilité la plus ouverte parmi ces notices s’applique.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "La gestion des pièces jointes numériques comprend aussi l’ouverture des pièces jointes réservées : qui peut modifier la visibilité d’un fichier peut de toute façon l’ouvrir, les deux autorisations sont donc enregistrées ensemble.", - "Diritti esistenti": "Droits existants" + "Diritti esistenti": "Droits existants", + "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s présente des vulnérabilités critiques : mettez-le à jour vers %s ou une version ultérieure", + "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Deux failles de PHP (%s) permettent d’exécuter du code sur le serveur sans se connecter. Elles ne dépendent pas de Pinakes mais de l’environnement : on les corrige en mettant à jour PHP depuis le panneau de l’hébergeur ou avec les paquets du système, et aucune mise à jour de Pinakes ne peut le faire à votre place.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "En attendant de pouvoir mettre à jour, définir session.upload_progress.enabled = 0 dans la configuration de PHP réduit le risque sans l’éliminer. Certaines distributions appliquent les correctifs sans changer le numéro de version : dans ce cas, vérifiez auprès de votre fournisseur.", + "Avviso di sicurezza di PHP": "Avis de sécurité de PHP" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 1c73a5189..913db74fb 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8373,5 +8373,9 @@ "Revoca tutti i link dell’app": "Revoca tutti i link dell’app", "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.": "Se lo stesso file è allegato anche ad altre schede, vale la visibilità più aperta tra quelle schede.", "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.": "Gestione allegati digitali comprende anche l’apertura degli allegati riservati: chi può cambiare la visibilità di un file può comunque aprirlo, quindi i due permessi vengono salvati insieme.", - "Diritti esistenti": "Diritti esistenti" + "Diritti esistenti": "Diritti esistenti", + "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo", + "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.", + "Avviso di sicurezza di PHP": "Avviso di sicurezza di PHP" } diff --git a/php.ini.recommended b/php.ini.recommended index 1ff9cf8e2..8b3ebe571 100644 --- a/php.ini.recommended +++ b/php.ini.recommended @@ -38,6 +38,14 @@ opcache.validate_timestamps=1 opcache.jit=disable opcache.jit_buffer_size=0 +; =================================== +; SECURITY +; =================================== +; Pinakes does not use PHP's session upload progress. Turning it off lowers the +; exposure to CVE-2026-109618 / CVE-2026-109619 on an unpatched PHP; it does not +; replace updating PHP to 8.2.35, 8.3.36, 8.4.27, 8.5.12 or later. +session.upload_progress.enabled=0 + ; =================================== ; MEMORY & PERFORMANCE ; =================================== diff --git a/tests/php-security-advisory.unit.php b/tests/php-security-advisory.unit.php new file mode 100644 index 000000000..100b45910 --- /dev/null +++ b/tests/php-security-advisory.unit.php @@ -0,0 +1,80 @@ + PhpSecurityAdvisory::check($v)['fixed'] ?? null; + +foreach (PhpSecurityAdvisory::FIXED as $branch => $fixed) { + [$maj, $min, $patch] = array_map('intval', explode('.', $fixed)); + $previous = "{$maj}.{$min}." . ($patch - 1); + $next = "{$maj}.{$min}." . ($patch + 1); + check_advisory($fixedFor($previous) === $fixed, "{$previous} is reported, to move to {$fixed}"); + check_advisory($fixedFor("{$maj}.{$min}.0") === $fixed, "{$maj}.{$min}.0 is reported"); + check_advisory($fixedFor($fixed) === null, "{$fixed} has the fixes"); + check_advisory($fixedFor($next) === null, "{$next} has the fixes"); +} + +check_advisory($fixedFor('8.4.26') === '8.4.27', 'the 0.8.6 Docker image base (8.4.26) is reported'); +check_advisory($fixedFor('8.3.35') === '8.3.36', 'a cPanel host on 8.3.35 is reported'); +check_advisory($fixedFor('8.4.10-1+0~20260101+debian12') === '8.4.27', 'a version with a distribution suffix is read by its release number'); +check_advisory($fixedFor('8.1.31') === '8.2.35', 'a branch the PHP project no longer patches is reported against the oldest fix'); +check_advisory($fixedFor('7.4.33') === '8.2.35', '7.4 is reported'); +check_advisory($fixedFor('8.6.0') === null, 'a branch released after the fix is not reported'); +check_advisory($fixedFor('8.6.0RC3') === null, 'a release candidate of a newer branch is not reported'); +check_advisory($fixedFor('9.0.0') === null, 'a later major is not reported'); +check_advisory($fixedFor('not a version') === null, 'an unreadable version is not reported'); +check_advisory(PhpSecurityAdvisory::check(PHP_VERSION) === PhpSecurityAdvisory::check(), 'the default argument is the running PHP'); +check_advisory(PhpSecurityAdvisory::CVES === ['CVE-2026-109618', 'CVE-2026-109619'], 'both CVE ids are named'); + +// ── The partial ───────────────────────────────────────────────────────────── +$render = static function (?string $role, string $class = 'mb-8'): string { + $_SESSION = $role === null ? [] : ['user' => ['id' => 1, 'tipo_utente' => $role]]; + $phpAdvisoryClass = $class; + ob_start(); + require dirname(__DIR__) . '/app/Views/partials/php-security-advisory.php'; + return (string) ob_get_clean(); +}; +$affected = PhpSecurityAdvisory::check() !== null; +$admin = $render('admin'); +if ($affected) { + $fixed = (string) PhpSecurityAdvisory::check()['fixed']; + check_advisory(str_contains($admin, 'data-php-security-advisory') && str_contains($admin, 'role="alert"'), 'an administrator sees the notice on an affected PHP (' . PHP_VERSION . ')'); + check_advisory(str_contains($admin, PHP_VERSION) && str_contains($admin, $fixed), 'the notice names the running PHP and the release to move to'); + check_advisory(str_contains($admin, 'CVE-2026-109618, CVE-2026-109619'), 'the notice names both CVEs'); + check_advisory(str_contains($admin, 'href="https://www.php.net/"') && str_contains($admin, 'rel="noopener noreferrer"'), 'the advisory link opens safely'); + check_advisory(str_contains($admin, 'class="mb-8 p-4 bg-red-50'), 'the including page sets the outer spacing'); +} else { + check_advisory($admin === '', 'on a patched PHP (' . PHP_VERSION . ') even an administrator sees nothing'); +} +check_advisory($render('staff') === '', 'staff see nothing: they cannot change the server'); +check_advisory($render('standard') === '', 'a reader sees nothing'); +check_advisory($render(null) === '', 'a visitor sees nothing'); +check_advisory(!str_contains($render('admin', '">', 'blocked_from' => '2030-03-17'], + ['max_loan_days' => ['6'], 'max_end_date' => '2030-03-16', 'blocked_from' => '2030-03-17'], + ['max_loan_days' => '-6', 'max_end_date' => '2030-03-16', 'blocked_from' => '2030-03-17'], + ['max_loan_days' => '6', 'max_end_date' => '2030-3-16', 'blocked_from' => '2030-03-17'], +]; +$rejected = array_map(static fn (array $p): ?string => LoanWindow::explainFromParams($p), $tampered); +$check($rejected === [null, null, null, null, null], '08 edited query parameters are ignored, never echoed', json_encode($rejected)); + +$one = LoanWindow::explain(1, '2030-03-11', '2030-03-12'); +$check(str_contains($one, '1 giorno') && !str_contains($one, '1 giorni') && str_contains($one, '11-03-2030'), + '09 one day uses the singular sentence', $one); + +// ── LoanWindow: renewals (#493) ───────────────────────────────────────────── +$renew = LoanWindow::renewalCap('2030-04-01', ['2030-04-02']); +$check(($renew['max_extension_days'] ?? -1) === 0 && ($renew['max_due_date'] ?? '') === '2030-04-01', + '10 a block on the day after the due date leaves 0 days and keeps the due date', json_encode($renew)); + +$renew = LoanWindow::renewalCap('2030-04-01', ['2030-04-05'], '2030-04-10'); +$check(($renew['max_due_date'] ?? '') === '2030-04-10' && ($renew['max_extension_days'] ?? -1) === 0, + '11 an overdue bulk extension counted from today never proposes a past due date', json_encode($renew)); + +$check(LoanWindow::renewalCap('2030-04-01', ['2030-03-20', '2030-04-01']) === null, + '12 conflicts on or before the current due date do not bound the renewal'); + +$renew = LoanWindow::renewalCap('2030-04-01', ['2030-04-09', '2030-04-06']); +$check(($renew['max_due_date'] ?? '') === '2030-04-05' && ($renew['max_extension_days'] ?? 0) === 4 + && ($renew['first_conflict_date'] ?? '') === '2030-04-06', '13 the earliest later conflict bounds the renewal', json_encode($renew)); + +$none = LoanWindow::explainRenewal(0, '2030-04-01', '2030-04-02'); +$check(str_contains($none, 'Rinnovo non possibile') && str_contains($none, '02-04-2030'), + '14 zero days says that no renewal is possible and from when', $none); + +$back = LoanWindow::explainRenewalFromParams(LoanWindow::renewalRedirectParams('2030-04-01', ['2030-04-06'])); +$check($back === LoanWindow::explainRenewal(4, '2030-04-05', '2030-04-06') + && LoanWindow::explainRenewalFromParams(['max_extension_days' => 'four', 'max_due_date' => '2030-04-05', 'blocked_from' => '2030-04-06']) === null, + '15 renewal parameters survive the redirect and reject non-numeric days', (string) $back); + +// ── CopyRepository::inventoryBase() (#489) ────────────────────────────────── +$check(CopyRepository::inventoryBase(42, 'BIB') === 'BIB', '16 the book prefix is the base when it is set'); + +$blank = [CopyRepository::inventoryBase(42, ''), CopyRepository::inventoryBase(42, ' '), CopyRepository::inventoryBase(42, null)]; +$check($blank === ['LIB-42', 'LIB-42', 'LIB-42'], '17 an empty, blank or missing prefix falls back to LIB-', json_encode($blank)); + +$check(CopyRepository::inventoryBase(7, ['BIB']) === 'LIB-7' && CopyRepository::inventoryBase(7, new stdClass()) === 'LIB-7', + '18 a non-scalar value is not a prefix'); + +$check(CopyRepository::inventoryBase(7, '0') === '0' && CopyRepository::inventoryBase(7, 2026) === '2026', + '19 a prefix of 0 and a numeric prefix are honoured'); + +$check(CopyRepository::inventoryBase(7, " FONDO-A \t") === 'FONDO-A', '20 surrounding whitespace is never part of a code'); + +// ── PhpSecurityAdvisory table (#491) ─────────────────────────────────────── +$branches = array_keys(PhpSecurityAdvisory::FIXED); +$sorted = $branches; +usort($sorted, 'version_compare'); +$consistent = $branches === $sorted; +foreach (PhpSecurityAdvisory::FIXED as $branch => $fixed) { + $consistent = $consistent && str_starts_with($fixed, $branch . '.') && preg_match('/^\d+\.\d+\.\d+$/', $fixed) === 1; +} +$check($consistent, '21 every fixed release belongs to its branch, branches in ascending order'); + +// ── The release itself ───────────────────────────────────────────────────── +$keys = [ + 'PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo', + 'Avviso di sicurezza di PHP', + 'Amministratori, staff, utenti autorizzati e chi ha in corso il prestito di quel libro.', +]; +$missing = []; +foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK'] as $locale) { + $table = json_decode((string) file_get_contents("{$root}/locale/{$locale}.json"), true); + foreach ($keys as $key) { + $value = $table[$key] ?? null; + if (!is_string($value) || substr_count($value, '%s') !== substr_count($key, '%s')) { + $missing[] = "{$locale}: {$key}"; + } + } +} +$check($missing === [], '22 the new notice and description strings exist in all five locales with their placeholders', implode('; ', $missing)); + +$version = (string) json_decode((string) file_get_contents("{$root}/version.json"), true)['version']; +$manifests = []; +foreach (['desiderata', 'mobile-api'] as $plugin) { + $manifest = json_decode((string) file_get_contents("{$root}/storage/plugins/{$plugin}/plugin.json"), true); + $required = (string) ($manifest['requires_app'] ?? ''); + $manifests[$plugin] = $required; +} +$usesHelper = str_contains((string) file_get_contents("{$root}/storage/plugins/desiderata/DesiderataPlugin.php"), 'inventoryBase(') + && str_contains((string) file_get_contents("{$root}/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php"), 'inventoryBase('); +$check($usesHelper && $manifests === ['desiderata' => '0.8.7', 'mobile-api' => '0.8.7'] && version_compare('0.8.7', $version, '<='), + '23 the two plugins that call inventoryBase() require the core that has it, and no later one', json_encode($manifests) . " app={$version}"); + +$changelog = (string) file_get_contents("{$root}/CHANGELOG.md"); +$readme = (string) file_get_contents("{$root}/README.md"); +$check(str_contains($changelog, "## [{$version}]") && str_contains($readme, "## What's New in v{$version}") + && preg_match('/badge\/dynamic\/json[^)]*version\.json/', $readme) === 1 + && preg_match('/img\.shields\.io\/badge\/version-\d/', $readme) === 0, + '24 changelog and README describe this version, and the version badge stays dynamic'); + +$pages = []; +foreach (['app/Views/dashboard/index.php', 'app/Views/admin/updates.php'] as $view) { + $pages[$view] = substr_count((string) file_get_contents("{$root}/{$view}"), "partials/php-security-advisory.php"); +} +$check($pages === ['app/Views/dashboard/index.php' => 1, 'app/Views/admin/updates.php' => 1], + '25 the dashboard and the updates page each show the PHP notice once', json_encode($pages)); + +I18n::setLocale($previousLocale); +echo "\nALL {$n} PASS\n"; From 642e84b45b8d57040e8ebcb47963e7248fb74bc3 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 17:47:21 +0200 Subject: [PATCH 23/26] test: find the uploads of an old release where that release writes them The Test B upgrade spec uploaded Digital Library files on the old release and expected them in public/uploads/digital. That held up to 0.8.5; since 0.8.6-rc.1 new uploads go to storage/digital, so with 0.8.6 as the published release the "before" phase failed on a file that was in the right place. The spec now reads the installed version.json and expects the folder that release writes to, and on 0.8.6 or later also that no copy is left in the public folder. The "after" phase already checks storage/digital for both cases. --- tests/digital-upgrade-480.spec.js | 39 ++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/tests/digital-upgrade-480.spec.js b/tests/digital-upgrade-480.spec.js index 235426d07..ec41cdc77 100644 --- a/tests/digital-upgrade-480.spec.js +++ b/tests/digital-upgrade-480.spec.js @@ -2,9 +2,10 @@ // Issue #480 — Digital Library files uploaded on an older release, then the upgrade. // // Run twice by scripts/reinstall-test.sh (Test B), against the same sandbox install: -// E2E_UPGRADE_PHASE=before on the old release (files go to public/uploads/digital and -// anyone can download them), the admin uploads through the -// book form like a person would; +// E2E_UPGRADE_PHASE=before on the old release (files go to public/uploads/digital up to +// 0.8.5, to storage/digital from 0.8.6-rc.1, and anyone can +// download them), the admin uploads through the book form +// like a person would; // E2E_UPGRADE_PHASE=after on the upgraded install: the same files, byte for byte, now // in storage/digital, still served at the same addresses, and // the per-file visibility of 0.8.6 enforced on them. @@ -26,6 +27,27 @@ const PHASE = process.env.E2E_UPGRADE_PHASE || ''; const STATE = process.env.E2E_UPGRADE_STATE || path.join(ROOT, 'storage/tmp/digital-upgrade-480.json'); const STORAGE = path.join(ROOT, 'storage/digital'); const LEGACY = path.join(ROOT, 'public/uploads/digital'); + +/** version_compare() for X.Y.Z[-rc.N]: a prerelease sorts before its final. */ +function compareVersions(a, b) { + const split = v => { const [core, pre = ''] = String(v).split('-', 2); return [core.split('.').map(Number), pre]; }; + const [ca, pa] = split(a), [cb, pb] = split(b); + for (let i = 0; i < Math.max(ca.length, cb.length); i++) { + const d = (ca[i] || 0) - (cb[i] || 0); + if (d !== 0) return d < 0 ? -1 : 1; + } + if (pa === pb) return 0; + if (pa === '') return 1; + if (pb === '') return -1; + return pa < pb ? -1 : 1; +} +// Where the installed release writes a new upload: out of the web root since #480. +function uploadFolder() { + const version = JSON.parse(fs.readFileSync(path.join(ROOT, 'version.json'), 'utf8')).version; + return compareVersions(version, '0.8.6-rc.1') >= 0 + ? { dir: STORAGE, label: 'storage/digital', version } + : { dir: LEGACY, label: 'public/uploads/digital', version }; +} // Without a phase this is the regular browser run, where the spec has nothing to do; a // reason with "required" there would read as broken infrastructure to the skip audit. test.skip(!PHASE, 'Runs only around a real upgrade (phase given by E2E_UPGRADE_PHASE)'); @@ -182,12 +204,17 @@ test.describe.serial(`Digital Library across the upgrade (#480) — ${PHASE}`, ( } await saveBook(page); - // The old release keeps them in the public uploads folder, as uploaded, and lists them on the book. + // The old release keeps them where it writes uploads (the public folder up to 0.8.5, + // storage/digital since 0.8.6-rc.1), as uploaded, and lists them on the book. + const folder = uploadFolder(); for (const { url, sha: digest } of Object.values(state.files)) { expect(url).toMatch(/^\/uploads\/digital\/[A-Za-z0-9._-]+\.(pdf|mp3)$/); - const file = path.join(LEGACY, path.basename(url)); - expect(fs.existsSync(file), `${url} in public/uploads/digital`).toBe(true); + const file = path.join(folder.dir, path.basename(url)); + expect(fs.existsSync(file), `${url} in ${folder.label} on ${folder.version}`).toBe(true); expect(sha(fs.readFileSync(file))).toBe(digest); + if (folder.dir === STORAGE) { + expect(fs.existsSync(path.join(LEGACY, path.basename(url))), `${url} not also in public/uploads/digital on ${folder.version}`).toBe(false); + } } const listed = db(`SELECT CONCAT_WS('|', file_url, audio_url) FROM libri WHERE id=${state.bookId}`) + (state.mode === 'attachments' ? db(`SELECT digital_attachments FROM libri WHERE id=${state.bookId}`) : ''); From c05cda3eaf9687786fe357d13456d021e4d6df5b Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 18:10:13 +0200 Subject: [PATCH 24/26] Give storage/digital back its group write bits after an upgrade Updater::fixPermissions() sets every folder under storage/ to 0755 at the end of an upgrade, storage/digital included, while the installer, the updater's own required folders and setup-permissions.sh give writable folders 0775: the maintenance cron that moves Digital Library files can run as another user than the web server. Upgrading from 0.8.5 hid it, since storage/digital was only created after the upgrade; from 0.8.6 the folder exists and came out 0755. fixPermissions() now passes storage/digital through DigitalStorage::ensureDir(), which adds the group bits and never the "other" ones, and so does the once-per-version window in PluginManager, because the upgrade to 0.8.7 still runs the 0.8.6 updater already in memory. tests/digital-storage-permissions-upgrade.unit.php runs the real fixPermissions() on a throwaway tree and fails on the previous code with 0755. --- CHANGELOG.md | 1 + app/Support/PluginManager.php | 14 ++++ app/Support/Updater.php | 12 +++ ...gital-storage-permissions-upgrade.unit.php | 79 +++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 tests/digital-storage-permissions-upgrade.unit.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e33dca5c..388603fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Full version-by-version history for Pinakes. The README shows only the latest re - **A loan request stops before a scheduled loan and says how long it can be** ([#490](https://github.com/fabiodalez-dev/Pinakes/pull/490)). When the only copy has a scheduled loan or a reservation inside the default loan window, the book page proposed a return date that fell on a blocked day and the request was refused with a bare "no copies available". Every surface that creates or edits a loan window (book page, user area, Mobile API, admin create, approval, date edit, admin reservations) now caps the window at the day before the conflict and says, in the reader's language, the longest possible loan and the day the book is booked from. - **A renewal says until when it is still possible, and can renew until that day** ([#493](https://github.com/fabiodalez-dev/Pinakes/pull/493)). A renewal that crossed a scheduled loan on every copy was refused outright on the admin page, the bulk extension, the Mobile API and NCIP RenewItem. It now names the furthest possible due date and how many days that is, the loans list and the book page offer "Rinnova fino al D", and `POST /staff/loans/{id}/renew` accepts a `new_due_date`. - **Copies received through Desiderata get the book's own inventory prefix** ([#489](https://github.com/fabiodalez-dev/Pinakes/issues/489), [#494](https://github.com/fabiodalez-dev/Pinakes/pull/494)). The plugin built them on `LIB-` and ignored "Prefisso inventario copie", and an inventory code cannot be changed afterwards. One helper, `CopyRepository::inventoryBase()`, now gives the rule to Desiderata, book creation, the copies form, the increase-copies endpoint and the Mobile API. Existing codes are not renamed. +- **`storage/digital` keeps its group write bits after an upgrade.** At the end of every upgrade the updater reset every folder under `storage/` to 0755, so an installation already on 0.8.6 lost the group write bits of `storage/digital` that the maintenance cron needs when it runs as another user than the web server. The updater now gives them back, and the first request after upgrading to 0.8.7 repairs a folder the previous updater already reset. - **The "Whoever has the book on loan" visibility says that authorized users open the file too** ([#492](https://github.com/fabiodalez-dev/Pinakes/pull/492)). The access rule was already so; only the description in the Digital Library settings left them out. Plugin versions: Desiderata 1.2.1 and Mobile API 1.8.2, both requiring 0.8.7 (they call `CopyRepository::inventoryBase()`). No migration. diff --git a/app/Support/PluginManager.php b/app/Support/PluginManager.php index 402d974ed..59dc3faed 100644 --- a/app/Support/PluginManager.php +++ b/app/Support/PluginManager.php @@ -2335,6 +2335,20 @@ public function loadActivePlugins(): void SecureLogger::error('[PluginManager] public/.htaccess still lets private uploads be served from disk (#480): put "RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [NC,QSA,L]" before the public /uploads/ rule, or restore the shipped file'); } + // Same window, for storage/digital: up to 0.8.6 the updater's fixPermissions() + // set every folder under storage/ to 0755 at the end of an upgrade, so an + // install already on 0.8.6 lost the group write bits of storage/digital, which + // the cron needs when it runs as another user. The OLD updater in memory does + // this upgrade too; ensureDir() gives the bits back (never the "other" ones). + try { + $digitalDir = DigitalStorage::targetDir(); + if (is_dir($digitalDir)) { + DigitalStorage::ensureDir($digitalDir); + } + } catch (\Throwable $e) { + SecureLogger::error('[PluginManager] storage/digital permission heal failed', ['error' => $e->getMessage()]); + } + QueryCache::set($maintenanceKey, 1, 900); } diff --git a/app/Support/Updater.php b/app/Support/Updater.php index b0370011e..0ac058a17 100644 --- a/app/Support/Updater.php +++ b/app/Support/Updater.php @@ -4788,6 +4788,18 @@ private function fixPermissions(): void } } + // The loop above gives every folder under storage/ 0755, storage/digital + // included: give it back the group write bits it is created with (0775), which + // the cron needs when it runs as another user than the web server (#480). + $digitalDir = DigitalStorage::targetDir($this->rootPath); + if (is_dir($digitalDir)) { + try { + DigitalStorage::ensureDir($digitalDir); + } catch (\Throwable $e) { + $this->debugLog('WARNING', 'storage/digital permissions not restored: ' . $e->getMessage()); + } + } + $envFile = $this->rootPath . '/.env'; if (file_exists($envFile)) { chmod($envFile, 0600); diff --git a/tests/digital-storage-permissions-upgrade.unit.php b/tests/digital-storage-permissions-upgrade.unit.php new file mode 100644 index 000000000..3d9178c9b --- /dev/null +++ b/tests/digital-storage-permissions-upgrade.unit.php @@ -0,0 +1,79 @@ +newInstanceWithoutConstructor(); + $property = new ReflectionProperty(Updater::class, 'rootPath'); + $property->setValue($updater, $root); + (new ReflectionMethod(Updater::class, 'fixPermissions'))->invoke($updater); + + $check($mode("{$root}/storage/digital") === '0775', 'fixPermissions() leaves storage/digital with its group write bits', $mode("{$root}/storage/digital")); + $check($mode("{$root}/storage/digital/20261021_book.pdf") === '0644', 'the files inside still get 0644 from fixPermissions()', $mode("{$root}/storage/digital/20261021_book.pdf")); + $check($mode("{$root}/storage/uploads") === '0755', 'the other storage folders keep what fixPermissions() always gave them', $mode("{$root}/storage/uploads")); + $check(is_file("{$root}/storage/digital/.htaccess") && str_contains((string) file_get_contents("{$root}/storage/digital/.htaccess"), 'Require all denied'), + 'the repair keeps storage/digital shut to direct requests'); + + chmod("{$root}/storage/digital", 0755); + DigitalStorage::ensureDir("{$root}/storage/digital"); + $check($mode("{$root}/storage/digital") === '0775', 'an existing 0755 storage/digital (an install upgraded from 0.8.6) gets 0775 back', $mode("{$root}/storage/digital")); + + chmod("{$root}/storage/digital", 0750); + DigitalStorage::ensureDir("{$root}/storage/digital"); + $check($mode("{$root}/storage/digital") === '0770', 'a stricter 0750 chosen by the administrator gets group write, never "other" bits', $mode("{$root}/storage/digital")); + + $rmTree("{$root}/storage/digital"); + $updaterNoDigital = (new ReflectionClass(Updater::class))->newInstanceWithoutConstructor(); + $property->setValue($updaterNoDigital, $root); + (new ReflectionMethod(Updater::class, 'fixPermissions'))->invoke($updaterNoDigital); + $check(!is_dir("{$root}/storage/digital"), 'fixPermissions() does not create storage/digital where an install has none'); +} finally { + umask($previousUmask); + if (is_dir($root)) { + $rmTree($root); + } +} + +echo "\nALL {$n} PASS\n"; From fe16d0ab847ec8c225b2167cb1f035a991010491 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 18:42:38 +0200 Subject: [PATCH 25/26] Link the PHP notice to the changelog entry of the release to install The link went to the php.net home page, where the 2026-10-21 announcement will be pushed down by newer news within weeks. PhpSecurityAdvisory::changelogUrl() points at the entry of the fixed release in ChangeLog-8.php (e.g. #8.4.27), a page that does not move and lists the fix by its advisory ids GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp. --- app/Support/PhpSecurityAdvisory.php | 13 ++++++++++++- app/Views/partials/php-security-advisory.php | 2 +- tests/php-security-advisory.unit.php | 3 ++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/Support/PhpSecurityAdvisory.php b/app/Support/PhpSecurityAdvisory.php index 63084c6bd..39a3f89a9 100644 --- a/app/Support/PhpSecurityAdvisory.php +++ b/app/Support/PhpSecurityAdvisory.php @@ -37,7 +37,18 @@ final class PhpSecurityAdvisory /** @var list */ public const CVES = ['CVE-2026-109618', 'CVE-2026-109619']; - public const ADVISORY_URL = 'https://www.php.net/'; + /** + * The PHP 8 changelog. Unlike the php.net home page it does not move as news come + * out, and each release has its own anchor, where the fix is listed by its GitHub + * advisory ids (GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp). + */ + public const ADVISORY_URL = 'https://www.php.net/ChangeLog-8.php'; + + /** The changelog entry of the release that fixes it, e.g. ChangeLog-8.php#8.4.27. */ + public static function changelogUrl(string $fixed): string + { + return self::ADVISORY_URL . '#' . rawurlencode($fixed); + } /** * The release the server should move to, or null when $version has the fixes. diff --git a/app/Views/partials/php-security-advisory.php b/app/Views/partials/php-security-advisory.php index 2bf8f9131..ebfe7b458 100644 --- a/app/Views/partials/php-security-advisory.php +++ b/app/Views/partials/php-security-advisory.php @@ -20,7 +20,7 @@

-

+

diff --git a/tests/php-security-advisory.unit.php b/tests/php-security-advisory.unit.php index 100b45910..1dabfe48d 100644 --- a/tests/php-security-advisory.unit.php +++ b/tests/php-security-advisory.unit.php @@ -51,6 +51,7 @@ function check_advisory(bool $ok, string $label): void check_advisory($fixedFor('not a version') === null, 'an unreadable version is not reported'); check_advisory(PhpSecurityAdvisory::check(PHP_VERSION) === PhpSecurityAdvisory::check(), 'the default argument is the running PHP'); check_advisory(PhpSecurityAdvisory::CVES === ['CVE-2026-109618', 'CVE-2026-109619'], 'both CVE ids are named'); +check_advisory(PhpSecurityAdvisory::changelogUrl('8.4.27') === 'https://www.php.net/ChangeLog-8.php#8.4.27', 'the link opens the changelog entry of the release to install'); // ── The partial ───────────────────────────────────────────────────────────── $render = static function (?string $role, string $class = 'mb-8'): string { @@ -67,7 +68,7 @@ function check_advisory(bool $ok, string $label): void check_advisory(str_contains($admin, 'data-php-security-advisory') && str_contains($admin, 'role="alert"'), 'an administrator sees the notice on an affected PHP (' . PHP_VERSION . ')'); check_advisory(str_contains($admin, PHP_VERSION) && str_contains($admin, $fixed), 'the notice names the running PHP and the release to move to'); check_advisory(str_contains($admin, 'CVE-2026-109618, CVE-2026-109619'), 'the notice names both CVEs'); - check_advisory(str_contains($admin, 'href="https://www.php.net/"') && str_contains($admin, 'rel="noopener noreferrer"'), 'the advisory link opens safely'); + check_advisory(str_contains($admin, 'href="https://www.php.net/ChangeLog-8.php#' . $fixed . '"') && str_contains($admin, 'rel="noopener noreferrer"'), 'the advisory link opens safely'); check_advisory(str_contains($admin, 'class="mb-8 p-4 bg-red-50'), 'the including page sets the outer spacing'); } else { check_advisory($admin === '', 'on a patched PHP (' . PHP_VERSION . ') even an administrator sees nothing'); From 3c6dc4ed754c06c46283d935a764039689c0c956 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 19:55:23 +0200 Subject: [PATCH 26/26] Close the CodeRabbit review of 0.8.7 - PhpSecurityAdvisory::check() keeps a pre-release suffix (8.4.27RC1, 8.4.27-dev) in the comparison, so a release candidate of the fixed release is reported instead of being read as 8.4.27; a distribution suffix still does not count. - The interim session.upload_progress.enabled = 0 is named as an administrative setting (php.ini or the PHP-FPM pool, then a PHP restart), in php.ini.recommended, the admin notice in all five locales and the README: the PHP advisory says .user.ini is applied too late for it. - The README no longer says the CVEs are fixed only by the upstream releases (distributions backport them), and its 0.8.6 summary says that authorized users open a loan-only file too. - Mobile API: CirculationOutcome puts meta.loan_id on a bulk_extend_conflict even when no window can be explained, as the OpenAPI document says. - Tests: the desiderata receipt specs wait for the receipt POST instead of a URL they are already on, the capped-loan spec skips titles a pending request already holds a copy of, and the inventoryBase() unit checks add non-scalar and long prefixes. --- README.md | 4 ++-- app/Support/PhpSecurityAdvisory.php | 7 +++++-- app/Views/partials/php-security-advisory.php | 2 +- locale/da_DK.json | 2 +- locale/de_DE.json | 2 +- locale/en_US.json | 2 +- locale/fr_FR.json | 2 +- locale/it_IT.json | 2 +- php.ini.recommended | 7 ++++++- .../src/Support/CirculationOutcome.php | 8 +++++--- tests/desiderata-inventory-prefix-489.spec.js | 16 ++++++++++++---- tests/desiderata-inventory-prefix-489.unit.php | 4 ++++ ...loan-request-capped-by-scheduled-loan.spec.js | 4 +++- tests/php-security-advisory.unit.php | 4 ++++ 14 files changed, 47 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c91a7abdd..7a439e9ca 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri **A security notice for PHP, loan windows that explain themselves, and Desiderata codes that follow the book.** -- **PHP security notice** ([#491](https://github.com/fabiodalez-dev/Pinakes/issues/491)): CVE-2026-109618 and CVE-2026-109619 are in the PHP runtime, not in Pinakes, and are fixed only by updating PHP to 8.5.12, 8.4.27, 8.3.36 or 8.2.35. The dashboard and the updates page tell administrators when their server's PHP is older, with the interim `session.upload_progress.enabled = 0`. +- **PHP security notice** ([#491](https://github.com/fabiodalez-dev/Pinakes/issues/491)): CVE-2026-109618 and CVE-2026-109619 are in the PHP runtime, not in Pinakes: they are fixed upstream in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, and some distributions backport the fixes to older version numbers, so check your provider's packages too. The dashboard and the updates page tell administrators when their server's PHP is older, with the interim `session.upload_progress.enabled = 0` (in `php.ini` or the PHP-FPM pool, not `.user.ini`). - **Loan requests stop before a scheduled loan** ([#490](https://github.com/fabiodalez-dev/Pinakes/pull/490)) and say the longest possible loan, on the book page, the user area, the Mobile API and every admin form. - **Renewals say until when they are possible** ([#493](https://github.com/fabiodalez-dev/Pinakes/pull/493)) and can renew until that day, also from the Mobile API and NCIP. - **Desiderata copies get the book's inventory prefix** ([#489](https://github.com/fabiodalez-dev/Pinakes/issues/489), [#494](https://github.com/fabiodalez-dev/Pinakes/pull/494)) instead of `LIB-`. @@ -63,7 +63,7 @@ Highlights of the 0.8 releases are below. The full version-by-version history li ### v0.8.6 -**Who may open each digital file, files out of the web root, and custom roles.** Every Digital Library attachment can be public, for signed-in readers, for authorised staff, or for the current borrower only, and every download passes through Pinakes, also from the app with signed links; administrators can create roles with exactly the permissions they need ([#480](https://github.com/fabiodalez-dev/Pinakes/issues/480)). The upgrade heals every older `public/.htaccess`, keeps files uploaded on older releases working, and public pages are much lighter. One additive migration (`migrate_0.8.6-rc.1.sql`); see **[CHANGELOG.md](CHANGELOG.md)**. +**Who may open each digital file, files out of the web root, and custom roles.** Every Digital Library attachment can be public, for signed-in readers, for authorised staff, or for the current borrower (authorised users open every file too), and every download passes through Pinakes, also from the app with signed links; administrators can create roles with exactly the permissions they need ([#480](https://github.com/fabiodalez-dev/Pinakes/issues/480)). The upgrade heals every older `public/.htaccess`, keeps files uploaded on older releases working, and public pages are much lighter. One additive migration (`migrate_0.8.6-rc.1.sql`); see **[CHANGELOG.md](CHANGELOG.md)**. ### v0.8.5 diff --git a/app/Support/PhpSecurityAdvisory.php b/app/Support/PhpSecurityAdvisory.php index 39a3f89a9..0f45e5ca8 100644 --- a/app/Support/PhpSecurityAdvisory.php +++ b/app/Support/PhpSecurityAdvisory.php @@ -57,11 +57,14 @@ public static function changelogUrl(string $fixed): string */ public static function check(string $version = PHP_VERSION): ?array { - if (preg_match('/^(\d+)\.(\d+)\.(\d+)/', $version, $m) !== 1) { + // A pre-release (8.4.27RC1, 8.4.27-dev) comes before its final and may lack the + // fix, so its suffix takes part in the comparison; a distribution's own suffix + // (8.4.10-1+0~deb12) does not, and the release number decides. + if (preg_match('/^(\d+)\.(\d+)\.(\d+)((?:-?(?:dev|alpha|beta|RC)\d*)?)/i', $version, $m) !== 1) { return null; } $branch = $m[1] . '.' . $m[2]; - $release = $branch . '.' . $m[3]; + $release = $branch . '.' . $m[3] . $m[4]; $fixed = self::FIXED[$branch] ?? null; if ($fixed === null) { // Not a patched branch: either newer than all of them (fine) or older, diff --git a/app/Views/partials/php-security-advisory.php b/app/Views/partials/php-security-advisory.php index ebfe7b458..b8bff65b8 100644 --- a/app/Views/partials/php-security-advisory.php +++ b/app/Views/partials/php-security-advisory.php @@ -19,7 +19,7 @@

-

+

diff --git a/locale/da_DK.json b/locale/da_DK.json index befc8b5b4..4c622f2b2 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8387,6 +8387,6 @@ "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.": "Den valgte dato ligger uden for den tilladte fornyelsesperiode. Opdater siden, og prøv igen.", "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s har kritiske sårbarheder: opdater til %s eller nyere", "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "To fejl i PHP (%s) gør det muligt at køre kode på serveren uden at logge ind. De afhænger ikke af Pinakes, men af miljøet: de rettes ved at opdatere PHP fra hostingpanelet eller med systemets pakker, og ingen Pinakes-opdatering kan gøre det for dig.", - "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Indtil du kan opdatere, mindsker session.upload_progress.enabled = 0 i PHP-konfigurationen risikoen uden at fjerne den. Nogle distributioner anvender rettelserne uden at ændre versionsnummeret: spørg i så fald din udbyder.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Indtil du kan opdatere, mindsker session.upload_progress.enabled = 0 i php.ini eller PHP-FPM-poolen (ikke i .user.ini, som virker for sent) og en genstart af PHP risikoen uden at fjerne den. Nogle distributioner anvender rettelserne uden at ændre versionsnummeret: spørg i så fald din udbyder.", "Avviso di sicurezza di PHP": "PHP-sikkerhedsmeddelelse" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 6f85afd34..1b6f63f66 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8387,6 +8387,6 @@ "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.": "Das gewählte Datum liegt außerhalb des zulässigen Verlängerungszeitraums. Laden Sie die Seite neu und versuchen Sie es erneut.", "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s hat kritische Sicherheitslücken: aktualisieren Sie auf %s oder neuer", "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Zwei Lücken in PHP (%s) erlauben es, ohne Anmeldung Code auf dem Server auszuführen. Sie hängen nicht von Pinakes ab, sondern von der Umgebung: Behoben werden sie durch ein PHP-Update im Hosting-Panel oder über die Systempakete, und kein Pinakes-Update kann das ersetzen.", - "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Bis Sie aktualisieren können, verringert session.upload_progress.enabled = 0 in der PHP-Konfiguration das Risiko, beseitigt es aber nicht. Manche Distributionen spielen die Korrekturen ein, ohne die Versionsnummer zu ändern: Fragen Sie in diesem Fall Ihren Anbieter.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Bis Sie aktualisieren können, verringert session.upload_progress.enabled = 0 in der php.ini oder im PHP-FPM-Pool (nicht in .user.ini, die zu spät greift) mit anschließendem Neustart von PHP das Risiko, beseitigt es aber nicht. Manche Distributionen spielen die Korrekturen ein, ohne die Versionsnummer zu ändern: Fragen Sie in diesem Fall Ihren Anbieter.", "Avviso di sicurezza di PHP": "PHP-Sicherheitshinweis" } diff --git a/locale/en_US.json b/locale/en_US.json index 4d715b687..716687124 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8387,6 +8387,6 @@ "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.": "The selected date is outside the allowed renewal period. Refresh the page and try again.", "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s has critical vulnerabilities: update it to %s or later", "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Two PHP flaws (%s) let anyone run code on the server without signing in. They do not depend on Pinakes but on the environment: they are fixed by updating PHP from the hosting panel or the system packages, and no Pinakes update can do it for you.", - "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Until you can update, setting session.upload_progress.enabled = 0 in the PHP configuration lowers the risk without removing it. Some distributions apply the fixes without changing the version number: in that case check with your provider.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Until you can update, setting session.upload_progress.enabled = 0 in php.ini or the PHP-FPM pool (not in .user.ini, which comes too late) and restarting PHP lowers the risk without removing it. Some distributions apply the fixes without changing the version number: in that case check with your provider.", "Avviso di sicurezza di PHP": "PHP security advisory" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 2544d803a..b8fbd16ec 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8387,6 +8387,6 @@ "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.": "La date choisie est hors de la période de renouvellement autorisée. Actualisez la page et réessayez.", "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s présente des vulnérabilités critiques : mettez-le à jour vers %s ou une version ultérieure", "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Deux failles de PHP (%s) permettent d’exécuter du code sur le serveur sans se connecter. Elles ne dépendent pas de Pinakes mais de l’environnement : on les corrige en mettant à jour PHP depuis le panneau de l’hébergeur ou avec les paquets du système, et aucune mise à jour de Pinakes ne peut le faire à votre place.", - "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "En attendant de pouvoir mettre à jour, définir session.upload_progress.enabled = 0 dans la configuration de PHP réduit le risque sans l’éliminer. Certaines distributions appliquent les correctifs sans changer le numéro de version : dans ce cas, vérifiez auprès de votre fournisseur.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "En attendant de pouvoir mettre à jour, définir session.upload_progress.enabled = 0 dans php.ini ou dans le pool PHP-FPM (pas dans .user.ini, appliqué trop tard) puis redémarrer PHP réduit le risque sans l’éliminer. Certaines distributions appliquent les correctifs sans changer le numéro de version : dans ce cas, vérifiez auprès de votre fournisseur.", "Avviso di sicurezza di PHP": "Avis de sécurité de PHP" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 375e76fa4..23267629c 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8387,6 +8387,6 @@ "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.": "La data scelta non rientra nel periodo di rinnovo consentito. Aggiorna la pagina e riprova.", "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo": "PHP %s ha vulnerabilità critiche: aggiornalo a %s o successivo", "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.": "Due falle di PHP (%s) permettono di eseguire codice sul server senza accedere. Non dipendono da Pinakes ma dall’ambiente: si correggono aggiornando PHP dal pannello dell’hosting o con i pacchetti del sistema, e nessun aggiornamento di Pinakes può farlo al posto tuo.", - "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nella configurazione di PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.", + "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.": "Finché non puoi aggiornare, impostare session.upload_progress.enabled = 0 nel php.ini o nel pool PHP-FPM (non in .user.ini, che arriva troppo tardi) e riavviare PHP riduce il rischio senza eliminarlo. Alcune distribuzioni applicano le correzioni senza cambiare il numero di versione: in quel caso verifica con il tuo fornitore.", "Avviso di sicurezza di PHP": "Avviso di sicurezza di PHP" } diff --git a/php.ini.recommended b/php.ini.recommended index 8b3ebe571..773447353 100644 --- a/php.ini.recommended +++ b/php.ini.recommended @@ -43,7 +43,12 @@ opcache.jit_buffer_size=0 ; =================================== ; Pinakes does not use PHP's session upload progress. Turning it off lowers the ; exposure to CVE-2026-109618 / CVE-2026-109619 on an unpatched PHP; it does not -; replace updating PHP to 8.2.35, 8.3.36, 8.4.27, 8.5.12 or later. +; replace updating PHP to 8.2.35, 8.3.36, 8.4.27, 8.5.12 or later (or a +; distribution package with the fixes backported). +; Unlike the rest of this file, this one must be set administratively, in php.ini, +; the PHP-FPM pool (php_admin_value) or the server configuration, and every PHP +; worker restarted: .user.ini is applied too late for this case (PHP advisory +; GHSA-6wmf-4rv5-p3rp). session.upload_progress.enabled=0 ; =================================== diff --git a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php index 9af8c931c..f95b16e12 100644 --- a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php +++ b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php @@ -136,15 +136,17 @@ public static function error(ResponseInterface $response, array $table, string $ $meta['reason'] = $coreCode; } $message = self::message($coreCode); + // The loan that stopped a bulk extension is named even when no window can be + // explained (OpenAPI documents meta.loan_id for bulk_extend_conflict). + if (isset($details['loan_id']) && is_numeric($details['loan_id'])) { + $meta['loan_id'] = (int) $details['loan_id']; + } $explained = \App\Support\LoanWindow::explainRenewalFromParams($details); if ($explained !== null) { $message = $explained; $meta['max_extension_days'] = (int) $details['max_extension_days']; $meta['max_due_date'] = $details['max_due_date']; $meta['first_conflict_date'] = $details['blocked_from']; - if (isset($details['loan_id']) && is_numeric($details['loan_id'])) { - $meta['loan_id'] = (int) $details['loan_id']; - } } return ResponseEnvelope::error($response, $code, $message, $status, $meta); diff --git a/tests/desiderata-inventory-prefix-489.spec.js b/tests/desiderata-inventory-prefix-489.spec.js index e2f40caeb..177f98454 100644 --- a/tests/desiderata-inventory-prefix-489.spec.js +++ b/tests/desiderata-inventory-prefix-489.spec.js @@ -127,8 +127,12 @@ test('B — "Libro donato" on the requests list uses the prefix', async ({ page page.on('dialog', (dialog) => dialog.accept()); await page.goto('/admin/desiderata'); const row = page.locator('li').filter({ hasText: book.title }); - await row.getByRole('button', { name: 'Libro donato: registra la copia', exact: true }).click(); - await page.waitForURL(/\/admin\/desiderata/); + // The click starts on /admin/desiderata, so waiting for that URL returns at once: + // wait for the receipt POST itself before reading the copies. + await Promise.all([ + page.waitForResponse((res) => res.request().method() === 'POST' && /\/admin\/desiderata\/books\/\d+\/received/.test(res.url())), + row.getByRole('button', { name: 'Libro donato: registra la copia', exact: true }).click(), + ]); await expectCopyCodes(page, book.id, [`${prefix}-C1`]); }); @@ -157,7 +161,11 @@ test('without a prefix the copy keeps the LIB- default', async ({ page }) => page.on('dialog', (dialog) => dialog.accept()); await page.goto('/admin/desiderata'); const row = page.locator('li').filter({ hasText: book.title }); - await row.getByRole('button', { name: 'Libro donato: registra la copia', exact: true }).click(); - await page.waitForURL(/\/admin\/desiderata/); + // The click starts on /admin/desiderata, so waiting for that URL returns at once: + // wait for the receipt POST itself before reading the copies. + await Promise.all([ + page.waitForResponse((res) => res.request().method() === 'POST' && /\/admin\/desiderata\/books\/\d+\/received/.test(res.url())), + row.getByRole('button', { name: 'Libro donato: registra la copia', exact: true }).click(), + ]); await expectCopyCodes(page, book.id, [`LIB-${book.id}-C1`]); }); diff --git a/tests/desiderata-inventory-prefix-489.unit.php b/tests/desiderata-inventory-prefix-489.unit.php index 55922ce73..00fb86736 100644 --- a/tests/desiderata-inventory-prefix-489.unit.php +++ b/tests/desiderata-inventory-prefix-489.unit.php @@ -167,6 +167,10 @@ public function sendEmail(string $to, string $subject, string $body, string $toN foreach ([null, '', ' '] as $blank) { $check(CopyRepository::inventoryBase(7, $blank) === 'LIB-7', 'no prefix (' . var_export($blank, true) . ') falls back to LIB-{id}'); } + $check(CopyRepository::inventoryBase(7, ['BIB-']) === 'LIB-7', 'a non-scalar prefix falls back to LIB-{id}'); + $check(CopyRepository::inventoryBase(7, new stdClass()) === 'LIB-7', 'an object prefix falls back to LIB-{id}'); + $long = str_repeat('P', 120); + $check(CopyRepository::inventoryBase(7, $long) === $long, 'a long prefix is returned whole; the allocator shortens it, not the rule'); } catch (Throwable $thrown) { $fatalError = $thrown; } finally { diff --git a/tests/loan-request-capped-by-scheduled-loan.spec.js b/tests/loan-request-capped-by-scheduled-loan.spec.js index 904eaf80d..e6281f426 100644 --- a/tests/loan-request-capped-by-scheduled-loan.spec.js +++ b/tests/loan-request-capped-by-scheduled-loan.spec.js @@ -101,7 +101,9 @@ test.describe('Book page: loan request capped by a scheduled loan', () => { dbQuery( "SELECT c.libro_id FROM copie c JOIN libri l ON l.id=c.libro_id " + "WHERE l.deleted_at IS NULL " + - "AND NOT EXISTS (SELECT 1 FROM prestiti p WHERE p.libro_id=c.libro_id AND p.attivo=1) " + + // A pending request with a copy (attivo=0) holds that copy too. + "AND NOT EXISTS (SELECT 1 FROM prestiti p WHERE p.libro_id=c.libro_id " + + "AND (p.attivo=1 OR (p.stato='pendente' AND p.copia_id IS NOT NULL))) " + "AND NOT EXISTS (SELECT 1 FROM prenotazioni r WHERE r.libro_id=c.libro_id AND r.stato='attiva') " + "GROUP BY c.libro_id HAVING COUNT(*)=1 AND SUM(c.stato='disponibile')=1 LIMIT 1", ) || '0', diff --git a/tests/php-security-advisory.unit.php b/tests/php-security-advisory.unit.php index 1dabfe48d..83ae12966 100644 --- a/tests/php-security-advisory.unit.php +++ b/tests/php-security-advisory.unit.php @@ -47,6 +47,10 @@ function check_advisory(bool $ok, string $label): void check_advisory($fixedFor('7.4.33') === '8.2.35', '7.4 is reported'); check_advisory($fixedFor('8.6.0') === null, 'a branch released after the fix is not reported'); check_advisory($fixedFor('8.6.0RC3') === null, 'a release candidate of a newer branch is not reported'); +check_advisory($fixedFor('8.4.27RC1') === '8.4.27', 'a release candidate of the fixed release comes before it and is reported'); +check_advisory($fixedFor('8.4.27-dev') === '8.4.27', 'a development build of the fixed release is reported'); +check_advisory($fixedFor('8.4.28RC1') === null, 'a release candidate after the fixed release is not reported'); +check_advisory($fixedFor('8.2.35rc2') === '8.2.35', 'the suffix is read in any case'); check_advisory($fixedFor('9.0.0') === null, 'a later major is not reported'); check_advisory($fixedFor('not a version') === null, 'an unreadable version is not reported'); check_advisory(PhpSecurityAdvisory::check(PHP_VERSION) === PhpSecurityAdvisory::check(), 'the default argument is the running PHP');