From 00ecfe86c36367e5b16f7dfa3b08130916c0d060 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sun, 27 Sep 2026 22:22:07 +0200 Subject: [PATCH] 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 --- apps/platforms/src/lib/longevity/db.ts | 12 +++ apps/platforms/src/lib/longevity/giornale.ts | 90 ++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 apps/platforms/src/lib/longevity/giornale.ts diff --git a/apps/platforms/src/lib/longevity/db.ts b/apps/platforms/src/lib/longevity/db.ts index 8621879..e551555 100644 --- a/apps/platforms/src/lib/longevity/db.ts +++ b/apps/platforms/src/lib/longevity/db.ts @@ -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 = ` diff --git a/apps/platforms/src/lib/longevity/giornale.ts b/apps/platforms/src/lib/longevity/giornale.ts new file mode 100644 index 0000000..1e20810 --- /dev/null +++ b/apps/platforms/src/lib/longevity/giornale.ts @@ -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 }; +}