Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
791ee11
Let import_logs record user imports (0.9.0 migration section)
fabiodalez-dev Oct 28, 2026
2094050
Print library cards, take the patron photo, import users from CSV
fabiodalez-dev Oct 28, 2026
3375bee
Translate the card, photo and user import strings into all five langu…
fabiodalez-dev Oct 28, 2026
2d351ef
Unit-test the card, photo and import logic; describe them in the chan…
fabiodalez-dev Oct 28, 2026
2fa87c2
E2E: print cards, take and erase the photo, import 20 users with dupl…
fabiodalez-dev Oct 28, 2026
54fe7be
Migration tests: check each import_type value, not the whole enum
fabiodalez-dev Oct 28, 2026
308bb53
User import: no orphaned CSV copies, post-insert failures are warning…
fabiodalez-dev Oct 28, 2026
5917553
E2E: reach files the web server owns through sudo when the runner cannot
fabiodalez-dev Oct 28, 2026
b44684b
Patron photo: cap the decoded pixels, apply the EXIF orientation
fabiodalez-dev Oct 28, 2026
5add09a
Card settings and user import views: plain htmlspecialchars; Danish w…
fabiodalez-dev Oct 28, 2026
6169015
Patron photo: rotate after the downscale, one full-size image in memo…
fabiodalez-dev Oct 28, 2026
a0e690d
User import: convert a Windows-1252 CSV to UTF-8 on upload
fabiodalez-dev Oct 28, 2026
7717aaf
User import: transliterate accented headers without relying on iconv
fabiodalez-dev Oct 28, 2026
41a3fd0
Users: erase the patron photo when a deleted account is only suspended
fabiodalez-dev Oct 28, 2026
d538b5f
Card settings: French name of the portrait layout
fabiodalez-dev Oct 28, 2026
28c5446
User import: mb_convert_encoding() of a string is a string
fabiodalez-dev Oct 28, 2026
013820a
Merge feat/minor-slims-features into feat/patron-cards-import
fabiodalez-dev Oct 28, 2026
8fa7395
Merge feat/minor-slims-features into feat/patron-cards-import
fabiodalez-dev Oct 28, 2026
b3b1eb3
User import picks the CSV separator by column structure, not by count
fabiodalez-dev Oct 28, 2026
c280f65
Merge feat/minor-slims-features into feat/patron-cards-import
fabiodalez-dev Oct 28, 2026
de08943
Merge remote-tracking branch 'origin/feat/minor-slims-features' into …
fabiodalez-dev Oct 28, 2026
92d5591
Normalize abbreviated French card headers consistently
fabiodalez-dev Oct 28, 2026
c924cf1
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 28, 2026
09102ce
User import: the enum change keeps its collation, a started run canno…
fabiodalez-dev Oct 29, 2026
5ad9a94
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 29, 2026
32ae23a
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 30, 2026
9b25b45
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 30, 2026
f083f8a
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 30, 2026
555631a
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 30, 2026
8e56f65
User import: refused uploads keep the run in progress, chunks resume …
fabiodalez-dev Oct 30, 2026
807478b
Patron photo: no orphan files on a failed write, delete keeps the ref…
fabiodalez-dev Oct 30, 2026
5ea4448
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 31, 2026
00222d9
Card tests check stored code type, format and the mirrored back sheet
fabiodalez-dev Oct 31, 2026
190fcc2
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 31, 2026
edff1b7
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 31, 2026
4d2e681
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 31, 2026
648427c
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 31, 2026
fff09ee
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 31, 2026
8cc1f5c
User import refuses a column mapping with non-canonical keys
fabiodalez-dev Oct 31, 2026
a20cc2d
User import error report says when it was truncated
fabiodalez-dev Oct 31, 2026
002a5b2
A retried last import block replays the final answer
fabiodalez-dev Oct 31, 2026
d76e3cb
An interrupted user import can be resumed, and is never replaced whil…
fabiodalez-dev Oct 31, 2026
df8e6e2
Patron photo files never outlive their reference
fabiodalez-dev Oct 31, 2026
8515e31
Patron photo test removes its scratch image even when a check fails
fabiodalez-dev Oct 31, 2026
5962b72
Card PDF test checks the Code128 and QR symbols, not just the code text
fabiodalez-dev Oct 31, 2026
7c4acaa
Card E2E checks the back of every card on a multi-card sheet
fabiodalez-dev Oct 31, 2026
35850c4
Patron photo E2E covers a custom role and the delete of a reader with…
fabiodalez-dev Oct 31, 2026
7c7e837
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 31, 2026
471b206
User import refuses start, blocks and cancel from a replaced preview
fabiodalez-dev Oct 31, 2026
6c2ba9d
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 31, 2026
6543889
User import closes a run whose staged file expired into the import hi…
fabiodalez-dev Oct 31, 2026
e746445
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Oct 31, 2026
ad28533
Stale user import preview is explained even when the mapping step is …
fabiodalez-dev Oct 31, 2026
3866e48
User import page: history link only for catalog.manage, finished run …
fabiodalez-dev Nov 1, 2026
2ee2651
User import: send_invites is a real boolean, no per-row sets in the s…
fabiodalez-dev Nov 1, 2026
02b4dae
User import: a run closed without its history entry is never announce…
fabiodalez-dev Nov 1, 2026
8aa4d92
Deleting a reader removes the row and the photo file together
fabiodalez-dev Nov 1, 2026
26ae4b3
Bulk card print above the limit is refused, never cut short
fabiodalez-dev Nov 1, 2026
d98a1ed
Patron photo: a failed or short write leaves no partial file behind
fabiodalez-dev Nov 1, 2026
01efbe5
Test the direction of the EXIF 6 and 8 rotations
fabiodalez-dev Nov 1, 2026
a52e44e
User import blocks resume at a byte offset instead of re-reading the …
fabiodalez-dev Nov 1, 2026
0adf562
Document why a resumable import keeps its staged file past the sweep
fabiodalez-dev Nov 1, 2026
717bd0e
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Nov 1, 2026
c70ef66
The upload form comes back after a cancel that could not be logged
fabiodalez-dev Nov 1, 2026
c1c6eb1
Merge branch 'feat/minor-slims-features' into feat/patron-cards-import
fabiodalez-dev Nov 1, 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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,18 @@ Full version-by-version history for Pinakes. The README shows only the last thre
- **RDA content, media and carrier types in cataloguing.** The book form shows the three RDA types derived from the media type and lets the cataloguer override each one for a single record (`libri.rda_content_type`, `rda_media_type`, `rda_carrier_type`, NULL = derived). The book page shows the effective types. The BIBFRAME output carries the same effective types: books keep txt/n/nc as before; DVD, disc and audiobook records now emit their real RDA types (tdi/v/vd, prm/s/sd, spw/s/sd) instead of a book's; an override wins over the derived value.
- **Statistics.** `/admin/statistics` adds loans of the last 12 months by material and the visitors of the last 30 days. The outcomes of the last closed stocktake (expected, found, misplaced, on loan, missing, unknown codes) appear once a session has been closed. Loans by patron type and fines have their place on the page and appear as soon as those features provide data (`App\Support\StatisticsSections`).

