diff --git a/.github/npm-audit-waivers.json b/.github/npm-audit-waivers.json
index 52e87ff82..fe51488c7 100644
--- a/.github/npm-audit-waivers.json
+++ b/.github/npm-audit-waivers.json
@@ -1,8 +1 @@
-[
- {
- "id": "GHSA-vfj7-8cjw-p6xm",
- "package": "braces",
- "expires": "2026-11-03",
- "reason": "Stack exhaustion on deeply nested brace patterns. Every published braces is affected (range <=3.0.3, latest 3.0.3) and only reaches the build through tailwindcss 3 (chokidar, fast-glob, micromatch); npm audit --omit=dev is clean in both trees, so nothing vulnerable ships. npm's only fix is the tailwindcss 4 major. Remove this entry as soon as a fixed braces (or a micromatch/chokidar that drops it) is published."
- }
-]
+[]
diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml
index ea538e7da..46639cfad 100644
--- a/.github/workflows/ci-e2e.yml
+++ b/.github/workflows/ci-e2e.yml
@@ -434,6 +434,7 @@ jobs:
- group: catalogue
specs: >-
tests/attendance-090.spec.js
+ tests/attendance-member-search.spec.js
tests/catalogue-cards-090.spec.js
tests/feature-toggle-catalogue-cards.spec.js
tests/feature-toggles-followups.spec.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c039197a..a6e3d468c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,26 @@
Full version-by-version history for Pinakes. The README shows only the last three releases; everything older lives here.
+## [0.9.0]
+
+First stable release of the 0.9.0 cycle. It contains everything in 0.9.0-rc.1, rc.2 and rc.3 below, plus the changes listed here. Upgrading from 0.8.9 runs two additive migrations, `migrate_0.9.0-rc.1.sql` and `migrate_0.9.0.sql`; upgrading from 0.9.0-rc.3 runs only the second. The optional modules follow the upgrade defaults described under rc.1 (Feature toggles).
+
+### Visitor log
+
+- **The card field also finds a member by name.** A reader who forgot the card can be found by name, surname, e-mail, phone or card, with the borrower search of the loan form. Picking a member records the entry for that member, within the operator's borrower scope as a card is; the operator confirms with "Registra ingresso". Enter picks a suggestion only when one is highlighted with the arrows, so a desk card reader that types the code and sends Enter works as before, also while the list is open.
+
+### Loans and closures: the limits left in rc.3
+
+- **A promoted reservation ends on an open day of the branch of the copy it takes.** With a book's copies in several branches the request's end followed the general calendar, because the copy was not known yet. Once the copy is chosen, a day closed at its branch now moves the end to the branch's next open day, when the book and that copy can take the added days; otherwise the requested end stays, as approval already did.
+- **The "book available" e-mail names the loan's return day.** It read the day from the request again, so an e-mail sent later (a retry after a failed send) could name the requested day while the loan had been moved, or the general day for a copy in a closed branch. It now reads the loan the promotion created.
+- **A refused move re-arms the due-soon reminder once.** When a closure makes the return day later but the move is refused (another reader holds the next days), the stored date stays and the reader's return day changes. A reminder already sent for the old day is now sent again for the new one, and only once: `prestiti.warning_sent_for` (new, `migrate_0.9.0.sql`) keeps the day the reminder announced. Loans warned before the upgrade count as warned for their stored date.
+- **A loan whose copy is replaced during a calendar save is still moved.** The save looked only at the branch of the copy found by its first scan and skipped the loan when that branch was open, even if the copy was being replaced by one in a branch closed that day. Every branch holding a copy of the book is now considered before the lock, and the decision is taken on the current row under the lock.
+- **The history of a calendar move records the loan as it was.** The "before" state of `loan.due_moved` and of a late flag cleared by the calendar is read with a current read, so a desk edit committed while the save was running is not shown as changed by the calendar.
+
+### Maintenance
+
+- **The npm audit waiver for `braces` is gone.** The advisory no longer reports against the dependency tree (`npm audit` is clean in both trees), so `.github/npm-audit-waivers.json` is empty again.
+
## [0.9.0-rc.3]
Release candidate: published as a GitHub prerelease, offered only to installations that opt in with `UPDATER_CHANNEL=rc` or `UPDATER_ALLOW_PRERELEASE=1`. No migration and no schema change.
diff --git a/README.md b/README.md
index f5b0d8f8b..17ff6534f 100644
--- a/README.md
+++ b/README.md
@@ -25,9 +25,9 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri
[](https://fabiodalez-dev.github.io/Pinakes/)
[](https://buymeacoffee.com/fabiodalez)
-## What's New in v0.9.0-rc.3
+## What's New in v0.9.0
-**Release candidate. Loans follow closures added later, and the admin works on a phone.** Saving a closure, a weekly closed day or a holiday now moves the active loans that fall on a newly closed day to the next open day, with their pickup deadlines, history and due-soon reminder; a loan due on a day closed afterwards is not late until the first open day after it, and the overdue sweep, e-mail, push and fine all count open days only. Every 0.9.0 page and the older book list, book page, book form, updates and archival pages were checked at 390 px: nothing scrolls sideways, wide tables read as cards and long values wrap. No migration. Full details in **[CHANGELOG.md](CHANGELOG.md)**.
+**The tools of a full library system, each one optional.** The first stable release of the 0.9.0 cycle, after three release candidates: inventory stocktake with barcode, camera or list scanning; MARC 21, UNIMARC and danMARC2 import and export with copy cataloguing over SRU; patron types with a rules matrix by material, a closures calendar with the public holidays of every country, and optional fines with a patron ledger and a debt block; printable library cards, patron photos and user import from CSV; a visitor log that also finds a member by name, ISBD catalogue cards, suppliers and RDA types. Due dates follow closures added after the loan was made, and a loan is late only from the first open day after its due date. Every module has its own switch in Settings → Modules; an upgrade turns on only what is reached from an explicit action, or what the library already uses. The Mobile API (1.10.0) opens all of it to the Android app, and every 0.9.0 page works at phone width. Two additive migrations (`migrate_0.9.0-rc.1.sql`, `migrate_0.9.0.sql`), both run when upgrading from 0.8.9. Full details in **[CHANGELOG.md](CHANGELOG.md)**.
---
@@ -53,13 +53,13 @@ The showcase contains 50 catalogue books, 8 desiderata, 3 periodicals with 10 is
Highlights of the two releases before the current one are below; the README never lists more than the last three versions. The full version-by-version history lives in **[CHANGELOG.md](CHANGELOG.md)**.
-### v0.9.0-rc.2
+### v0.8.9
-**Everything the 0.9.0 admin does, ready for the Android app.** The Mobile API plugin (1.10.0) opens every new 0.9.0 tool to the staff app: stocktake with the phone as scanner, the patron ledger at the desk, library settings, patron types, the rules matrix, the closures calendar, suppliers, catalogue cards, MARC import and export, copy cataloguing, the visitor log, statistics and patron import, each through the same controllers as the website. No migration. Full details in **[CHANGELOG.md](CHANGELOG.md)**.
+**Search Console verification, a pickup reminder, and an SEO pass.** Settings → Advanced has a field for the `` verification tags of Google, Bing and the other search engines ([#497](https://github.com/fabiodalez-dev/Pinakes/pull/497)). Readers get a reminder before the pickup deadline passes, and the staff is told when a pickup expires ([#498](https://github.com/fabiodalez-dev/Pinakes/pull/498)). One additive migration (`migrate_0.8.9.sql`). Full details in **[CHANGELOG.md](CHANGELOG.md)**.
-### v0.9.0-rc.1
+### v0.8.8
-**Stocktake, MARC in and out, and the circulation tools of a full library system.** Inventory stocktake sessions with barcode, camera or list scanning and a live report; MARC 21, UNIMARC and danMARC2 bulk import and export with copy cataloguing over SRU; a visitor log, ISBD catalogue cards, suppliers and RDA types; printable library cards, patron photos and user import from CSV; patron types with a rules matrix, a closures calendar and optional fines with a patron ledger. Every module is optional and starts switched off on an upgrade. One additive migration (`migrate_0.9.0-rc.1.sql`). Full details in **[CHANGELOG.md](CHANGELOG.md)**.
+**A French sentence that now includes the last day of a loan.** "retour avant le D" left out the return day the server still accepts; it now says "retour au plus tard le D" ([#490](https://github.com/fabiodalez-dev/Pinakes/pull/490)). No migration.
Older releases are in **[CHANGELOG.md](CHANGELOG.md)**.
diff --git a/app/Controllers/AttendanceController.php b/app/Controllers/AttendanceController.php
index 7f184a0e0..79f2c1710 100644
--- a/app/Controllers/AttendanceController.php
+++ b/app/Controllers/AttendanceController.php
@@ -60,10 +60,15 @@ public function scan(Request $request, Response $response, mysqli $db): Response
$body = (array) $request->getParsedBody();
$repo = new AttendanceRepository($db);
$card = is_string($body['codice_tessera'] ?? null) ? $body['codice_tessera'] : '';
+ // A member picked from the name search arrives as utente_id, and the
+ // field then holds the label, not a code: the id wins over the card.
+ $pickedId = filter_var($body['utente_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
try {
- $member = $repo->findMemberByCard($card);
+ $member = $pickedId !== false ? $repo->findMemberById($pickedId) : $repo->findMemberByCard($card);
if ($member === null) {
- $_SESSION['error_message'] = __('Tessera non riconosciuta. Controlla il codice o registra la visita come ospite.');
+ $_SESSION['error_message'] = $pickedId !== false
+ ? __('Utente non trovato')
+ : __('Tessera non riconosciuta. Controlla il codice, scegli il socio dai suggerimenti o registra la visita come ospite.');
return $this->back($response);
}
$repo->recordMember($member['id'], $this->rememberBranch($repo, $body));
diff --git a/app/Controllers/ReservationManager.php b/app/Controllers/ReservationManager.php
index 9ec16b615..8ef1be0bf 100644
--- a/app/Controllers/ReservationManager.php
+++ b/app/Controllers/ReservationManager.php
@@ -340,6 +340,8 @@ public function processBookAvailability($bookId)
$this->rollbackIfOwned($ownTransaction);
return false;
}
+ // The e-mail states this loan's day, not a recomputed one.
+ $nextReservation['prestito_id'] = (int) $loanCreated;
// BUG9/D4 double-subtraction fix: the reservation is claimed
// 'completata' BEFORE createLoanFromReservation() recalcs, so
@@ -456,7 +458,7 @@ private function isDateRangeAvailable($bookId, $startDate, $endDate, ?int $exclu
* and creates a loan with state 'pendente' and origin 'prenotazione'.
* The loan requires admin confirmation of physical book pickup.
*
- * @param array{libro_id: int, utente_id: int, data_inizio_richiesta: string, data_fine_richiesta: string} $reservation Reservation data
+ * @param array{libro_id: int, utente_id: int, data_inizio_richiesta: string, data_fine_richiesta: string, queue_position?: int|string|null} $reservation Reservation data
* @return int|false Loan ID on success, false on failure (no copies available or race condition)
*/
private function createLoanFromReservation($reservation)
@@ -603,6 +605,44 @@ private function createLoanFromReservation($reservation)
return false;
}
+ // The copy is known only now. With the book's copies in several
+ // branches the end above follows the general calendar; a day
+ // closed at this copy's branch moves to the branch's next open day
+ // when the book and this copy can take the added days, as approval
+ // does (LoanApprovalController). Otherwise the date stays.
+ $branchDue = \App\Services\CirculationPolicy::dueDateFor((string) $endDate, 0, $this->db, $copyId);
+ if ($branchDue !== $endDate) {
+ $queuePos = isset($reservation['queue_position']) ? (int) $reservation['queue_position'] : null;
+ $fits = (new \App\Services\CapacityService($this->db, lockingReads: true))->hasFreeCapacity(
+ $bookId,
+ (string) $startDate,
+ $branchDue,
+ excludeReservationsAfterQueuePos: $queuePos
+ );
+ if ($fits) {
+ $extraStart = \App\Support\LibraryCalendar::shift((string) $endDate, 1);
+ $extraStmt = $this->db->prepare("
+ SELECT 1 FROM prestiti
+ WHERE copia_id = ?
+ AND data_prestito <= ?
+ AND (stato = 'in_ritardo' OR (stato = 'in_corso' AND data_scadenza < ?) OR data_scadenza >= ?)
+ AND (
+ (attivo = 1 AND stato IN ('in_corso','da_ritirare','prenotato','in_ritardo'))
+ OR (stato = 'pendente' AND copia_id IS NOT NULL)
+ )
+ LIMIT 1
+ FOR UPDATE
+ ");
+ $extraStmt->bind_param('isss', $copyId, $branchDue, $today, $extraStart);
+ $extraStmt->execute();
+ $extraBusy = (bool) $extraStmt->get_result()->fetch_row();
+ $extraStmt->close();
+ if (!$extraBusy) {
+ $endDate = $branchDue;
+ }
+ }
+ }
+
// Create loan with copia_id and origine='prenotazione'
// Copy stays available until admin confirms physical pickup
$stmt = $this->db->prepare("
@@ -798,8 +838,7 @@ private function sendReservationNotification(array $reservation): bool
'libro_autore' => $book['autore'] ?: $notificationService->translateInLocale('Autore non specificato', $recipientLocale),
'libro_isbn' => $book['isbn'] ?: 'N/A',
'data_inizio' => $notificationService->formatEmailDate((string) $reservation['data_inizio_richiesta'], false, $recipientLocale),
- // The day createLoanFromReservation() stores: a closed end day moves to the next open day.
- 'data_fine' => $notificationService->formatEmailDate(\App\Support\LibraryCalendar::requestDueDate($this->db, (string) $reservation['data_fine_richiesta'], 0, (int) $reservation['libro_id']), false, $recipientLocale),
+ 'data_fine' => $notificationService->formatEmailDate($this->noticeDueDate($reservation), false, $recipientLocale),
'book_url' => absoluteUrl($bookLink),
'profile_url' => absoluteUrl(RouteTranslator::route('profile'))
];
@@ -840,6 +879,79 @@ private function sendReservationNotification(array $reservation): bool
}
}
+ /**
+ * The return day the e-mail states: the usable day of the loan the
+ * promotion created (its branch, a later calendar move or a refused one
+ * included), read from that loan rather than recomputed from the
+ * request. The promotion passes the loan id; the retry sweep finds the
+ * loan by book, reader, origin and creation time. Without a loan, the day the
+ * request would get.
+ *
+ * @param array $reservation
+ */
+ private function noticeDueDate(array $reservation): string
+ {
+ $bookId = (int) $reservation['libro_id'];
+ $loanId = (int) ($reservation['prestito_id'] ?? 0);
+ $loan = null;
+ if ($loanId > 0) {
+ $stmt = $this->db->prepare("SELECT data_scadenza, copia_id FROM prestiti WHERE id = ? AND libro_id = ?");
+ $stmt->bind_param('ii', $loanId, $bookId);
+ } else {
+ // The retry sweep: the promotion created the loan after the
+ // reservation was made, in the transaction that completed it, so
+ // the loan is the first one this reader got from a reservation of
+ // this book since the reservation's creation. created_at never
+ // moves; updated_at only moves forward (each failed send reverts
+ // the claim and touches it). The reservation is completed before
+ // the loan is inserted, after the copy lock and the capacity
+ // reads, so the loan can be a few seconds (a lock wait) newer
+ // than the completion: the upper bound keeps ten minutes of
+ // slack, harmless since the earliest loan wins. Staff may have
+ // changed the loan's dates before the retry, so dates play no
+ // part in the lookup.
+ $userId = (int) $reservation['utente_id'];
+ $madeAt = (string) ($reservation['created_at'] ?? '');
+ $completedAt = (string) ($reservation['updated_at'] ?? '');
+ if ($completedAt === '') {
+ $completedAt = \App\Support\DateHelper::now();
+ }
+ if ($madeAt === '') {
+ $madeAt = $completedAt;
+ $stmt = $this->db->prepare("
+ SELECT data_scadenza, copia_id FROM prestiti
+ WHERE libro_id = ? AND utente_id = ? AND origine = 'prenotazione'
+ AND data_scadenza IS NOT NULL
+ AND created_at BETWEEN ? - INTERVAL 8 DAY AND ? + INTERVAL 10 MINUTE
+ ORDER BY created_at DESC, id DESC
+ LIMIT 1
+ ");
+ } else {
+ $stmt = $this->db->prepare("
+ SELECT data_scadenza, copia_id FROM prestiti
+ WHERE libro_id = ? AND utente_id = ? AND origine = 'prenotazione'
+ AND data_scadenza IS NOT NULL
+ AND created_at BETWEEN ? AND ? + INTERVAL 10 MINUTE
+ ORDER BY created_at, id
+ LIMIT 1
+ ");
+ }
+ if ($stmt !== false) {
+ $stmt->bind_param('iiss', $bookId, $userId, $madeAt, $completedAt);
+ }
+ }
+ if ($stmt !== false) {
+ $stmt->execute();
+ $loan = $stmt->get_result()->fetch_assoc();
+ $stmt->close();
+ }
+ if (is_array($loan) && !empty($loan['data_scadenza'])) {
+ $stored = substr((string) $loan['data_scadenza'], 0, 10);
+ return \App\Support\LibraryCalendar::dueDate($this->db, $stored, $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null);
+ }
+ return \App\Support\LibraryCalendar::requestDueDate($this->db, (string) $reservation['data_fine_richiesta'], 0, $bookId);
+ }
+
/**
* Ritenta l'invio delle email 'reservation_book_available' fallite (M4).
*
diff --git a/app/Models/AttendanceRepository.php b/app/Models/AttendanceRepository.php
index 9c55226a0..5939596b0 100644
--- a/app/Models/AttendanceRepository.php
+++ b/app/Models/AttendanceRepository.php
@@ -57,9 +57,35 @@ public function findMemberByCard(string $card): ?array
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
- if (!$row) {
+ return $row ? self::member($row) : null;
+ }
+
+ /**
+ * The member picked by name in the desk search, for a reader who forgot
+ * the card. Same reach as findMemberByCard().
+ *
+ * @return array{id:int, nome:string, cognome:string, stato:string}|null
+ */
+ public function findMemberById(int $userId): ?array
+ {
+ if ($userId <= 0) {
return null;
}
+ $scope = Permissions::accountScopeSql('', Permissions::SCOPE_BORROWER);
+ $stmt = $this->db->prepare('SELECT id, nome, cognome, stato FROM utenti WHERE id = ?' . $scope . ' LIMIT 1');
+ $stmt->bind_param('i', $userId);
+ $stmt->execute();
+ $row = $stmt->get_result()->fetch_assoc();
+ $stmt->close();
+ return $row ? self::member($row) : null;
+ }
+
+ /**
+ * @param array $row
+ * @return array{id:int, nome:string, cognome:string, stato:string}
+ */
+ private static function member(array $row): array
+ {
return [
'id' => (int) $row['id'],
'nome' => (string) $row['nome'],
diff --git a/app/Support/ActivityLog.php b/app/Support/ActivityLog.php
index ecbaca4e3..947d368ed 100644
--- a/app/Support/ActivityLog.php
+++ b/app/Support/ActivityLog.php
@@ -552,15 +552,19 @@ public static function loadBookSnapshot(mysqli $db, int $bookId): array
}
/** @return array */
- public static function loadLoanSnapshot(mysqli $db, int $loanId): array
+ public static function loadLoanSnapshot(mysqli $db, int $loanId, bool $lockingRead = false): array
{
try {
+ // $lockingRead: a current read for a caller that already holds the
+ // loan row and its book row locked inside a transaction, where a
+ // plain read can return the transaction's older snapshot.
// CI-SOFT-DELETE-EXEMPT: an audit event must retain the title of an existing loan even after its book is archived.
$stmt = $db->prepare(
'SELECT p.id, p.libro_id, p.copia_id, p.utente_id, p.data_prestito, '
. 'p.data_scadenza, p.data_restituzione, p.stato, p.origine, p.renewals, '
. 'p.note, p.attivo, p.pickup_deadline, p.sanzione, l.titolo AS book_title '
. 'FROM prestiti p LEFT JOIN libri l ON l.id = p.libro_id WHERE p.id = ? LIMIT 1'
+ . ($lockingRead ? ' LOCK IN SHARE MODE' : '')
);
if ($stmt === false) {
throw new \RuntimeException($db->error);
diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php
index 45baba744..3e49b7f44 100644
--- a/app/Support/LibraryCalendar.php
+++ b/app/Support/LibraryCalendar.php
@@ -154,6 +154,29 @@ public static function forDb(\mysqli $db): self
public static function reset(): void
{
self::$instances = [];
+ self::$warningForColumn = [];
+ }
+
+ /** @var array spl_object_id(db) => prestiti.warning_sent_for exists */
+ private static array $warningForColumn = [];
+
+ /**
+ * Whether prestiti.warning_sent_for exists (migrate_0.9.0.sql, also added
+ * by NotificationService::addNotificationColumns()). Without it the
+ * due-soon reminder works as before: one flag, no day remembered.
+ */
+ public static function hasWarningSentFor(\mysqli $db): bool
+ {
+ $key = spl_object_id($db);
+ if (!isset(self::$warningForColumn[$key])) {
+ try {
+ $r = $db->query("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'prestiti' AND COLUMN_NAME = 'warning_sent_for'");
+ self::$warningForColumn[$key] = $r instanceof \mysqli_result && (int) $r->fetch_row()[0] > 0;
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+ return self::$warningForColumn[$key];
}
// ── Funnels used by the loan code ───────────────────────────────────────
@@ -313,11 +336,15 @@ public static function extendDueDates(\mysqli $db, ?int $operatorId = null): int
{
$calendar = self::forDb($db);
if (!$calendar->hasRules()) {
+ // The last closure was removed: no date moves, but a reminder
+ // that announced a later usable day must announce the stored one.
+ self::rearmChangedReminders($db);
return 0;
}
$today = DateHelper::today();
$stmt = $db->prepare(
- "SELECT p.id, p.libro_id, p.data_scadenza, c.sede_id
+ "SELECT p.id, p.libro_id, p.data_scadenza, c.sede_id,
+ (SELECT GROUP_CONCAT(DISTINCT c2.sede_id) FROM copie c2 WHERE c2.libro_id = p.libro_id) AS book_branches
FROM prestiti p
LEFT JOIN copie c ON c.id = p.copia_id
WHERE p.attivo = 1 AND p.stato IN ('in_corso', 'da_ritirare', 'prenotato')
@@ -332,12 +359,33 @@ public static function extendDueDates(\mysqli $db, ?int $operatorId = null): int
$stmt->close();
$capacity = new \App\Services\CapacityService($db, lockingReads: true);
+ $withWarningDay = self::hasWarningSentFor($db);
$moved = 0;
$notMoved = 0;
foreach ($rows as $row) {
$due = substr((string) $row['data_scadenza'], 0, 10);
- $next = $calendar->nextOpenDay($due, $row['sede_id'] !== null ? (int) $row['sede_id'] : null);
- if (!DateHelper::isISODateFormat($due) || $next <= $due) {
+ if (!DateHelper::isISODateFormat($due)) {
+ continue;
+ }
+ // Pre-filter on the scan, before any lock: a loan is a candidate
+ // when its due day is closed at its copy's branch or at any other
+ // branch holding a copy of the book, since the copy can be
+ // replaced by one of those while this save waits for the lock.
+ // The decision below is taken on the current row under the lock.
+ $branches = [$row['sede_id'] !== null ? (int) $row['sede_id'] : null];
+ foreach (explode(',', (string) ($row['book_branches'] ?? '')) as $b) {
+ if (ctype_digit(trim($b))) {
+ $branches[] = (int) trim($b);
+ }
+ }
+ $candidate = false;
+ foreach (array_unique($branches, SORT_REGULAR) as $branch) {
+ if ($calendar->nextOpenDay($due, $branch) > $due) {
+ $candidate = true;
+ break;
+ }
+ }
+ if (!$candidate) {
continue;
}
$id = (int) $row['id'];
@@ -392,12 +440,29 @@ public static function extendDueDates(\mysqli $db, ?int $operatorId = null): int
// snapshot. Locking capacity reads see the operator who held
// this book lock before us, including copyless reservations.
if ($capacity->hasFreeCapacity($bookId, self::shift($due, 1), $next, excludePrestitoId: $id)) {
- $before = ActivityLog::loadLoanSnapshot($db, $id);
- $update = $db->prepare(
- "UPDATE prestiti SET data_scadenza = ?, warning_sent = 0
- WHERE id = ? AND attivo = 1 AND data_scadenza = ? AND stato IN ('in_corso', 'da_ritirare', 'prenotato')"
- );
- $update->bind_param('sis', $next, $id, $due);
+ // Current read: the row is locked, but a plain read in the
+ // calendar-save transaction can return its older snapshot.
+ $before = ActivityLog::loadLoanSnapshot($db, $id, lockingRead: true);
+ if ($withWarningDay) {
+ // A reminder that already announced $next (sent after
+ // an earlier refused move) stays sent: the reader was
+ // told this day. Any other reminder is re-armed.
+ // warning_sent is assigned first: it reads the old
+ // warning_sent_for.
+ $update = $db->prepare(
+ "UPDATE prestiti SET data_scadenza = ?,
+ warning_sent = IF(warning_sent_for <=> ?, warning_sent, 0),
+ warning_sent_for = IF(warning_sent_for <=> ?, warning_sent_for, NULL)
+ WHERE id = ? AND attivo = 1 AND data_scadenza = ? AND stato IN ('in_corso', 'da_ritirare', 'prenotato')"
+ );
+ $update->bind_param('sssis', $next, $next, $next, $id, $due);
+ } else {
+ $update = $db->prepare(
+ "UPDATE prestiti SET data_scadenza = ?, warning_sent = 0
+ WHERE id = ? AND attivo = 1 AND data_scadenza = ? AND stato IN ('in_corso', 'da_ritirare', 'prenotato')"
+ );
+ $update->bind_param('sis', $next, $id, $due);
+ }
$update->execute();
$changed = $update->affected_rows > 0;
$update->close();
@@ -405,6 +470,9 @@ public static function extendDueDates(\mysqli $db, ?int $operatorId = null): int
ActivityLog::recordLoanEvent($db, $id, 'loan.due_moved', $before, 'aggiornamento', 'calendar', $operatorId);
}
}
+ // A refused move (capacity here, the overlap trigger below)
+ // keeps the stored date: rearmChangedReminders() tells the
+ // reader the new usable day.
if ($ownTransaction) {
$db->commit();
}
@@ -449,6 +517,7 @@ public static function extendDueDates(\mysqli $db, ?int $operatorId = null): int
}
self::reconcileOverdue($db, $today, $operatorId);
+ self::rearmChangedReminders($db);
if ($notMoved > 0) {
// Loans already moved are not touched again by a retry (their due
// date is open now), so a second run only finishes these.
@@ -504,7 +573,7 @@ private static function reconcileOverdue(\mysqli $db, string $today, ?int $opera
$id = (int) $row['id'];
$due = (string) $row['data_scadenza'];
self::withBookLock($db, (int) $row['libro_id'], static function () use ($db, $id, $due, $operatorId): void {
- $before = ActivityLog::loadLoanSnapshot($db, $id);
+ $before = ActivityLog::loadLoanSnapshot($db, $id, lockingRead: true);
$update = $db->prepare("UPDATE prestiti SET stato = 'in_corso' WHERE id = ? AND attivo = 1 AND stato = 'in_ritardo' AND data_scadenza = ?");
$update->bind_param('is', $id, $due);
$update->execute();
@@ -517,6 +586,53 @@ private static function reconcileOverdue(\mysqli $db, string $today, ?int $opera
}
}
+ /**
+ * A due-soon reminder already sent announces a day; when the usable day
+ * of that loan is now a different one, the reminder is re-armed so the
+ * reader is told the new day once. That covers a move refused by
+ * capacity or by the overlap trigger (the usable day goes later) and a
+ * closure removed after such a refusal (it goes back to the stored day).
+ * A row warned before warning_sent_for existed is taken to announce its
+ * stored date. The flags are cleared only if they still name the day read
+ * here, so a reminder claimed meanwhile for the new day is kept.
+ */
+ private static function rearmChangedReminders(\mysqli $db): void
+ {
+ if (!self::hasWarningSentFor($db)) {
+ return;
+ }
+ $stmt = $db->prepare(
+ "SELECT id, libro_id, data_scadenza, copia_id, warning_sent_for FROM prestiti
+ WHERE attivo = 1 AND stato IN ('in_corso', 'in_ritardo') AND warning_sent = 1
+ AND data_scadenza IS NOT NULL"
+ );
+ if ($stmt === false) {
+ return;
+ }
+ $stmt->execute();
+ $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
+ $stmt->close();
+ self::primeCopyBranches($db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $rows));
+ foreach ($rows as $row) {
+ $due = substr((string) $row['data_scadenza'], 0, 10);
+ $announced = $row['warning_sent_for'] !== null ? substr((string) $row['warning_sent_for'], 0, 10) : null;
+ $usable = self::dueDate($db, $due, $row['copia_id'] !== null ? (int) $row['copia_id'] : null);
+ if ($usable === ($announced ?? $due)) {
+ continue;
+ }
+ $id = (int) $row['id'];
+ self::withBookLock($db, (int) $row['libro_id'], static function () use ($db, $id, $due, $announced): void {
+ $rearm = $db->prepare(
+ "UPDATE prestiti SET warning_sent = 0, warning_sent_for = NULL
+ WHERE id = ? AND attivo = 1 AND data_scadenza = ? AND warning_sent = 1 AND warning_sent_for <=> ?"
+ );
+ $rearm->bind_param('iss', $id, $due, $announced);
+ $rearm->execute();
+ $rearm->close();
+ });
+ }
+ }
+
/**
* Runs a change to a loan with its book row locked first, the order desk
* operations use. A caller's transaction keeps the lock until it commits;
diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php
index d5ee2c87c..23fda00a0 100644
--- a/app/Support/NotificationService.php
+++ b/app/Support/NotificationService.php
@@ -682,8 +682,16 @@ public function sendLoanExpirationWarnings(): int {
}
$loan['effective_due_at'] = $effectiveDue;
$loan['giorni_rimasti'] = (int) (new \DateTimeImmutable($today))->diff(new \DateTimeImmutable($effectiveDue))->days;
- $updateStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 1 WHERE id = ? AND utente_id = ? AND copia_id <=> ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo') AND data_scadenza = ? AND (warning_sent IS NULL OR warning_sent = 0)");
- $updateStmt->bind_param('iiis', $loan['id'], $loan['utente_id'], $copyId, $loan['data_scadenza']);
+ // warning_sent_for keeps the day this e-mail announces, so a
+ // calendar save that changes the usable day without moving
+ // the stored date re-arms it (LibraryCalendar::extendDueDates()).
+ if (LibraryCalendar::hasWarningSentFor($this->db)) {
+ $updateStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 1, warning_sent_for = ? WHERE id = ? AND utente_id = ? AND copia_id <=> ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo') AND data_scadenza = ? AND (warning_sent IS NULL OR warning_sent = 0)");
+ $updateStmt->bind_param('siiis', $effectiveDue, $loan['id'], $loan['utente_id'], $copyId, $loan['data_scadenza']);
+ } else {
+ $updateStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 1 WHERE id = ? AND utente_id = ? AND copia_id <=> ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo') AND data_scadenza = ? AND (warning_sent IS NULL OR warning_sent = 0)");
+ $updateStmt->bind_param('iiis', $loan['id'], $loan['utente_id'], $copyId, $loan['data_scadenza']);
+ }
$updateStmt->execute();
$claimed = $updateStmt->affected_rows === 1;
$updateStmt->close();
@@ -740,8 +748,16 @@ public function sendLoanExpirationWarnings(): int {
$sentCount++;
} else {
// Email failed after retries, revert the flag so it can be retried next run
- $revertStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 0 WHERE id = ? AND data_scadenza = ? AND utente_id = ? AND copia_id <=> ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')");
- $revertStmt->bind_param('isii', $loan['id'], $loan['data_scadenza'], $loan['utente_id'], $copyId);
+ // Only this claim: a calendar save may have re-armed the
+ // reminder meanwhile and another run claimed it for a new
+ // day; reverting that one would send it twice.
+ if (LibraryCalendar::hasWarningSentFor($this->db)) {
+ $revertStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 0, warning_sent_for = NULL WHERE id = ? AND data_scadenza = ? AND utente_id = ? AND copia_id <=> ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo') AND warning_sent = 1 AND warning_sent_for <=> ?");
+ $revertStmt->bind_param('isiis', $loan['id'], $loan['data_scadenza'], $loan['utente_id'], $copyId, $loan['effective_due_at']);
+ } else {
+ $revertStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 0 WHERE id = ? AND data_scadenza = ? AND utente_id = ? AND copia_id <=> ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')");
+ $revertStmt->bind_param('isii', $loan['id'], $loan['data_scadenza'], $loan['utente_id'], $copyId);
+ }
$revertStmt->execute();
$revertStmt->close();
SecureLogger::warning("Failed to send expiration warning for loan {$loan['id']} after retries, flag reverted");
@@ -1896,6 +1912,20 @@ public function addNotificationColumns(): bool {
$this->db->query("ALTER TABLE prestiti ADD COLUMN pickup_reminder_sent_for DATE NULL DEFAULT NULL");
}
+ // Due-soon reminder: the usable day it announced (migrate_0.9.0.sql).
+ // Optional: without it the reminders work as before 0.9.0
+ // (LibraryCalendar::hasWarningSentFor()), so an account that
+ // cannot ALTER must not stop the pickup and other reminders.
+ try {
+ $result = $this->db->query("SHOW COLUMNS FROM prestiti LIKE 'warning_sent_for'");
+ if ($result !== false && $result->num_rows === 0) {
+ $this->db->query("ALTER TABLE prestiti ADD COLUMN warning_sent_for DATE NULL DEFAULT NULL");
+ LibraryCalendar::reset();
+ }
+ } catch (\Throwable $e) {
+ SecureLogger::warning('prestiti.warning_sent_for not added: ' . $e->getMessage());
+ }
+
if (!$pickupSchemaReady) {
return false;
}
diff --git a/app/Views/attendance/index.php b/app/Views/attendance/index.php
index cecde403d..7068d8401 100644
--- a/app/Views/attendance/index.php
+++ b/app/Views/attendance/index.php
@@ -85,9 +85,14 @@