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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/Views/frontend/book-detail.php
Original file line number Diff line number Diff line change
Expand Up @@ -1798,6 +1798,11 @@ function addDaysToIso(isoDate, days) {
endPicker = window.flatpickr(endEl, {
...baseOpts,
minDate: suggestedDate,
// baseOpts.defaultDate is the start date: inherited here, it made a
// request sent without touching the calendars a one-day loan. The
// start picker's onChange only fires on a click, so the default
// return date has to be set here too.
defaultDate: addDaysToIso(suggestedDate, defaultRequestLoanDays),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
maxDate: undefined // End date can extend beyond availability range
});

Expand Down
4 changes: 2 additions & 2 deletions storage/plugins/mobile-api/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "mobile-api",
"display_name": "Mobile API",
"description": "API REST/JSON versionata (/api/v1) per l'app companion mobile di Pinakes: discovery, autenticazione a token per dispositivo, ricerca catalogo, prestiti/prenotazioni, wishlist, profilo, messaggi e notifiche push. Disattivata per default finché non viene abilitata.",
"version": "1.8.0",
"version": "1.8.1",
"author": "Fabiodalez",
"author_url": "",
"plugin_url": "",
Expand All @@ -22,5 +22,5 @@
"optional": true,
"status": "stable"
},
"changelog": "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.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."
}
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,13 @@ public function bookAvailability(
return ResponseEnvelope::error($response, 'not_found', __('Libro non trovato.'), 404);
}

return ResponseEnvelope::success($response, self::mapAvailability($avail), []);
// The return date POST /reservations sets: the chosen start plus the
// configured loan duration (UserActionsController::loan()/reserve()), so
// the app can show it before the reader sends the request.
$data = self::mapAvailability($avail)
+ ['loan_duration_days' => (new \App\Models\SettingsRepository($this->db))->loanDurationDays()];

return ResponseEnvelope::success($response, $data, []);
} catch (\Throwable $e) {
SecureLogger::error('[MobileApi] book availability failed: ' . $e->getMessage());
return ResponseEnvelope::error($response, 'internal_error', __('Disponibilità non disponibile.'), 500);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ private function paths(): array
'get' => [
'tags' => ['catalog'],
'summary' => 'Book loan availability (calendar)',
'description' => 'Per-day loan availability for the date-picker calendar: total_copies, earliest_available, unavailable_dates (fully-booked days), and days[] with per-day free/total counts. Same computation as the website; excludes the requesting user\'s own active reservations. Soft-deleted books return 404.',
'description' => 'Per-day loan availability for the date-picker calendar: total_copies, earliest_available, unavailable_dates (fully-booked days), and days[] with per-day free/total counts, plus loan_duration_days, the configured loan duration that POST /reservations adds to the chosen start to set the return date (Mobile API 1.8.1). Same computation as the website; excludes the requesting user\'s own active reservations. Soft-deleted books return 404.',
'operationId' => 'getCatalogBookAvailability',
'security' => [['bearerAuth' => []]],
'parameters' => [
Expand All @@ -610,6 +610,7 @@ private function paths(): array
'state' => ['type' => 'string', 'enum' => ['free', 'partial', 'full']],
],
]],
'loan_duration_days' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Days POST /reservations adds to the chosen start to set the return date.'],
],
]],
]]],
Expand Down
123 changes: 123 additions & 0 deletions tests/loan-request-default-end.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// @ts-check
// Regression: a loan request sent without touching the calendars must ask for the
// default loan duration. The return-date picker inherited the start picker's
// defaultDate, so it showed the start date and the request went out with
// end_date = start_date: a one-day loan. The expected duration comes from the
// settings in the database, with the same cap the reservation endpoint applies.
const { test, expect } = require('@playwright/test');
const { execFileSync } = require('child_process');

const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || '';
const ADMIN_PASS = process.env.E2E_ADMIN_PASS || '';

const DB_USER = process.env.E2E_DB_USER || '';
const DB_PASS = process.env.E2E_DB_PASS || '';
const DB_HOST = process.env.E2E_DB_HOST || '';
const DB_PORT = process.env.E2E_DB_PORT || '';
const DB_SOCKET = process.env.E2E_DB_SOCKET || '';
const DB_NAME = process.env.E2E_DB_NAME || '';

test.skip(
!ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_NAME,
'E2E credentials not configured (set E2E_ADMIN_EMAIL, E2E_ADMIN_PASS, E2E_DB_USER, E2E_DB_NAME)',
);

/**
* Runs one statement against the test database (TCP when E2E_DB_HOST is set, else the
* socket) and returns its tab-separated output, trimmed.
* @param {string} sql
* @returns {string}
*/
function dbQuery(sql) {
const args = [];
if (DB_HOST) {
args.push('-h', DB_HOST);
if (DB_PORT) args.push('-P', DB_PORT);
} else if (DB_SOCKET) {
args.push('-S', DB_SOCKET);
}
args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql);
return execFileSync('mysql', args, {
encoding: 'utf-8', timeout: 10000,
env: { ...process.env, MYSQL_PWD: DB_PASS },
}).trim();
}

