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>
This commit is contained in:
2026-09-27 22:22:07 +02:00
parent 6bbfcd8f3c
commit 00ecfe86c3
2 changed files with 102 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)) 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); 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 = ` 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 };
}