Skip to content
Closed
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre
- **Patron ledger.** New append-only table `movimenti_lettore` (charges, payments, waivers, reversals); the balance is the sum of the rows. The reader record has a "Movimenti" section: balance, accruing fines, list, register a payment (cash, card, transfer, other) with a PDF receipt, manual charge (description required), waiver (reason required). Each desk form is recorded once even when it is sent twice (the same form sent again with another amount, method or text is refused with "Richiesta già registrata" instead of being answered with the earlier movement; a page restored from the browser cache gets a fresh form), and a long ledger is shown 200 rows per page. Recording a payment, a charge or a waiver and downloading a receipt are loan-desk actions: they need the loans permission (`loans.manage`, as Staff has), so a custom role that only manages readers sees the ledger read-only. The return screen shows the fine before the confirmation and can waive it on the spot, with a reason; a waived fine no longer appears in the return email, the fine email or on the loan page. A reader with ledger rows is suspended instead of deleted.
- **Debt block.** With a threshold set, a reader who owes more than it gets `debt_block`. The threshold is the one of the reader's patron type; a reader without a type (or whose type no longer exists) is governed by the default type; a type whose field is empty inherits the global `debt_block_threshold`, and 0 switches the block off for that type. A reader who owes more than the applicable threshold gets `debt_block` ("Saldo a debito oltre la soglia consentita…") on loan requests, admin loans, approvals, renewals and the reservation queue, on the website, the Mobile API and NCIP.
- **Reports and email.** Statistics: charged, collected, waived and reversed per month with CSV export. Return email: new placeholder `{{multa}}` (empty when nothing was charged); an upgraded install whose stored "Restituzione confermata" template is still the previously shipped text gets the line when Settings → Email templates is opened, a customised template is left as it is. New optional email "Multa per ritardo addebitata" (`fine_charged`), off by default.
- **Mobile API 1.9.0** (requires 0.9.0): `GET /me/movements` with balance, movements and accruing fines; `debt_block` arrives as `not_eligible` with `meta.reason` (and as `eligibility_reason` in the staff loan lists). The same plugin version carries the 0.9.0 circulation changes: `closed_dates`, `loanable` and `reservations_enabled` in `GET /catalog/books/{id}/availability`, the 409 codes `media_limit_reached`, `not_loanable`, `reservations_disabled` and `reservation_limit_reached`, and `override_limits` on the staff reservation form.
- **Mobile API 1.9.0** (requires 0.9.0): `GET /me/movements` with balance, movements and accruing fines; `debt_block` arrives as `not_eligible` with `meta.reason` (and as `eligibility_reason` in the staff loan lists). The same plugin version carries the 0.9.0 circulation changes: `closed_dates`, `loanable` and `reservations_enabled` in `GET /catalog/books/{id}/availability`, the 409 codes `media_limit_reached`, `not_loanable`, `reservations_disabled` and `reservation_limit_reached`, and `override_limits` on the staff reservation form. `GET /me` names the reader's `patron_type` (id, code, name; null with the rules off), and staff copies carry `consultation_only`, which `PUT /staff/copies/{id}` sets as the book page does, with the same refusals.
- `app.currency` is now read from the database too (it was a code default only).
- **Fine settings are administrators only.** Staff can still save the Loans tab, but the daily fine, grace days, cap per loan, debt threshold and fine email stay as they are unless an administrator changes them; the fields are read-only for staff.

