Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
a0b75f4
feat(copies): manage physical copies from the book summary
fabiodalez-dev Aug 14, 2026
96876f5
fix(copies): harden copy management from the book summary
fabiodalez-dev Aug 14, 2026
9d83ed6
fix(copies): address self-review findings on copy management
fabiodalez-dev Aug 14, 2026
4004ab1
fix(books): reject a non-integer copie_totali before casting (store)
fabiodalez-dev Aug 14, 2026
936187c
test(email): raise Mailpit wait to 30s to stop the deep-regression flake
fabiodalez-dev Aug 14, 2026
f5a90ac
fix(books): make book + initial copies creation atomic (store)
fabiodalez-dev Aug 14, 2026
ef7045b
fix(copies): make inventory mutations fully atomic
fabiodalez-dev Aug 14, 2026
4a2b4f0
fix(copies): close final review findings
fabiodalez-dev Aug 14, 2026
f9b4f15
fix(loans): align reservations with physical copies
fabiodalez-dev Aug 14, 2026
343a3a1
fix(copies): harden final persistence edge cases
fabiodalez-dev Aug 14, 2026
d2733e7
test(ci): stabilize cross-browser accessibility audit
fabiodalez-dev Aug 14, 2026
0438de4
test(ci): reuse admin session in browser audit
fabiodalez-dev Aug 14, 2026
fa73643
test(ci): audit stable accessibility styles
fabiodalez-dev Aug 14, 2026
a4397a0
test(ci): wait for accessibility animations
fabiodalez-dev Aug 14, 2026
e1fd9db
test(ci): settle animations before axe scan
fabiodalez-dev Aug 14, 2026
9a27233
test(loans): align coverage guard with 64 cases
fabiodalez-dev Aug 14, 2026
ed89ad1
test(ci): avoid asynchronous Mailpit purge races
fabiodalez-dev Aug 14, 2026
795aecd
fix(copies): keep admin routes literal (M1) and make increase-copies …
fabiodalez-dev Aug 15, 2026
bc4d4dd
fix(loans/copies): close remaining review findings
fabiodalez-dev Aug 15, 2026
401bacf
test(regression): E2E coverage for the v0.7.59 fixes #333/#336/#338
fabiodalez-dev Aug 15, 2026
6b82c9e
fix(copies): preserve circulation invariants
fabiodalez-dev Aug 15, 2026
15837b4
fix(i18n): translate the copy-status reservation guard messages
fabiodalez-dev Aug 15, 2026
8b437ab
chore(release): 0.7.61-rc.1
fabiodalez-dev Aug 15, 2026
1e15724
fix: apply CodeRabbit review findings from the 0.7.61-rc.1 PR
fabiodalez-dev Aug 15, 2026
49bf168
chore(release): 0.7.61-rc.2
fabiodalez-dev Aug 15, 2026
7f99abc
test(ci): poll for the created book id in the #173 cover suite
fabiodalez-dev Aug 15, 2026
3206659
chore(release): 0.7.61
fabiodalez-dev Aug 15, 2026
13deeb8
fix(migration): backfill per-copy rows for legacy books on 0.7.61 upg…
fabiodalez-dev Aug 15, 2026
b2ba4e2
fix(circulation): enforce physical-copy availability invariants
fabiodalez-dev Aug 15, 2026
37a6b82
test(circulation): assert derived book availability contract
fabiodalez-dev Aug 15, 2026
a0763ea
fix(migration): preserve legacy database compatibility
fabiodalez-dev Aug 15, 2026
d8f1759
feat: harden physical copy batches and global locale defaults
fabiodalez-dev Aug 16, 2026
0364b80
fix: restore per-user locale preferences
fabiodalez-dev Aug 16, 2026
f6019f2
fix(release): harden 0.7.61 backfill migration and per-user locale pr…
fabiodalez-dev Aug 16, 2026
7165929
fix(migration): scope Pass C copy ranking to books with unbound legac…
fabiodalez-dev Aug 16, 2026
9f7548b
docs(release): add v0.7.61 What's New section to README
fabiodalez-dev Aug 16, 2026
1e50b24
fix(review): bound copy-lock name and stop the default-language chang…
fabiodalez-dev Aug 16, 2026
3c5556c
test(loans): make the post-migration availability guard fail-closed
fabiodalez-dev Aug 16, 2026
a9b9b77
fix(locks): bound the contributor-backfill and book-club advisory-loc…
fabiodalez-dev Aug 16, 2026
d8eb559
fix(locks): unify advisory lock protocols
fabiodalez-dev Aug 16, 2026
2b2cc97
test(e2e): harden temporary trigger cleanup
fabiodalez-dev Aug 16, 2026
08b65bc
chore(release): 0.7.61-rc.3
fabiodalez-dev Aug 16, 2026
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
59 changes: 59 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,65 @@

Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here.