/**
* A loans setting as a positive integer, or the fallback the server uses when it is
* missing or below 1.
* @param {string} key
* @param {number} fallback
* @returns {number}
*/
function setting(key, fallback) {
const value = parseInt(
dbQuery(`SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='${key}' LIMIT 1`) || '',
10,
);
return Number.isFinite(value) && value >= 1 ? value : fallback;
}

/**
* yyyy-mm-dd plus a number of days, computed in UTC so no time zone shifts the day.
* @param {string} iso
* @param {number} days
* @returns {string}
*/
function addDays(iso, days) {
const [y, m, d] = iso.split('-').map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}

test.describe('Book page: loan request return date', () => {
let bookId = 0;
let expectedDays = 0;

test.beforeAll(() => {
bookId = parseInt(
dbQuery(
"SELECT c.libro_id FROM copie c JOIN libri l ON l.id=c.libro_id " +
"WHERE c.stato='disponibile' AND l.deleted_at IS NULL LIMIT 1",
) || '0',
10,
);
expectedDays = Math.min(setting('loan_duration_days', 30), setting('max_loan_duration_days', 90));
});

test('without touching the calendars, the return date is start + the default loan duration', async ({ page }) => {
test.skip(!bookId, 'No available book to open the loan request on');

await page.goto(`${BASE}/accedi`);
await page.fill('input[name="email"]', ADMIN_EMAIL);
await page.fill('input[name="password"]', ADMIN_PASS);
await page.locator('button[type="submit"]').click();
await page.waitForURL(/(\/$|admin|profilo|account)/, { timeout: 15000 }).catch(() => {});

await page.goto(`${BASE}/libro/${bookId}`);
const btn = page.locator('#btn-request-loan');
test.skip((await btn.count()) === 0, 'This book has no request-loan button');

await btn.click();
await page.waitForSelector('.swal2-popup', { timeout: 8000 });
await page.waitForFunction(
() => {
const start = document.querySelector('#swal-date-start');
const end = document.querySelector('#swal-date-end');
return start && end && /** @type {any} */ (start)._flatpickr && /** @type {any} */ (end)._flatpickr;
},
{ timeout: 8000 },
);

const start = await page.locator('#swal-date-start').inputValue();
const end = await page.locator('#swal-date-end').inputValue();
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(end, 'the return date must not repeat the start date').not.toBe(start);
expect(end).toBe(addDays(start, expectedDays));
// What the visitor reads is the same date, not an empty field.
await expect(page.locator('#swal-date-end-display')).not.toHaveValue('');

});
});
4 changes: 4 additions & 0 deletions tests/mobile-api-behaviors.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,10 @@ test.describe('Availability state', () => {
// earliest_available must be strictly after today (the copy is busy now).
expect(d.earliest_available, 'earliest_available present').toBeTruthy();
expect(d.earliest_available > start, 'earliest_available after today').toBe(true);
// The app shows the return date from it: POST /reservations adds the
// configured loan duration (30 days when unset or invalid) to the start.
const configured = dbInt("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = 'loan_duration_days'");
expect(d.loan_duration_days, 'loan_duration_days is the configured loan duration').toBe(configured >= 1 ? configured : 30);
} finally {
teardown();
}
Expand Down
27 changes: 27 additions & 0 deletions tests/role-boundaries-480.unit.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ public function handle(ServerRequestInterface $request): ResponseInterface

$savedJs = $value('advanced', 'custom_js_essential');
$savedHost = $value('email', 'smtp_host');
// A refusal that regresses saves the whole tab, not only the probed key: keep both
// categories to put them back as they were.
$guardedSettings = $db->query("SELECT category, setting_key, setting_value, description FROM system_settings WHERE category IN ('advanced', 'email')")->fetch_all(MYSQLI_ASSOC);
try {
$settingsRole = $addRole(['backend.access', 'settings.manage']);
$usersRole = $addRole(['backend.access', 'users.manage']);
Expand Down Expand Up @@ -345,6 +348,30 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$db->query('DELETE FROM ruoli WHERE id = ' . (int) $id);
}
$db->query("DELETE FROM api_keys WHERE name = '" . $db->real_escape_string($tag) . "'");
// A refusal that fails above saves its whole tab: a script on every public page,
// a foreign SMTP host and the other keys of those forms. Put both categories back
// whatever happened, or the next suites run against that.
// In one transaction, so a failed insert never leaves both categories emptied.
$db->begin_transaction();
try {
$db->query("DELETE FROM system_settings WHERE category IN ('advanced', 'email')");
$restore = $db->prepare('INSERT INTO system_settings (category, setting_key, setting_value, description) VALUES (?, ?, ?, ?)');
foreach ($guardedSettings as $row) {
$restore->bind_param('ssss', $row['category'], $row['setting_key'], $row['setting_value'], $row['description']);
$restore->execute();
}
$restore->close();
$db->commit();
} catch (\Throwable $e) {
try {
$db->rollback();
} catch (\Throwable) {
// Keep the error that stopped the restore.
}
throw $e;
} finally {
\App\Support\ConfigStore::clearCache();
}
unset($_SESSION);
R::reset();
}
Expand Down
Loading