diff --git a/CHANGELOG.md b/CHANGELOG.md index ec739737e..82844c1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/storage/plugins/mobile-api/plugin.json b/storage/plugins/mobile-api/plugin.json index 810f03da8..2bd63fc61 100644 --- a/storage/plugins/mobile-api/plugin.json +++ b/storage/plugins/mobile-api/plugin.json @@ -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." } diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index 5f3ff37e4..934be6602 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -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 @@ -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 ─────────────────────────────────────────────────────────── /** diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index 99292b0c4..878f444be 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -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']], + ], ], ]; } diff --git a/storage/plugins/mobile-api/src/Controllers/StaffCopiesController.php b/storage/plugins/mobile-api/src/Controllers/StaffCopiesController.php index 09f0e57bc..8e92a6c57 100644 --- a/storage/plugins/mobile-api/src/Controllers/StaffCopiesController.php +++ b/storage/plugins/mobile-api/src/Controllers/StaffCopiesController.php @@ -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'] ?? ''); @@ -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 { diff --git a/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php b/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php index 28bb66640..5f34104b7 100644 --- a/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php +++ b/storage/plugins/mobile-api/src/Support/StaffCirculationOpenApi.php @@ -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, ]), diff --git a/storage/plugins/mobile-api/src/Support/StaffContentOpenApi.php b/storage/plugins/mobile-api/src/Support/StaffContentOpenApi.php index 0f56e2774..8dd8a2be3 100644 --- a/storage/plugins/mobile-api/src/Support/StaffContentOpenApi.php +++ b/storage/plugins/mobile-api/src/Support/StaffContentOpenApi.php @@ -67,14 +67,15 @@ private static function paths(): array 'copies' => [ '/staff/copies/{id}' => [ 'get' => O::op($t, 'staffCopy', 'One physical copy', ['200' => O::ok('Copy.', O::ref('StaffCopy'))] + O::notFound(), $copyId), - 'put' => O::op($t, 'staffUpdateCopy', 'Change the status and/or notes of a copy', [ + 'put' => O::op($t, 'staffUpdateCopy', 'Change the status, notes and/or consultation flag of a copy', [ '200' => O::ok('Updated copy.', O::ref('StaffCopy')), - ] + O::notFound() + O::invalid() + O::conflict('`copy_on_loan` (out on loan: use the return), `copy_held` (reserved: use circulation), `conflict` (refused by the server, message = its text).'), $copyId + [ + ] + O::notFound() + O::invalid() + O::conflict('`copy_on_loan` (out on loan: use the return), `copy_held` (reserved: use circulation), `conflict` (refused by the server, message = its text; also a copy held by a loan, a pickup or a request cannot become consultation only).'), $copyId + [ 'requestBody' => O::inlineBody([ 'status' => ['type' => 'string', 'enum' => \App\Plugins\MobileApi\Controllers\StaffBooksController::CREATABLE_COPY_STATES], 'notes' => ['type' => ['string', 'null'], 'maxLength' => 500], + 'consultation_only' => ['type' => 'boolean', 'description' => 'Pinakes 0.9.0: keep the copy for in-house consultation (true) or make it lendable again (false), as the book page does. 404 feature_disabled with features.consultation_only off.'], ]), - 'description' => 'Setting `disponibile` on a copy that is out on loan records the return of that loan (same as the web copy page).', + 'description' => 'Setting `disponibile` on a copy that is out on loan records the return of that loan (same as the web copy page). `consultation_only` is applied first, with the book page\'s refusals; making a copy lendable again promotes the waitlist.', ]), 'delete' => O::op($t, 'staffDeleteCopy', 'Delete a copy that is out of circulation and has no loan history', [ '200' => O::ok('Deleted.', O::object(['id' => ['type' => 'integer'], 'deleted' => ['type' => 'boolean']])), diff --git a/storage/plugins/mobile-api/src/Support/StaffCopyReader.php b/storage/plugins/mobile-api/src/Support/StaffCopyReader.php index 90bc6c000..7e7c12771 100644 --- a/storage/plugins/mobile-api/src/Support/StaffCopyReader.php +++ b/storage/plugins/mobile-api/src/Support/StaffCopyReader.php @@ -18,7 +18,7 @@ final class StaffCopyReader /** States a copy may be deleted from (CopyController::deleteCopy). */ public const OUT_OF_CIRCULATION = ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento']; - private const SELECT = "SELECT c.id, c.libro_id, c.numero_inventario, c.stato, c.note, c.created_at, c.updated_at, + private const SELECT = "SELECT c.id, c.libro_id, c.numero_inventario, c.stato, c.note, c.created_at, c.updated_at, {consultation}, l.titolo, l.copertina_url, EXISTS(SELECT 1 FROM prestiti p WHERE p.copia_id = c.id AND ((p.attivo = 1 AND p.stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) @@ -31,10 +31,30 @@ final class StaffCopyReader FROM copie c JOIN libri l ON l.id = c.libro_id AND l.deleted_at IS NULL"; + /** Whether consultation-only copies apply (module on, 0.9.0 column present), memoised. */ + private ?bool $consultation = null; + public function __construct(private mysqli $db) { } + /** + * Consultation-only copies are in force: the module is on (Settings -> + * Loans, also off in catalogue mode) and the 0.9.0 column exists. Off, + * every copy reads consultation_only false and none can be changed. + */ + public function consultationActive(): bool + { + return $this->consultation ??= \App\Support\Features::enabled('consultation_only') + && (new \App\Services\CirculationPolicy($this->db))->schemaReady(); + } + + /** SELECT with the consultation flag, or a constant before the migration / with the module off. */ + private function select(): string + { + return str_replace('{consultation}', $this->consultationActive() ? 'c.solo_consultazione' : '0 AS solo_consultazione', self::SELECT); + } + /** @return array */ public static function labels(): array { @@ -53,7 +73,7 @@ public static function labels(): array /** @return array|null StaffCopy */ public function copy(int $id): ?array { - $rows = $this->query(self::SELECT . ' WHERE c.id = ? LIMIT 1', 'i', [$id]); + $rows = $this->query($this->select() . ' WHERE c.id = ? LIMIT 1', 'i', [$id]); return isset($rows[0]) ? $this->map($rows[0]) : null; } @@ -69,7 +89,7 @@ public function row(int $id): ?array /** @return list> */ public function forBook(int $bookId): array { - $rows = $this->query(self::SELECT . ' WHERE c.libro_id = ? ORDER BY c.numero_inventario ASC, c.id ASC', 'i', [$bookId]); + $rows = $this->query($this->select() . ' WHERE c.libro_id = ? ORDER BY c.numero_inventario ASC, c.id ASC', 'i', [$bookId]); return array_map(fn (array $r): array => $this->map($r), $rows); } @@ -77,7 +97,7 @@ public function forBook(int $bookId): array /** @return array|null copie row by inventory code */ public function byCode(string $code): ?array { - $rows = $this->query(self::SELECT . ' WHERE c.numero_inventario = ? LIMIT 1', 's', [$code]); + $rows = $this->query($this->select() . ' WHERE c.numero_inventario = ? LIMIT 1', 's', [$code]); return isset($rows[0]) ? $this->map($rows[0]) : null; } @@ -113,6 +133,8 @@ public function map(array $r): array 'cover_url' => absoluteUrl(StaffLoanReader::coverPath($r['copertina_url'] ?? null)), ], 'held' => $held, + // Kept for in-house consultation, never lent (Pinakes 0.9.0). + 'consultation_only' => (int) ($r['solo_consultazione'] ?? 0) === 1, 'deletable' => !$held && in_array($status, self::OUT_OF_CIRCULATION, true) && (int) ($r['has_history'] ?? 0) === 0, diff --git a/tests/mobile-api-patron-consultation-090.unit.php b/tests/mobile-api-patron-consultation-090.unit.php new file mode 100644 index 000000000..3a7306a62 --- /dev/null +++ b/tests/mobile-api-patron-consultation-090.unit.php @@ -0,0 +1,215 @@ +set_charset('utf8mb4'); + DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable: {$e->getMessage()}\n"); + exit(1); +} +if (!(new CirculationPolicy($db))->schemaReady()) { + fwrite(STDERR, "FAIL: the 0.9.0 circulation schema is missing on this database\n"); + exit(1); +} +if (session_status() !== PHP_SESSION_ACTIVE) { + $_SESSION = []; +} + +$run = bin2hex(random_bytes(4)); +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label) use (&$passed, &$failed): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . PHP_EOL; + $ok ? $passed++ : $failed++; +}; +$insert = static function (string $sql, string $types, array $params) use ($db): int { + $stmt = $db->prepare($sql); + $stmt->bind_param($types, ...$params); + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + return $id; +}; + +// Rows this run created, removed at shutdown even when a check throws. +$created = new stdClass(); +$created->book = 0; +$created->users = []; +$created->type = 0; +$cleanup = static function () use ($db, $created): void { + Features::resetOverrides(); + $bookId = (int) $created->book; + if ($bookId > 0) { + $db->query("DELETE FROM log_modifiche WHERE tabella = 'libri' AND record_id = {$bookId}"); + $db->query("DELETE FROM prestiti WHERE libro_id = {$bookId}"); + $db->query("DELETE FROM copie WHERE libro_id = {$bookId}"); + $db->query("DELETE FROM libri WHERE id = {$bookId}"); + } + foreach ((array) $created->users as $id) { + $db->query('DELETE FROM utenti WHERE id = ' . (int) $id); + } + $typeId = (int) $created->type; + if ($typeId > 0) { + $db->query("DELETE FROM tipologie_lettore WHERE id = {$typeId}"); + } +}; +register_shutdown_function($cleanup); + +$request = static function (string $method, string $path, ?array $json = null, int $userId = 0) { + $req = (new ServerRequestFactory())->createServerRequest($method, $path); + if ($json !== null) { + $req = $req->withHeader('Content-Type', 'application/json') + ->withBody((new StreamFactory())->createStream((string) json_encode($json))); + } + return $userId > 0 ? $req->withAttribute(AppAuthMiddleware::ATTR_USER, ['id' => $userId]) : $req; +}; +$payload = static function (\Psr\Http\Message\ResponseInterface $r): array { + $r->getBody()->rewind(); + return (array) json_decode((string) $r->getBody(), true); +}; + +// ── A. patron_type in GET /me ───────────────────────────────────────────────── +echo "A. GET /me patron_type\n"; +$typeId = $insert( + 'INSERT INTO tipologie_lettore (codice, nome, prenotazioni_abilitate, attiva) VALUES (?, ?, 1, 1)', + 'ss', + ["zzmapc-{$run}", "Studente & ricercatore {$run}"] +); +$created->type = $typeId; +$typed = $insert( + "INSERT INTO utenti (nome, cognome, email, password, codice_tessera, tipo_utente, stato, email_verificata, tipologia_id) VALUES ('Ada', 'Zz', ?, 'x', ?, 'standard', 'attivo', 1, ?)", + 'ssi', + ["zzmapc+typed{$run}@test.local", "ZZMAPC-T-{$run}", $typeId] +); +$created->users = [$typed]; +$untyped = $insert( + "INSERT INTO utenti (nome, cognome, email, password, codice_tessera, tipo_utente, stato, email_verificata) VALUES ('Bea', 'Zz', ?, 'x', ?, 'standard', 'attivo', 1)", + 'ss', + ["zzmapc+plain{$run}@test.local", "ZZMAPC-P-{$run}"] +); +$created->users = [$typed, $untyped]; + +$me = static fn (int $id): array => $payload((new ActionsController($db))->getProfile($request('GET', '/api/v1/me', null, $id), new Response())); + +Features::override('circulation_rules', true); +$data = $me($typed)['data'] ?? []; +$check(($data['patron_type']['id'] ?? null) === $typeId, "the reader's own patron type is sent"); +$check(($data['patron_type']['code'] ?? null) === "zzmapc-{$run}", 'with its code'); +$check(($data['patron_type']['name'] ?? null) === "Studente & ricercatore {$run}", 'and its name, decoded'); + +$default = (new CirculationPolicy($db))->defaultTypeId(); +$plain = $me($untyped)['data'] ?? []; +$check(array_key_exists('patron_type', $plain), 'patron_type is always in the payload'); +$check(($plain['patron_type']['id'] ?? null) === $default, 'a reader without a type gets the library default (or null when none exists)'); + +Features::override('circulation_rules', false); +$off = $me($typed)['data'] ?? []; +$check(array_key_exists('patron_type', $off) && $off['patron_type'] === null, 'with circulation_rules off patron_type is null'); +Features::resetOverrides(); + +// ── B. consultation_only on staff copies ────────────────────────────────────── +echo "B. Staff copies consultation_only\n"; +$bookId = $insert("INSERT INTO libri (titolo, copie_totali, copie_disponibili) VALUES (?, 2, 2)", 's', ["ZZ_MAPC_{$run}"]); +$created->book = $bookId; +$free = $insert("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'disponibile')", 'is', [$bookId, "ZZMAPC-F-{$run}"]); +$lent = $insert("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'prestato')", 'is', [$bookId, "ZZMAPC-L-{$run}"]); +$insert( + "INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo) VALUES (?, ?, ?, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 14 DAY), 'in_corso', 1)", + 'iii', + [$bookId, $lent, $typed] +); +$column = static function (int $copyId) use ($db): int { + return (int) $db->query("SELECT solo_consultazione FROM copie WHERE id = {$copyId}")->fetch_row()[0]; +}; +$put = static fn (int $copyId, array $body): array => (function () use ($db, $request, $payload, $copyId, $body): array { + $r = (new StaffCopiesController($db))->update($request('PUT', "/api/v1/staff/copies/{$copyId}", $body), new Response(), $copyId); + return ['status' => $r->getStatusCode()] + $payload($r); +})(); + +Features::override('consultation_only', true); +$check(((new StaffCopyReader($db))->copy($free)['consultation_only'] ?? null) === false, 'a lendable copy reads consultation_only false'); + +$set = $put($free, ['consultation_only' => true]); +$check($set['status'] === 200 && ($set['data']['consultation_only'] ?? null) === true, 'PUT consultation_only true answers the copy with the flag on'); +$check($column($free) === 1, 'and stores it'); +$listed = array_column((new StaffCopyReader($db))->forBook($bookId), 'consultation_only', 'id'); +$check(($listed[$free] ?? null) === true && ($listed[$lent] ?? null) === false, 'the book copy list carries the flag of each copy'); + +$back = $put($free, ['consultation_only' => false, 'notes' => "nota {$run}"]); +$check($back['status'] === 200 && ($back['data']['consultation_only'] ?? null) === false && $column($free) === 0, 'PUT consultation_only false makes it lendable again'); +$check(($back['data']['notes'] ?? null) === "nota {$run}", 'notes sent in the same call are applied too'); + +$held = $put($lent, ['consultation_only' => true]); +$check($held['status'] === 409 && ($held['error']['code'] ?? null) === 'conflict' && $column($lent) === 0, 'a copy out on loan cannot become consultation only (409, nothing stored)'); + +$bad = $put($free, ['consultation_only' => 'yes']); +$check($bad['status'] === 422 && ($bad['error']['code'] ?? null) === 'validation_error', 'a non-boolean consultation_only is a 422'); + +Features::override('consultation_only', false); +$db->query("UPDATE copie SET solo_consultazione = 1 WHERE id = {$free}"); +$check(((new StaffCopyReader($db))->copy($free)['consultation_only'] ?? null) === false, 'with the module off every copy reads consultation_only false'); +$disabled = $put($free, ['consultation_only' => false]); +$check($disabled['status'] === 404 && ($disabled['error']['code'] ?? null) === 'feature_disabled' && $column($free) === 1, 'with the module off the flag cannot be changed (404 feature_disabled)'); +$notes = $put($free, ['notes' => "solo note {$run}"]); +$check($notes['status'] === 200 && ($notes['data']['notes'] ?? null) === "solo note {$run}", 'status and notes still work with the module off'); +Features::resetOverrides(); + +echo PHP_EOL . ($failed === 0 ? "ALL {$passed} PASS" : "{$failed} FAIL, {$passed} PASS") . PHP_EOL; +exit($failed === 0 ? 0 : 1);