4 Commits

Author SHA1 Message Date
Adriano 2231a476c7 longevity: sezione Giornale di bordo nella scheda cliente
Form (data, nota) ed elenco voci dopo Anagrafica, cancella visibile solo ad
autore o admin, messaggi per ?giornale=aggiunta|cancellata.
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:24:22 +02:00
Adriano cef08aaf9f longevity: API giornale di bordo per cliente (aggiungi/cancella)
POST unico con campo azione, autore e ruolo sempre da locals.user, redirect
303 alla scheda cliente #giornale.
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:23:03 +02:00
Adriano 37b1a316d8 longevity: test unitari giornale di bordo per cliente
Aggiunta/lettura, rifiuti di aggiungiVoce, cancellaVoce (autore/admin/altro
trainer/id inesistente), schema idempotente, nessuna pagina cliente importa
giornale.ts.
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:22:40 +02:00
Adriano 00ecfe86c3 longevity: schema e libreria giornale di bordo per cliente
Tabella giornale in longevity.db (solo client_code) e giornale.ts:
vociDelGiornale, aggiungiVoce (validazioni), cancellaVoce (autore o admin).
Refs #16

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:22:07 +02:00
5 changed files with 376 additions and 0 deletions
+12
View File
@@ -110,6 +110,18 @@ CREATE TABLE IF NOT EXISTS score (
CHECK ((stato = 'ok' AND valore IS NOT NULL) OR (stato = 'insufficiente' AND valore IS NULL))
);
CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC);
-- Giornale di bordo per cliente (issue #16, solo staff): una nota di testo libero, mai
-- modificata — sbagliata si cancella e si riscrive (decisione di Adriano, 27/09/2026). Come
-- il resto di questo schema, solo client_code: nessun nome qui, mai in longevity.db.
CREATE TABLE IF NOT EXISTS giornale (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
data TEXT NOT NULL,
autore TEXT NOT NULL,
testo TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_giornale_cliente ON giornale (client_code, data DESC);
`;
const SCHEMA_IDENTITY = `
@@ -0,0 +1,90 @@
// Il giornale di bordo per cliente (#16, solo staff — il cliente non lo vede): una nota di
// testo libero legata a una data, che il trainer scrive per tenere la storia del percorso.
// Stesso vincolo di gestionale.ts: solo il database clinico (vedi db.ts), mai un nome — un
// cliente qui è solo un client_code, mai un nome. Nessuna voce automatica (decisione di
// Adriano, 27/09/2026): si aggiunge e si cancella, non si modifica — una nota sbagliata si
// cancella e si riscrive, così non c'è uno storico di modifiche da tenere.
import type Database from 'better-sqlite3';
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
const TESTO_MAX = 5000;
export type VoceGiornale = {
id: number;
data: string;
autore: string;
testo: string;
created_at: string;
};
/** Le voci di un cliente, più recente prima (data DESC, a parità id DESC — stesso criterio
* di sessioniDi in gestionale.ts). */
export function vociDelGiornale(db: Database.Database, clientCode: string): VoceGiornale[] {
return db.prepare(
`SELECT id, data, autore, testo, created_at FROM giornale
WHERE client_code = ? ORDER BY data DESC, id DESC`
).all(clientCode) as VoceGiornale[];
}
export type EsitoAggiunta = { ok: true; id: number } | { ok: false; errore: string };
/**
* Aggiunge una voce al giornale. Rifiuta PRIMA di scrivere: testo vuoto dopo trim, testo
* troppo lungo, data malformata o nel futuro, cliente senza soggetto in longevity — stesso
* schema di validazione di salvaCheckup (gestionale.ts).
*/
export function aggiungiVoce(
db: Database.Database,
input: { clientCode: string; data: string; autore: string; testo: string }
): EsitoAggiunta {
const testo = input.testo.trim();
if (testo === '') {
return { ok: false, errore: 'Il testo della nota non può essere vuoto.' };
}
if (testo.length > TESTO_MAX) {
return { ok: false, errore: `Il testo della nota non può superare ${TESTO_MAX} caratteri.` };
}
if (!REGEX_DATA.test(input.data)) {
return { ok: false, errore: 'Data non valida (formato AAAA-MM-GG).' };
}
const oggi = new Date().toISOString().slice(0, 10);
if (input.data > oggi) {
return { ok: false, errore: 'La data della nota non può essere nel futuro.' };
}
const soggetto = db.prepare(`SELECT 1 FROM soggetti WHERE client_code = ?`).get(input.clientCode);
if (!soggetto) {
return { ok: false, errore: `Cliente inesistente: ${input.clientCode}` };
}
const r = db.prepare(
`INSERT INTO giornale (client_code, data, autore, testo) VALUES (?, ?, ?, ?)`
).run(input.clientCode, input.data, input.autore, testo);
return { ok: true, id: Number(r.lastInsertRowid) };
}
export type EsitoCancellazione = { ok: true; clientCode: string } | { ok: false; errore: string };
/**
* Cancella una voce: solo chi l'ha scritta o un admin può farlo (decisione di Adriano,
* 27/09/2026) — un altro trainer non cancella la nota di un collega. Restituisce il
* client_code della voce cancellata, letto dal database, per il redirect alla scheda giusta
* — stesso principio di verificaMisura (misure.ts): l'id non porta con sé il fascicolo, lo
* si legge da dove è scritto.
*/
export function cancellaVoce(
db: Database.Database,
input: { id: number; utente: string; ruolo: string }
): EsitoCancellazione {
const voce = db.prepare(
`SELECT client_code, autore FROM giornale WHERE id = ?`
).get(input.id) as { client_code: string; autore: string } | undefined;
if (!voce) {
return { ok: false, errore: `Voce inesistente: ${input.id}` };
}
if (voce.autore !== input.utente && input.ruolo !== 'admin') {
return { ok: false, errore: 'Solo l\'autore della nota o un admin possono cancellarla.' };
}
db.prepare(`DELETE FROM giornale WHERE id = ?`).run(input.id);
return { ok: true, clientCode: voce.client_code };
}
@@ -0,0 +1,67 @@
import type { APIRoute } from 'astro';
import { getLongevityDb } from '../../../../lib/longevity/db';
import { aggiungiVoce, cancellaVoce } from '../../../../lib/longevity/giornale';
export const prerender = false;
const REGEX_CODE = /^ISL-\d{4}$/;
// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/).
/**
* Aggiunge o cancella una voce del giornale di bordo (#16, solo staff): un unico endpoint
* per le due azioni, come verifica.ts fa per conferma/scarto — un campo `azione` invece di
* due rotte. Autore e ruolo SEMPRE da `locals.user`, mai dal form (stesso principio di
* checkup.ts e verifica.ts: chi scrive la richiesta non decide chi ne è l'autore).
*/
export const POST: APIRoute = async ({ request, locals }) => {
const form = await request.formData();
const db = getLongevityDb();
const autore = locals.user!.username;
const ruolo = locals.user!.role;
const azione = form.get('azione');
if (azione === 'aggiungi') {
const clientCode = String(form.get('client_code') ?? '').trim();
// Senza un codice valido non c'è una scheda a cui tornare — stesso trattamento di
// checkup.ts: l'unico ritorno sensato è l'indice del gestionale.
if (!REGEX_CODE.test(clientCode)) {
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`);
}
const data = String(form.get('data') ?? '').trim();
const testo = String(form.get('testo') ?? '');
const esito = aggiungiVoce(db, { clientCode, data, autore, testo });
if (!esito.ok) {
return tornaA(clientCode, `?errore=${encodeURIComponent(esito.errore)}`);
}
return tornaA(clientCode, '?giornale=aggiunta');
}
if (azione === 'cancella') {
const idRaw = form.get('id');
const id = typeof idRaw === 'string' ? Number(idRaw) : NaN;
if (!Number.isInteger(id)) {
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Voce non valida.')}`);
}
const esito = cancellaVoce(db, { id, utente: autore, ruolo });
if (!esito.ok) {
// Nessuna voce da cui leggere il client_code (id inesistente, o non c'era già): non
// c'è una scheda su cui tornare, come per un id di misura inesistente in verifica.ts.
return redirect(`/longevity/gestionale?errore=${encodeURIComponent(esito.errore)}`);
}
return tornaA(esito.clientCode, '?giornale=cancellata');
}
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Azione non valida.')}`);
};
const redirect = (location: string): Response => new Response(null, { status: 303, headers: { Location: location } });
// Il redirect torna sempre alla sezione #giornale della scheda, dove il form e l'elenco
// vivono — così chi aggiunge o cancella una nota si ritrova esattamente dove era.
const tornaA = (clientCode: string, query: string): Response =>
redirect(`/longevity/gestionale/cliente/${clientCode}${query}#giornale`);
@@ -12,6 +12,7 @@ import { sessioniDi } from '../../../../lib/longevity/gestionale';
import { misureDaVerificare } from '../../../../lib/longevity/misure';
import { refertoDi, ultimaSessioneDi } from '../../../../lib/longevity/vista';
import { formattaNumero } from '../../../../lib/longevity/formato';
import { vociDelGiornale } from '../../../../lib/longevity/giornale';
export const prerender = false;
@@ -26,6 +27,7 @@ const sessioni = sessioniDi(db, cliente.client_code);
const daVerificare = misureDaVerificare(db, cliente.client_code);
const ultimaId = ultimaSessioneDi(db, cliente.client_code);
const referto = ultimaId !== null ? refertoDi(db, ultimaId) : null;
const voci = vociDelGiornale(db, cliente.client_code); // #16: giornale di bordo, solo staff
const ETICHETTA_TIPO: Record<'checkup' | 'questionario', string> = {
checkup: 'Check-up',
@@ -46,6 +48,13 @@ const MESSAGGIO_VERIFICA: Record<'confermata' | 'scartata', string> = {
};
const messaggioVerifica =
verificata === 'confermata' || verificata === 'scartata' ? MESSAGGIO_VERIFICA[verificata] : null;
const giornaleEsito = params.get('giornale'); // #16: 'aggiunta' | 'cancellata', dal redirect dell'API
const MESSAGGIO_GIORNALE: Record<'aggiunta' | 'cancellata', string> = {
aggiunta: 'Nota aggiunta al giornale di bordo.',
cancellata: 'Nota cancellata dal giornale di bordo.',
};
const messaggioGiornale =
giornaleEsito === 'aggiunta' || giornaleEsito === 'cancellata' ? MESSAGGIO_GIORNALE[giornaleEsito] : null;
---
<Longevity
title={`${cliente.cognome} ${cliente.nome}`}
@@ -72,6 +81,7 @@ const messaggioVerifica =
{salvato && <p class="lg-card">Check-up salvato (sessione #{salvato}).</p>}
{corretto && <p class="lg-card">Check-up corretto (sessione #{corretto}): punteggi ricalcolati.</p>}
{messaggioVerifica && <p class="lg-card">{messaggioVerifica}</p>}
{messaggioGiornale && <p class="lg-card">{messaggioGiornale}</p>}
{fuoriCount && (
<p class="lg-misura--fuori-range">
{fuoriCount} {Number(fuoriCount) === 1 ? 'misura è' : 'misure sono'} fuori dal range di
@@ -86,6 +96,53 @@ const messaggioVerifica =
<p>Data di nascita: {cliente.data_nascita ? formattaData(cliente.data_nascita) : '—'}</p>
</div>
<div class="lg-card" id="giornale">
<p class="lg-score__label">Giornale di bordo</p>
<p class="lg-muted">Solo lo staff lo vede. Una nota sbagliata si cancella e si riscrive: non si modifica.</p>
<form class="lg-form" method="post" action="/api/longevity/gestionale/giornale">
<input type="hidden" name="azione" value="aggiungi" />
<input type="hidden" name="client_code" value={cliente.client_code} />
<label class="lg-field">
<span>Data</span>
<input type="date" name="data" value={oggi} max={oggi} required />
</label>
<label class="lg-field">
<span>Nota</span>
<textarea name="testo" rows="3" required></textarea>
</label>
<div class="lg-form__nav">
<button type="submit">Aggiungi nota</button>
</div>
</form>
{voci.length === 0 ? (
<p class="lg-muted">Nessuna nota ancora.</p>
) : (
<table class="lg-table">
<thead><tr><th>Data</th><th>Autore</th><th>Nota</th><th></th></tr></thead>
<tbody>
{voci.map((v) => (
<tr>
<td>{formattaData(v.data)}</td>
<td>{v.autore}</td>
<td style="white-space: pre-wrap;">{v.testo}</td>
<td>
{(v.autore === Astro.locals.user!.username || Astro.locals.user!.role === 'admin') && (
<form method="post" action="/api/longevity/gestionale/giornale">
<input type="hidden" name="azione" value="cancella" />
<input type="hidden" name="id" value={v.id} />
<button type="submit">Cancella</button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{!referto && (
<div class="lg-card lg-card--incompleto">
<p>Nessuna sessione ancora: apri un check-up per cominciare il fascicolo.</p>
@@ -0,0 +1,150 @@
// Issue #16: giornale di bordo per cliente (solo staff, nessuna voce automatica, si
// aggiunge e si cancella, non si modifica).
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { vociDelGiornale, aggiungiVoce, cancellaVoce } from '../../src/lib/longevity/giornale';
function dbPronto() {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
return db;
}
describe('aggiungiVoce e vociDelGiornale', () => {
it('aggiunge e legge le voci, più recente prima (data DESC, a parità id DESC), a capo conservati', () => {
const db = dbPronto();
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Prima nota' });
const b = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-10', autore: 'donata', testo: 'Riga uno\nRiga due' });
const c = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-10', autore: 'nicola', testo: 'Stessa data, dopo' });
expect(a.ok).toBe(true);
expect(b.ok).toBe(true);
expect(c.ok).toBe(true);
const voci = vociDelGiornale(db, 'ISL-0001');
expect(voci.map((v) => v.testo)).toEqual(['Stessa data, dopo', 'Riga uno\nRiga due', 'Prima nota']);
expect(voci[1].testo).toBe('Riga uno\nRiga due'); // a capo preservati
expect(voci[0].autore).toBe('nicola');
});
});
describe('aggiungiVoce: rifiuti, nessuna riga scritta', () => {
const contaRighe = (db: ReturnType<typeof dbPronto>) =>
(db.prepare(`SELECT COUNT(*) n FROM giornale`).get() as { n: number }).n;
it('rifiuta testo vuoto', () => {
const db = dbPronto();
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: '' });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
it('rifiuta testo di soli spazi', () => {
const db = dbPronto();
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: ' \n ' });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
it('rifiuta testo troppo lungo', () => {
const db = dbPronto();
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'x'.repeat(5001) });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
it('rifiuta una data nel futuro', () => {
const db = dbPronto();
const domani = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: domani, autore: 'nicola', testo: 'Nota' });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
it('rifiuta una data malformata', () => {
const db = dbPronto();
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '01-09-2026', autore: 'nicola', testo: 'Nota' });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
it('rifiuta un cliente inesistente', () => {
const db = dbPronto();
const esito = aggiungiVoce(db, { clientCode: 'ISL-9999', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
expect(esito.ok).toBe(false);
expect(contaRighe(db)).toBe(0);
});
});
describe('cancellaVoce', () => {
it('l\'autore può cancellare la propria voce', () => {
const db = dbPronto();
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
if (!a.ok) throw new Error('setup fallito');
const esito = cancellaVoce(db, { id: a.id, utente: 'nicola', ruolo: 'trainer' });
expect(esito.ok).toBe(true);
if (esito.ok) expect(esito.clientCode).toBe('ISL-0001');
expect(vociDelGiornale(db, 'ISL-0001')).toEqual([]);
});
it('un admin può cancellare la voce di un altro', () => {
const db = dbPronto();
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
if (!a.ok) throw new Error('setup fallito');
const esito = cancellaVoce(db, { id: a.id, utente: 'adriano', ruolo: 'admin' });
expect(esito.ok).toBe(true);
expect(vociDelGiornale(db, 'ISL-0001')).toEqual([]);
});
it('un altro trainer non può cancellare la voce di un collega', () => {
const db = dbPronto();
const a = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
if (!a.ok) throw new Error('setup fallito');
const esito = cancellaVoce(db, { id: a.id, utente: 'donata', ruolo: 'trainer' });
expect(esito.ok).toBe(false);
expect(vociDelGiornale(db, 'ISL-0001').length).toBe(1); // nessuna scrittura
});
it('rifiuta un id inesistente', () => {
const db = dbPronto();
const esito = cancellaVoce(db, { id: 999999, utente: 'nicola', ruolo: 'trainer' });
expect(esito.ok).toBe(false);
});
});
describe('schema: la tabella giornale si crea su un longevity.db preesistente senza di essa', () => {
it('CREATE TABLE IF NOT EXISTS basta: nessuna migrazione a mano serve', () => {
// db.ts crea la tabella con CREATE TABLE IF NOT EXISTS nello stesso blocco SCHEMA_LONGEVITY
// delle altre tabelle: su un file che le ha già (come questo, aperto due volte) la seconda
// apertura è un'operazione a vuoto, esattamente come per soggetti o sessioni — non serve
// una migra() dedicata perché non c'è una tabella preesistente da alterare, solo una nuova
// da creare la prima volta che manca.
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
const esito = aggiungiVoce(db, { clientCode: 'ISL-0001', data: '2026-09-01', autore: 'nicola', testo: 'Nota' });
expect(esito.ok).toBe(true);
// Riaprire lo stesso schema (qui, un nuovo :memory:, ma stesso SCHEMA_LONGEVITY) non deve
// fallire: la tabella è già presente, IF NOT EXISTS salta la creazione.
expect(() => createLongevityDb(':memory:')).not.toThrow();
});
});
describe('nessuna pagina cliente importa il giornale', () => {
it('io.astro, progressione.astro e questionario.astro non fanno riferimento a giornale.ts', () => {
// Stile di anagrafica.ts: controllo testuale sui file, non sull'albero delle import —
// basta a catturare un'importazione diretta, che è l'unico modo in cui il cliente
// potrebbe finire per vedere il giornale di bordo (solo staff, #16).
const fs = require('node:fs') as typeof import('node:fs');
const path = require('node:path') as typeof import('node:path');
const pagine = ['io.astro', 'progressione.astro', 'questionario.astro'];
for (const p of pagine) {
const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/longevity', p), 'utf8');
expect(src, `${p} non deve importare giornale.ts`).not.toMatch(/giornale/i);
}
});
});