Expand Down
2 changes: 1 addition & 1 deletion storage/plugins/mobile-api/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@
"optional": true,
"status": "stable"
},
"changelog": "v1.9.0: saldo e movimenti del lettore (/me/movements: addebiti, pagamenti, condoni, storni e multe in maturazione sui prestiti scaduti); il blocco per debito arriva come not_eligible con reason debt_block e come eligibility_reason negli elenchi prestiti dello staff. Regole di circolazione per tipologia lettore (Pinakes 0.9.0): il calendario di disponibilità (/catalog/books/{id}/availability) indica closed_dates (giorni di chiusura della biblioteca), loanable (falso se il titolo è solo in consultazione per il lettore) e reservations_enabled (falso se la tipologia non ammette prenotazioni); nuovi codici 409 media_limit_reached, not_loanable, reservations_disabled e reservation_limit_reached su richieste e prestiti; override_limits nella prenotazione da parte dello staff ignora i limiti di prenotazione della tipologia, come il modulo amministrativo. In /health i flag fines, closures_calendar e circulation_rules seguono i moduli di Impostazioni → Prestiti; con le multe disattivate /me/movements risponde 404 feature_disabled. v1.8.1: il calendario di disponibilità (/catalog/books/{id}/availability) indica loan_duration_days, la durata del prestito che la richiesta aggiunge alla data scelta, così l'app mostra la data di restituzione prima dell'invio. v1.8.0: allegati digitali con visibilità (tutti, utenti registrati, solo staff, chi ha il prestito): il dettaglio libro indica per ogni file visibility e access, i file aperti hanno un link firmato valido 24 ore, quelli chiusi url null e il motivo. v1.7.0: circolazione completa nell'app (richieste, ritiri, prestiti attivi e scaduti, storico, prenotazioni con coda): creazione prestito con ricerca utente e scansione del codice copia, approvazione/rifiuto, conferma/annullamento ritiro, restituzione rapida o con esito della copia e sanzione, restituzione da codice a barre, rinnovo, proroga e solleciti multipli, modifica, ricevuta PDF e via email, calendario disponibilità per utente, contatori per la home staff; creazione di prestiti e prenotazioni ripetibile in sicurezza con chiave di idempotenza. Libri: gestione per singola copia (codice esplicito, stato, note, eliminazione), collane (albero, dettaglio, creazione, rinomina, unione, ordine, assegnazione multipla, opera madre), unione duplicati di autori/editori/generi, etichette PDF e relative impostazioni, importazione CSV e LibraryThing dal telefono con avanzamento ripristinabile ed esportazione LibraryThing, copertine mancanti e arricchimento massivo. Contenuti dei plugin: desiderata (proposte di donazione: accetta, rifiuta, ricevi, elimina, ricezione diretta) e archivi (schede, autorità, allegati, pubblicazione); se attivi, opere FRBR con le espressioni e il collegamento dei libri, allegati digitali (e-book e audiolibri), banco dell'emeroteca e gestione dei gruppi di lettura tramite i rispettivi plugin. In /health un flag per ogni area, vero solo quando l'area è disponibile. Le operazioni passano dagli stessi controller del sito: il registro attività riporta l'operatore e la sorgente del sito (manuale, approvazione, ritiro, restituzione). v1.6.0: area staff nell'app (riservata ad admin e staff): ricerca e scheda modificabile dei libri, importazione da ISBN/EAN con tutte le fonti di scraping, creazione e modifica tramite la stessa pipeline del modulo web, aggiunta copie, copertina da foto o da URL, eliminazione con controllo dei prestiti attivi; gestione di autori, editori, generi, scaffali e ripiani. Nuovo feed delta /catalog/sync per il catalogo offline e calendario personale di prestiti e prenotazioni (feed ICS con link segreto revocabile, eventi dall'inizio alla scadenza, senza dati personali). Sesso e codice fiscale seguono le impostazioni di registrazione: se nascosti non compaiono in /auth/registration-fields né in /me. v1.5.1: cifratura dei messaggi push e irrobustimento dell'API. v1.5.0: archivi e desiderata nell'app."
"changelog": "v1.9.0: saldo e movimenti del lettore (/me/movements: addebiti, pagamenti, condoni, storni e multe in maturazione sui prestiti scaduti); il blocco per debito arriva come not_eligible con reason debt_block e come eligibility_reason negli elenchi prestiti dello staff. Regole di circolazione per tipologia lettore (Pinakes 0.9.0): il calendario di disponibilità (/catalog/books/{id}/availability) indica closed_dates (giorni di chiusura della biblioteca), loanable (falso se il titolo è solo in consultazione per il lettore) e reservations_enabled (falso se la tipologia non ammette prenotazioni); nuovi codici 409 media_limit_reached, not_loanable, reservations_disabled e reservation_limit_reached su richieste e prestiti; override_limits nella prenotazione da parte dello staff ignora i limiti di prenotazione della tipologia, come il modulo amministrativo. GET /me indica patron_type (id, codice e nome della tipologia del lettore, null con le regole di circolazione disattivate); le copie dello staff indicano consultation_only e PUT /staff/copies/{id} lo imposta come la scheda del libro, con gli stessi rifiuti. In /health i flag fines, closures_calendar e circulation_rules seguono i moduli di Impostazioni → Prestiti; con le multe disattivate /me/movements risponde 404 feature_disabled. v1.8.1: il calendario di disponibilità (/catalog/books/{id}/availability) indica loan_duration_days, la durata del prestito che la richiesta aggiunge alla data scelta, così l'app mostra la data di restituzione prima dell'invio. v1.8.0: allegati digitali con visibilità (tutti, utenti registrati, solo staff, chi ha il prestito): il dettaglio libro indica per ogni file visibility e access, i file aperti hanno un link firmato valido 24 ore, quelli chiusi url null e il motivo. v1.7.0: circolazione completa nell'app (richieste, ritiri, prestiti attivi e scaduti, storico, prenotazioni con coda): creazione prestito con ricerca utente e scansione del codice copia, approvazione/rifiuto, conferma/annullamento ritiro, restituzione rapida o con esito della copia e sanzione, restituzione da codice a barre, rinnovo, proroga e solleciti multipli, modifica, ricevuta PDF e via email, calendario disponibilità per utente, contatori per la home staff; creazione di prestiti e prenotazioni ripetibile in sicurezza con chiave di idempotenza. Libri: gestione per singola copia (codice esplicito, stato, note, eliminazione), collane (albero, dettaglio, creazione, rinomina, unione, ordine, assegnazione multipla, opera madre), unione duplicati di autori/editori/generi, etichette PDF e relative impostazioni, importazione CSV e LibraryThing dal telefono con avanzamento ripristinabile ed esportazione LibraryThing, copertine mancanti e arricchimento massivo. Contenuti dei plugin: desiderata (proposte di donazione: accetta, rifiuta, ricevi, elimina, ricezione diretta) e archivi (schede, autorità, allegati, pubblicazione); se attivi, opere FRBR con le espressioni e il collegamento dei libri, allegati digitali (e-book e audiolibri), banco dell'emeroteca e gestione dei gruppi di lettura tramite i rispettivi plugin. In /health un flag per ogni area, vero solo quando l'area è disponibile. Le operazioni passano dagli stessi controller del sito: il registro attività riporta l'operatore e la sorgente del sito (manuale, approvazione, ritiro, restituzione). v1.6.0: area staff nell'app (riservata ad admin e staff): ricerca e scheda modificabile dei libri, importazione da ISBN/EAN con tutte le fonti di scraping, creazione e modifica tramite la stessa pipeline del modulo web, aggiunta copie, copertina da foto o da URL, eliminazione con controllo dei prestiti attivi; gestione di autori, editori, generi, scaffali e ripiani. Nuovo feed delta /catalog/sync per il catalogo offline e calendario personale di prestiti e prenotazioni (feed ICS con link segreto revocabile, eventi dall'inizio alla scadenza, senza dati personali). Sesso e codice fiscale seguono le impostazioni di registrazione: se nascosti non compaiono in /auth/registration-fields né in /me. v1.5.1: cifratura dei messaggi push e irrobustimento dell'API. v1.5.0: archivi e desiderata nell'app."
}
29 changes: 29 additions & 0 deletions storage/plugins/mobile-api/src/Controllers/ActionsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,7 @@ public function getProfile(Request $request, ResponseInterface $response): Respo
];

