Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions .github/npm-audit-waivers.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
[]
1 change: 1 addition & 0 deletions .github/workflows/ci-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri
[![Documentation](https://img.shields.io/badge/Documentazione-Docsify-4285f4?style=for-the-badge&logo=readthedocs&logoColor=white)](https://fabiodalez-dev.github.io/Pinakes/)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/fabiodalez)

## What's New in v0.9.0-rc.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)**.

---

Expand All @@ -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 `<meta>` 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)**.

Expand Down
9 changes: 7 additions & 2 deletions app/Controllers/AttendanceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
118 changes: 115 additions & 3 deletions app/Controllers/ReservationManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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("
Expand Down Expand Up @@ -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'))
];
Expand Down Expand Up @@ -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<string, mixed> $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).
*
Expand Down
Loading