### Library cards, patron photo and user import
- **Printable library cards.** Every reader's page has "Stampa tessera": a TCPDF PDF in CR80 size (85.6 × 54 mm, one card per page) or on A4 sheets of business-card stock with cut lines (10 per sheet, 9 for the portrait layout). The front carries the library logo and name, the reader's name, the card code as a Code128 barcode or a QR code, the photo and the expiry date; the back carries a text of your choice (rules, website, opening hours), mirrored on the A4 sheet so a long-edge duplex print puts each back behind its own card. The users list gained a selection column: selected readers print in one PDF, as single cards or A4 sheets.
- **Settings → Tessere** (`/admin/settings/cards`): three layouts (classic with a header band, minimal without photo, portrait), Code128 or QR, default format, header text, which elements to show, back text. Untouched settings give the classic Code128 card; nothing else changes for an installation that never prints a card.
- **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo, also when the account has a loan history and is therefore only suspended. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change.
- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. The separator (";", "," or TAB) is the one that splits the first lines into the same number of columns as the header, quoted fields respected, so a ";" file whose addresses hold commas is no longer read as a one-column "," file. A patron-type column is assigned by code once the patron types schema exists; until then it is not offered.

### Migration

- `migrate_0.9.0-rc.1.sql`, section "Inventory stocktake": creates `inventario_sessioni` (with `azione_mancanti`, the close's choice), `inventario_attese` (with `esito_chiusura`, the bucket frozen at close, and `codice_atteso`/`titolo_atteso`, the copy code and title frozen at opening so a copy deleted mid-session stays identifiable in the report; each added with a guard to a table an earlier build created) and `inventario_scansioni` (UNIQUE `sessione_id, copia_id`) and seeds the "Revisore inventario" role. Additive and idempotent; nothing existing changes.
- `migrate_0.9.0-rc.1.sql`, section "MARC bulk import / copy cataloguing": `import_logs.import_type` gains the value `marc`. Idempotent; existing rows are untouched.
- `migrate_0.9.0-rc.1.sql`, section "minor features": creates `presenze` (visitor log) and `fornitori` (suppliers), adds `libri.fornitore_id` (ON DELETE SET NULL) and the nullable `libri.rda_content_type`, `rda_media_type`, `rda_carrier_type` overrides. Additive and idempotent.
- `migrate_0.9.0-rc.1.sql`, section "patron-cards-import": `import_logs.import_type` gains the value `users`; the new enum is computed from the current one, so `marc` and any value added by another section are kept.

## [0.8.9]

Expand Down
7 changes: 5 additions & 2 deletions app/Controllers/ImportHistoryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ public function index(Request $request, Response $response, \mysqli $db): Respon
/**
* Download error report as CSV
*/
public function downloadErrors(Request $request, Response $response, \mysqli $db): Response
/**
* @param string|null $requiredType only serve imports of this import_type (404 otherwise)
*/
public function downloadErrors(Request $request, Response $response, \mysqli $db, ?string $requiredType = null): Response
{
$importId = $request->getQueryParams()['import_id'] ?? null;

Expand All @@ -109,7 +112,7 @@ public function downloadErrors(Request $request, Response $response, \mysqli $db
$stmt->execute();
$result = $stmt->get_result();

if (!($row = $result->fetch_assoc())) {
if (!($row = $result->fetch_assoc()) || ($requiredType !== null && ($row['import_type'] ?? '') !== $requiredType)) {
$stmt->close();
$response->getBody()->write('Import non trovato');
return $response->withStatus(404);
Expand Down
259 changes: 259 additions & 0 deletions app/Controllers/PatronCardsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);

namespace App\Controllers;

use App\Models\SettingsRepository;
use App\Support\Branding;
use App\Support\ConfigStore;
use App\Support\PatronCardPdf;
use App\Support\PatronCardSettings;
use App\Support\PatronPhoto;
use mysqli;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;

/**
* Library cards (PDF) and the patron photo.
*
* Who may do what follows the reader record it acts on, exactly like
* UsersController: printing a card or viewing the photo is reading the record
* (admin and staff read every record, a custom users manager only the ones it
* may edit); changing or deleting the photo is editing it.
*/
class PatronCardsController
{
/** Upper bound of cards in one bulk PDF (ten A4 sheets of 50 pages). */
public const MAX_BULK = 500;

public function cardPdf(Request $request, Response $response, mysqli $db, int $id): Response
{
if (!\App\Support\Permissions::can('users.manage')) {
return $response->withStatus(403);
}
$rows = $this->loadUsers($db, [$id]);
if ($rows === []) {
return $response->withStatus(404);
}
if (!$this->canRead($rows[0])) {
return $response->withStatus(403);
}
$code = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $rows[0]['codice_tessera']) ?: (string) $id;
return $this->pdfResponse($response, $db, $rows, $request->getQueryParams(), 'tessera_' . $code . '.pdf');
}

public function bulkPdf(Request $request, Response $response, mysqli $db): Response
{
if (!\App\Support\Permissions::can('users.manage')) {
return $response->withStatus(403);
}
$query = $request->getQueryParams();
$ids = [];
foreach (explode(',', is_string($query['ids'] ?? null) ? $query['ids'] : '') as $part) {
$n = (int) trim($part);
if ($n > 0) {
$ids[$n] = $n;
}
}
if ($ids === []) {
return $response->withStatus(400);
}
if (count($ids) > self::MAX_BULK) {
// Never a PDF with fewer cards than were selected: the page warns
// before opening it, and a request that gets here anyway is refused.
$response->getBody()->write(sprintf(__('Si possono stampare al massimo %d tessere alla volta: selezionane di meno.'), self::MAX_BULK));
return $response->withStatus(422)->withHeader('Content-Type', 'text/plain; charset=UTF-8');
}
$rows = array_values(array_filter($this->loadUsers($db, array_values($ids)), fn (array $row): bool => $this->canRead($row)));
if ($rows === []) {
return $response->withStatus(404);
}
return $this->pdfResponse($response, $db, $rows, $query, 'tessere.pdf');
}

/** Stream the stored photo (never cached by shared caches). */
public function photo(Request $request, Response $response, mysqli $db, int $id): Response
{
if (!\App\Support\Permissions::can('users.manage')) {
return $response->withStatus(403);
}
$rows = $this->loadUsers($db, [$id]);
if ($rows === [] || !$this->canRead($rows[0])) {
return $response->withStatus($rows === [] ? 404 : 403);
}
$path = PatronPhoto::absolutePath($rows[0]['foto_profilo_url'] ?? null);
if ($path === null) {
return $response->withStatus(404);
}
$bytes = (string) file_get_contents($path);
$response->getBody()->write($bytes);
return $response
->withHeader('Content-Type', 'image/jpeg')
->withHeader('Content-Length', (string) strlen($bytes))
->withHeader('Cache-Control', 'private, no-store')
->withHeader('X-Content-Type-Options', 'nosniff');
}

/** Save a photo sent as multipart field `photo` (webcam frame or picked file). JSON reply. */
public function uploadPhoto(Request $request, Response $response, mysqli $db, int $id): Response
{
$guard = $this->editGuard($db, $id);
if ($guard !== null) {
return $this->json($response, ['success' => false, 'error' => $guard[1]], $guard[0]);
}
$files = $request->getUploadedFiles();
$file = $files['photo'] ?? null;
if (!$file instanceof \Psr\Http\Message\UploadedFileInterface || $file->getError() !== UPLOAD_ERR_OK) {
$tooLarge = $file instanceof \Psr\Http\Message\UploadedFileInterface
&& in_array($file->getError(), [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true);
return $this->json($response, ['success' => false, 'error' => $tooLarge ? __('La foto supera la dimensione massima consentita.') : __('Nessuna foto ricevuta.')], 400);
}
$tmp = $file->getStream()->getMetadata('uri');
if (!is_string($tmp) || !is_file($tmp)) {
return $this->json($response, ['success' => false, 'error' => __('Nessuna foto ricevuta.')], 400);
}
$error = PatronPhoto::store($db, $id, $tmp);
if ($error !== null) {
$message = match ($error) {
'too_large' => __('La foto supera la dimensione massima consentita.'),
'invalid_image' => __('Il file non è un\'immagine JPEG, PNG o WebP valida.'),
default => __('Impossibile salvare la foto. Riprova più tardi.'),
};
return $this->json($response, ['success' => false, 'error' => $message], $error === 'storage' || $error === 'db' ? 500 : 422);
}
\App\Support\SecureLogger::info('Patron photo updated', ['user_id' => $id, 'by' => $_SESSION['user']['id'] ?? null]);
return $this->json($response, [
'success' => true,
'url' => url('/admin/users/' . $id . '/photo') . '?v=' . substr(sha1((string) PatronPhoto::storedFor($db, $id)), 0, 10),
]);
}

/** Remove the photo (the reader's right to erasure). JSON for fetch(), redirect for a plain form. */
public function deletePhoto(Request $request, Response $response, mysqli $db, int $id): Response
{
$wantsJson = str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json');
$guard = $this->editGuard($db, $id);
if ($guard !== null) {
return $wantsJson
? $this->json($response, ['success' => false, 'error' => $guard[1]], $guard[0])
: $response->withStatus($guard[0]);
}
$ok = PatronPhoto::delete($db, $id);
if ($ok) {
\App\Support\SecureLogger::info('Patron photo deleted', ['user_id' => $id, 'by' => $_SESSION['user']['id'] ?? null]);
}
if ($wantsJson) {
return $this->json($response, $ok ? ['success' => true] : ['success' => false, 'error' => __('Impossibile eliminare la foto. Riprova più tardi.')], $ok ? 200 : 500);
}
$_SESSION[$ok ? 'success_message' : 'error_message'] = $ok ? __('Foto eliminata.') : __('Impossibile eliminare la foto. Riprova più tardi.');
return $response->withHeader('Location', url('/admin/users/details/' . $id))->withStatus(302);
}

/**
* @param list<array<string,mixed>> $rows
* @param array<string,mixed> $query
*/
private function pdfResponse(Response $response, mysqli $db, array $rows, array $query, string $filename): Response
{
$repository = new SettingsRepository($db);
$settings = PatronCardSettings::resolve($repository);
$format = is_string($query['format'] ?? null) && in_array($query['format'], PatronCardSettings::FORMATS, true)
? $query['format']
: $settings['format'];

$cards = [];
foreach ($rows as $row) {
$expiry = (string) ($row['data_scadenza_tessera'] ?? '');
$cards[] = [
'name' => trim((string) ($row['nome'] ?? '') . ' ' . (string) ($row['cognome'] ?? '')),
'code' => (string) ($row['codice_tessera'] ?? ''),
'expiry' => $expiry !== '' && function_exists('format_date') ? format_date($expiry, false, '/') : $expiry,
'photo' => $settings['show_photo'] ? PatronPhoto::absolutePath($row['foto_profilo_url'] ?? null) : null,
];
}

$appName = (string) ConfigStore::get('app.name', 'Biblioteca');
$generator = new PatronCardPdf($settings, $appName, $this->localLogo());
$pdf = $generator->render($cards, $format);

$response->getBody()->write($pdf);
return $response
->withHeader('Content-Type', 'application/pdf')
->withHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->withHeader('Cache-Control', 'private, no-store');
}

/** The configured logo as a local file (remote logos are not fetched while rendering). */
private function localLogo(): ?string
{
$logo = Branding::logo();
if ($logo === '' || preg_match('#^https?://#i', $logo) === 1) {
return null;
}
$path = (string) (parse_url($logo, PHP_URL_PATH) ?? '');
$public = realpath(dirname(__DIR__, 2) . '/public');
if ($path === '' || $public === false) {
return null;
}
$file = realpath($public . '/' . ltrim($path, '/'));
return $file !== false && str_starts_with($file, $public . DIRECTORY_SEPARATOR) ? $file : null;
}

/**
* @param list<int> $ids
* @return list<array<string,mixed>>
*/
private function loadUsers(mysqli $db, array $ids): array
{
$ids = array_values(array_filter(array_map('intval', $ids), static fn (int $n): bool => $n > 0));
if ($ids === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("SELECT id, nome, cognome, codice_tessera, data_scadenza_tessera, foto_profilo_url, tipo_utente, ruolo_id, accesso_digitale
FROM utenti WHERE id IN ({$placeholders})
ORDER BY cognome, nome, id");
if ($stmt === false) {
return [];
}
$stmt->bind_param(str_repeat('i', count($ids)), ...$ids);
$stmt->execute();
$result = $stmt->get_result();
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = $row;
}
$stmt->close();
return $rows;
}

/** @param array<string,mixed> $row */
private function canRead(array $row): bool
{
return in_array($_SESSION['user']['tipo_utente'] ?? '', ['admin', 'staff'], true)
|| \App\Support\UserRoleAssignment::canEdit($row);
}

/** @return array{0:int,1:string}|null status and message when refused */
private function editGuard(mysqli $db, int $id): ?array
{
if (!\App\Support\Permissions::can('users.manage')) {
return [403, __('Accesso negato')];
}
$rows = $this->loadUsers($db, [$id]);
if ($rows === []) {
return [404, __('Utente non trovato')];
}
if (!\App\Support\UserRoleAssignment::canEdit($rows[0])) {
return [403, __('Accesso negato')];
}
return null;
}

/** @param array<string,mixed> $payload */
private function json(Response $response, array $payload, int $status = 200): Response
{
$response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $response->withHeader('Content-Type', 'application/json')->withStatus($status);
}
}
16 changes: 16 additions & 0 deletions app/Controllers/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons
$contactSettings = $this->resolveContactSettings($repository);
$privacySettings = $this->resolvePrivacySettings($repository);
$labelSettings = $this->resolveLabelSettings($repository);
$cardSettings = \App\Support\PatronCardSettings::resolve($repository);
$eventSettings = $this->resolveEventSettings($repository);
// Catalogue header (Settings → CMS): edit the configured site language.
// Historical translations stay stored for later language changes.
Expand Down Expand Up @@ -104,6 +105,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons
'contactSettings',
'privacySettings',
'labelSettings',
'cardSettings',
'eventSettings',
'catalogHeaderLocales',
'catalogHeaderTexts',
Expand Down Expand Up @@ -1301,6 +1303,20 @@ public function updateLabels(Request $request, Response $response, mysqli $db):
return $this->redirect($response, '/admin/settings?tab=labels');
}

/** Save the library card settings (Settings → Tessere). */
public function updateCards(Request $request, Response $response, mysqli $db): Response
{
$data = (array) $request->getParsedBody();
// CSRF validated by CsrfMiddleware

$repository = new SettingsRepository($db);
$repository->ensureTables();
\App\Support\PatronCardSettings::save($repository, \App\Support\PatronCardSettings::sanitize($data));

$_SESSION['success_message'] = __('Impostazioni tessere aggiornate correttamente.');
return $this->redirect($response, '/admin/settings?tab=cards');
}

public function toggleApi(Request $request, Response $response, mysqli $db): Response
{
// Exposing or withdrawing the public API changes the application's
Expand Down
Loading