## [0.7.61-rc.3]

Release candidate for 0.7.61 — see the [0.7.61] notes below. Cut for
verification on the reference install before the stable release.

## [0.7.61]

Physical-copy management from the book summary, with the whole holding and
circulation lifecycle made atomic and derived from the copies.

### Features

- The book page (`/admin/books/{id}`) now shows a **Copie Fisiche** section for
every book — even one with no copies — with an "Aggiungi copia" modal, per-copy
status editing, and per-copy delete. Copy status covers the physical states
(available, maintenance, under restoration, in transfer, lost, damaged); an
out-of-circulation copy lowers the derived total on its own.
- A book can be created with zero physical copies and have them added later from
the summary. On the edit form the copy count is read-only and delegates to the
per-copy management, so availability is always derived from the copies.

### Fixes

- Book creation is now atomic: the book row and its initial copies are committed
together, so a copy-creation failure can no longer leave an orphan book with no
holdings. The bulk `increase-copies` endpoint is likewise transactional,
allocates collision-free inventory codes, promotes the wait-list, and validates
its input.
- Adding an available copy repairs blocked reservations and promotes the next
wait-list entry into a physical-copy-linked loan, mirroring the loan engine.
- Copies under restoration or in transfer can now be deleted from the UI; the
loan/reservation system keeps exclusive ownership of the `prestato`/`prenotato`
states.
- Legacy reservations with a missing or past start date are no longer promoted
into back-dated loans.
- Admin copy routes stay fixed English literals (not routed through the i18n
system), inventory-code allocation escapes LIKE metacharacters, and the copy
note is sanitised and length-capped like the inventory number.
- All new copy-management strings are translated across the five locales.

### Upgrade notes

- **Legacy availability is migrated automatically.** Books that predate copy
tracking (only the old counters, no per-copy rows) are backfilled into real
copies *before* availability is recalculated, so availability carries over
from the old counter model to the new copy-derived one without being zeroed.
Active loans and reservations are preserved, and copies already marked
lost/damaged/maintenance/under-restoration/in-transfer are left untouched.
- A book whose only record of unavailability was the legacy counter (marked
unavailable with no active loan) becomes available again after the upgrade:
the new model derives availability from physical copies, and a physically
missing book with no loan leaves no machine-readable trace to preserve.
Re-mark those copies from the book page after upgrading.
- If you upgraded through an intermediate version that had already zeroed a
legacy book's counters before this release, the backfill cannot reconstruct
the lost count. Restore `libri.copie_totali` from the automatic pre-upgrade
backup under `storage/backups/`, then re-run the availability recalculation
from Maintenance, or re-add the copies from the book page.

## [0.7.60]

Maintenance release: UPC barcode support, a read-only availability field, and a
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,25 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri

Highlights of the latest release are below. The full version-by-version history (v0.7.59 → v0.6.x) lives in **[CHANGELOG.md](CHANGELOG.md)**.

### v0.7.60 — latest
### v0.7.61 — latest

Physical-copy management from the book page, with availability derived from the copies — plus a per-user interface language and a safe upgrade path for existing catalogues.

### New