$data += \App\Support\Permissions::describe(\App\Support\Permissions::viewer($userId, $this->db));
$data['patron_type'] = $this->patronTypeOf($userId);

// Fields the administrator has hidden (Settings -> Registration) are
// omitted from the payload and listed in hidden_fields, so the app
Expand All @@ -683,6 +684,34 @@ public function getProfile(Request $request, ResponseInterface $response): Respo
}
}

/**
* The patron type that governs the reader's circulation rules (Pinakes
* 0.9.0): their own, else the library's default one. Null with the
* module off, before the migration, or when no type exists. A failure
* here never takes the profile down with it.
*
* @return array{id: int, code: string, name: string}|null
*/
private function patronTypeOf(int $userId): ?array
{
try {
$policy = new \App\Services\CirculationPolicy($this->db);
$typeId = $policy->patronTypeOf($userId);
$type = $typeId !== null ? ($policy->types()[$typeId] ?? null) : null;
if (!is_array($type)) {
return null;
}
return [
'id' => (int) $type['id'],
'code' => (string) ($type['codice'] ?? ''),
'name' => \App\Support\HtmlHelper::decode((string) ($type['nome'] ?? '')),
];
} catch (\Throwable $e) {
SecureLogger::warning('[MobileApi] patron type lookup failed: ' . $e->getMessage());
return null;
}
}