- **Manage physical copies from the book page** — every book now has a *Copie Fisiche* section (even one with no copies) to add copies, edit each copy's status (available, maintenance, under restoration, in transfer, lost, damaged), and delete copies. Availability is derived from the copies; an out-of-circulation copy lowers the total on its own.
- **Per-user interface language** — each account keeps its own language, set from the profile/language switcher or by an admin from the *Edit user* form, instead of everyone being forced to Italian regardless of the install language ([#238](https://github.com/fabiodalez-dev/Pinakes/discussions/238)).

### Fixes

- **Book creation and copy changes are atomic** — a book and its initial copies are committed together, and the bulk *increase copies* endpoint allocates collision-free inventory codes, promotes the wait-list, and validates its input.
- **New users inherit the installation language** instead of the old hard-coded Italian default; an admin can set any user's language from the *Edit user* form (#238).

### Upgrade Notes

- **Legacy availability is migrated automatically** (`migrate_0.7.61-rc.1.sql`). Books that predate copy tracking are backfilled into real copies *before* availability is recalculated, so availability carries over from the old counters to the new copy-derived model without being zeroed. Active loans and reservations are preserved, and out-of-circulation copies are left untouched. See **[CHANGELOG.md](CHANGELOG.md)** for the full notes, including how to recover a catalogue already zeroed by an intermediate version.

### v0.7.60

A maintenance release: UPC barcode support, a read-only availability field, and a PHP 8.5 scraping fix.

Expand Down
25 changes: 11 additions & 14 deletions app/Controllers/Admin/LanguagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -703,20 +703,17 @@ private function synchronizeGlobalLocale(\mysqli $db, string $code): void

$this->updateEnvLocale($normalized);

I18n::setLocale($normalized);
$_SESSION['locale'] = $normalized;

// Propagate the new default to every user account so that
// AuthController and RememberMeMiddleware pick it up on the
// next login/token refresh.
try {
$stmt = $db->prepare("UPDATE utenti SET locale = ?");
$stmt->bind_param('s', $normalized);
$stmt->execute();
$stmt->close();
} catch (\Throwable $e) {
SecureLogger::error('LanguagesController: Unable to propagate locale to users: ' . $e->getMessage());
}
// The installation default governs NEW accounts (created with the
// current default) and anonymous rendering only. Deliberately it does
// NOT:
// - touch existing `utenti.locale`: there is no inherited-vs-explicit
// flag, so any propagation (even scoped by the previous default)
// would silently overwrite a user who deliberately chose that
// language. Existing accounts keep their own preference and change
// it via the switcher/profile (#238, Option B);
// - change the current admin's session locale: the admin keeps the
// language stored on their own account (forcing it here would
// diverge from `utenti.locale` and silently revert on next login).
}

private function updateEnvLocale(string $locale): void
Expand Down
14 changes: 7 additions & 7 deletions app/Controllers/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,13 @@ public function login(Request $request, Response $response, mysqli $db): Respons
'name' => trim(\App\Support\HtmlHelper::decode((string) ($row['nome'] ?? '')) . ' ' . \App\Support\HtmlHelper::decode((string) ($row['cognome'] ?? ''))),
];

// Load and apply user's preferred locale (only persist if setLocale succeeds)
if (!empty($row['locale'])) {
$requestedLocale = (string) $row['locale'];
if (\App\Support\I18n::setLocale($requestedLocale)) {
$_SESSION['locale'] = $requestedLocale;
}
}
// Apply the per-user preference on every login. NULL, empty or
// obsolete values inherit the current installation default,
// preventing a previous anonymous/user session locale leaking in.
$requestedLocale = \App\Support\I18n::resolveUserLocale($row['locale'] ?? null);
\App\Support\I18n::setLocale($requestedLocale);
$_SESSION['locale'] = $requestedLocale;
$_SESSION['user']['locale'] = $requestedLocale;

// Handle "Remember Me" functionality with database-backed tokens
if ($remember) {
Expand Down
106 changes: 57 additions & 49 deletions app/Controllers/CollaneController.php
Original file line number Diff line number Diff line change
Expand Up @@ -574,69 +574,77 @@ public function createParentWork(Request $request, Response $response, mysqli $d
}
}

// Create the parent book
$stmt = $db->prepare("INSERT INTO libri (titolo, collana, copie_totali, copie_disponibili, created_at, updated_at) VALUES (?, ?, 0, 0, NOW(), NOW())");
if (!$stmt) {
$_SESSION['error_message'] = __('Errore database');
return $response->withHeader('Location', url('/admin/series'))->withStatus(302);
}
$stmt->bind_param('ss', $parentTitle, $collana);
$stmt->execute();
$parentId = (int) $db->insert_id;
$stmt->close();

// SEC1-1 (review): bail BEFORE calling assignPrimarySeries on a
// failed insert (parentId == 0). Pre-fix the check was after the
// mutation and the early-return was dead code.
if ($parentId <= 0) {
$_SESSION['error_message'] = __('Errore nella creazione dell\'opera');
return $response->withHeader('Location', url('/admin/series'))->withStatus(302);
}
// The zero-copy parent row, its series membership, volume links and
// canonical availability projection are one logical write.
$parentId = 0;
$linkedCount = 0;
$db->begin_transaction();
try {
$stmt = $db->prepare("INSERT INTO libri (titolo, collana, copie_totali, copie_disponibili, created_at, updated_at) VALUES (?, ?, 0, 0, NOW(), NOW())");
if (!$stmt) {
throw new \RuntimeException('Unable to prepare parent-work insert');
}
$stmt->bind_param('ss', $parentTitle, $collana);
$stmt->execute();
$parentId = (int) $db->insert_id;
$stmt->close();

$seriesRepo = new SeriesRepository($db);
$seriesRepo->assignPrimarySeries($parentId, $collana);
if ($parentId <= 0) {
throw new \RuntimeException('Unable to create parent work');
}

// Link all books in the collana as volumes
$linkedCount = 0;
$rows = array_values(array_filter(
$seriesRepo->getBooksForSeries($collana),
static fn(array $row): bool => (int) ($row['id'] ?? 0) !== $parentId
));
if ($rows !== []) {
// Build set of used numero_serie values
$usedNumbers = [];
foreach ($rows as $row) {
if (!empty($row['numero_serie'])) {
$usedNumbers[(int) $row['numero_serie']] = true;
$seriesRepo = new SeriesRepository($db);
$seriesRepo->assignPrimarySeries($parentId, $collana);

// Link all books in the collana as volumes.
$rows = array_values(array_filter(
$seriesRepo->getBooksForSeries($collana),
static fn(array $row): bool => (int) ($row['id'] ?? 0) !== $parentId
));
if ($rows !== []) {
$usedNumbers = [];
foreach ($rows as $row) {
if (!empty($row['numero_serie'])) {
$usedNumbers[(int) $row['numero_serie']] = true;
}
}
}

$stmtInsert = $db->prepare("INSERT IGNORE INTO volumi (opera_id, volume_id, numero_volume) VALUES (?, ?, ?)");
$nextFree = 1;
foreach ($rows as $row) {
$bookId = (int) $row['id'];
if (!empty($row['numero_serie'])) {
$num = (int) $row['numero_serie'];
} else {
// Find next free number not already used
while (isset($usedNumbers[$nextFree])) {
$stmtInsert = $db->prepare("INSERT IGNORE INTO volumi (opera_id, volume_id, numero_volume) VALUES (?, ?, ?)");
if (!$stmtInsert) {
throw new \RuntimeException('Unable to prepare parent-work volume links');
}
$nextFree = 1;
foreach ($rows as $row) {
$bookId = (int) $row['id'];
if (!empty($row['numero_serie'])) {
$num = (int) $row['numero_serie'];
} else {
while (isset($usedNumbers[$nextFree])) {
$nextFree++;
}
$num = $nextFree;
$usedNumbers[$nextFree] = true;
$nextFree++;
}
$num = $nextFree;
$usedNumbers[$nextFree] = true;
$nextFree++;
}
if ($stmtInsert) {
$stmtInsert->bind_param('iii', $parentId, $bookId, $num);
$stmtInsert->execute();
if ($stmtInsert->affected_rows > 0) {
$linkedCount++;
}
}
}
if ($stmtInsert) {
$stmtInsert->close();
}

if (!(new \App\Support\DataIntegrity($db))->recalculateBookAvailability($parentId, insideTransaction: true)) {
throw new \RuntimeException('Unable to derive parent-work availability');
}

$db->commit();
} catch (\Throwable $e) {
$db->rollback();
\App\Support\SecureLogger::error('CollaneController::createParentWork failed', ['error' => $e->getMessage()]);
$_SESSION['error_message'] = __('Errore nella creazione dell\'opera');
return $response->withHeader('Location', url('/admin/series'))->withStatus(302);
}

// Build the new parent book's denormalized search_index — otherwise it
Expand Down
Loading