// ─── PATCH /me ───────────────────────────────────────────────────────────

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1542,6 +1542,11 @@ private function userProfileSchema(): array
'description' => 'Built-in fields the admin has hidden (Settings -> Registration). They are omitted from this payload and ignored by PATCH /me, which keeps the stored value. Empty when every field is shown.',
'items' => ['type' => 'string', 'enum' => ['sesso', 'cod_fiscale']],
],
'patron_type' => [
'type' => ['object', 'null'],
'description' => 'Since 1.9.0 (Pinakes 0.9.0): the patron type whose circulation rules apply to the reader, their own or the library default. Null with circulation_rules off or when no type exists.',
'properties' => ['id' => ['type' => 'integer'], 'code' => ['type' => 'string'], 'name' => ['type' => 'string']],
],
],
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,33 @@ public function update(ServerRequestInterface $request, ResponseInterface $respo
if (array_key_exists('notes', $json) && $json['notes'] !== null && !is_string($json['notes'])) {
return ResponseEnvelope::error($response, 'validation_error', __('Valore non valido.'), 422, ['field' => 'notes']);
}
if (array_key_exists('consultation_only', $json) && !is_bool($json['consultation_only'])) {
return ResponseEnvelope::error($response, 'validation_error', __('Valore non valido.'), 422, ['field' => 'consultation_only']);
}
try {
$reader = new StaffCopyReader($this->db);
$row = $reader->row($id);
if ($row === null) {
return $this->notFound($response);
}
// Consultation only (Pinakes 0.9.0): the same toggle as the book
// page, with its refusals (a copy held by a loan, a pickup or a
// copy-bound request) and its queue promotion when it is lendable
// again. Status and notes, when sent too, are applied after it.
if (array_key_exists('consultation_only', $json)) {
if (!$reader->consultationActive()) {
return ResponseEnvelope::error($response, 'feature_disabled', __('Funzione non disponibile su questa istanza.'), 404);
}
$refused = $this->setConsultation($id, $json['consultation_only'] === true);
if ($refused !== null) {
return ResponseEnvelope::error($response, 'conflict', $refused, 409);
}
if (!array_key_exists('status', $json) && !array_key_exists('notes', $json)) {
$copy = $reader->copy($id);
return $copy === null ? $this->notFound($response) : ResponseEnvelope::success($response, $copy);
}
$row = $reader->row($id) ?? $row;
}
$current = (string) $row['stato'];
$target = is_string($json['status'] ?? null) ? (string) $json['status'] : $current;
$notes = array_key_exists('notes', $json) ? (string) ($json['notes'] ?? '') : (string) ($row['note'] ?? '');
Expand Down Expand Up @@ -97,6 +118,27 @@ public function update(ServerRequestInterface $request, ResponseInterface $respo
}
}

/**
* Mark the copy consultation only, or lendable again, through
* CopyController::toggleConsultation(). Returns the refusal message, or
* null when the copy now has the requested flag.
*/
private function setConsultation(int $id, bool $consultation): ?string
{
$db = $this->db;
$outcome = Delegation::run(static fn (): ResponseInterface => (new CopyController())->toggleConsultation(
Delegation::request('POST', '/admin/books/copies/' . $id . '/consultation', ['solo_consultazione' => $consultation ? '1' : '0']),
new SlimResponse(),
$db,
$id
));
$error = $outcome->flash('error_message');
if ($error !== null || $outcome->errorCode() !== null) {
return $error ?? __('Operazione non consentita nello stato attuale.');
}
return null;
}

public function delete(ServerRequestInterface $request, ResponseInterface $response, int $id): ResponseInterface
{
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ private static function schemas(): array
'id' => $i, 'inventory_number' => $s, 'status' => $s, 'status_label' => $s, 'notes' => $ns,
'book' => O::object(['id' => $i, 'title' => $s, 'cover_url' => $ns]),
'held' => $b, 'deletable' => $b,
'consultation_only' => ['type' => 'boolean', 'description' => 'Kept for in-house consultation, never lent (Pinakes 0.9.0). False with features.consultation_only off.'],
'on_loan' => ['oneOf' => [['type' => 'null'], O::object(['loan_id' => $i, 'user_name' => $s, 'due_at' => $nd])]],
'created_at' => $ns, 'updated_at' => $ns,
]),
Expand Down
Loading