longevity: #15 libreria e schema della prima consulenza, con test

Tabella consulenza (client_code, UPSERT, nessuno storico) e modulo
lib/longevity/consulenza.ts: consulenzaDi, salvaConsulenza con validazioni
(livello 1-3, obiettivo nell'elenco chiuso, data AAAA-MM-GG, disponibilita
0-7, testo max 5000). 22 test: modulo, schema, API consulenza.ts e
checkup.ts (flusso guidato), redirect di clienti.ts, nessuna pagina cliente
importa consulenza.

Refs #15
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 08:21:24 +02:00
parent b0f30f9e4c
commit 498395dddb
4 changed files with 457 additions and 0 deletions
@@ -0,0 +1,157 @@
// La prima consulenza (#15): una scheda sola per cliente — livello, obiettivo, note dello
// screening iniziale — che il trainer aggiorna in ogni follow-up. Correggere e salvare
// SOVRASCRIVE, nessuno storico (decisione di Adriano, 27/09/2026): il racconto dei cambi va
// nel giornale di bordo (giornale.ts), non qui. Stesso vincolo del resto di longevity.db:
// solo client_code, mai un nome.
import type Database from 'better-sqlite3';
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
export const TESTO_MAX = 5000;
// Elenco chiuso dell'obiettivo primario (foglio «Profilo Cliente», call del 27/09): chiave
// salvata nel DB, etichetta mostrata nel form — stesso schema di ETICHETTA_TIPO altrove nel
// gestionale.
export const OBIETTIVI: { chiave: string; etichetta: string }[] = [
{ chiave: 'performance_sportiva', etichetta: 'Performance sportiva' },
{ chiave: 'energia_quotidiana', etichetta: 'Energia quotidiana' },
{ chiave: 'recupero_benessere', etichetta: 'Recupero e benessere' },
{ chiave: 'riabilitazione', etichetta: 'Riabilitazione' },
{ chiave: 'ricomposizione_corporea', etichetta: 'Ricomposizione corporea' },
{ chiave: 'dimagrimento', etichetta: 'Dimagrimento' },
{ chiave: 'altro', etichetta: 'Altro' },
];
const CHIAVI_OBIETTIVI = new Set(OBIETTIVI.map((o) => o.chiave));
export type Consulenza = {
client_code: string;
livello: number | null;
obiettivo_primario: string | null;
obiettivo_specifico: string | null;
data_target: string | null;
note_mediche: string | null;
storia_sportiva: string | null;
limitazioni: string | null;
disponibilita_giorni: number | null;
fascia_oraria: string | null;
note_screening: string | null;
aggiornata_da: string;
aggiornata_at: string;
};
/** La scheda del cliente, o null se non è mai stata compilata. */
export function consulenzaDi(db: Database.Database, clientCode: string): Consulenza | null {
return (db.prepare(`SELECT * FROM consulenza WHERE client_code = ?`).get(clientCode) as
Consulenza | undefined) ?? null;
}
// I campi grezzi, tutti facoltativi e tutti stringhe: chi chiama (l'API) li legge dal
// FormData così come sono, la validazione vera è qui — stesso confine di aggiungiVoce
// (giornale.ts), che riceve stringhe e decide se sono valide.
export type CampiConsulenza = {
livello?: string;
obiettivoPrimario?: string;
obiettivoSpecifico?: string;
dataTarget?: string;
noteMediche?: string;
storiaSportiva?: string;
limitazioni?: string;
disponibilitaGiorni?: string;
fasciaOraria?: string;
noteScreening?: string;
};
export type EsitoSalvataggio = { ok: true } | { ok: false; errore: string };
// Una stringa vuota (dopo trim) diventa NULL: tutti i campi sono facoltativi (decisione di
// Adriano, 27/09/2026), e NULL è "non compilato", non un errore.
const nullSeVuoto = (v: string | undefined): string | null => {
const t = (v ?? '').trim();
return t === '' ? null : t;
};
const testoValido = (v: string | null): v is string | null => v === null || v.length <= TESTO_MAX;
/**
* Scrive (o riscrive) la scheda di prima consulenza. Rifiuta PRIMA di scrivere: soggetto
* inesistente, livello fuori 1-3, obiettivo fuori elenco, data_target malformata,
* disponibilità fuori 0-7 o non intera, un testo libero oltre TESTO_MAX — stesso schema di
* validazione di aggiungiVoce (giornale.ts) e salvaCheckup (gestionale.ts). UPSERT: una
* seconda chiamata sullo stesso client_code sovrascrive la prima, niente storico.
*/
export function salvaConsulenza(
db: Database.Database,
input: { clientCode: string; autore: string; campi: CampiConsulenza }
): EsitoSalvataggio {
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 c = input.campi;
let livello: number | null = null;
const livelloGrezzo = (c.livello ?? '').trim();
if (livelloGrezzo !== '') {
livello = Number(livelloGrezzo);
if (!Number.isInteger(livello) || livello < 1 || livello > 3) {
return { ok: false, errore: 'Livello non valido: deve essere 1, 2 o 3.' };
}
}
const obiettivoPrimario = nullSeVuoto(c.obiettivoPrimario);
if (obiettivoPrimario !== null && !CHIAVI_OBIETTIVI.has(obiettivoPrimario)) {
return { ok: false, errore: `Obiettivo primario non valido: ${obiettivoPrimario}` };
}
const dataTarget = nullSeVuoto(c.dataTarget);
if (dataTarget !== null && !REGEX_DATA.test(dataTarget)) {
return { ok: false, errore: 'Data target non valida (formato AAAA-MM-GG).' };
}
let disponibilitaGiorni: number | null = null;
const disponibilitaGrezza = (c.disponibilitaGiorni ?? '').trim();
if (disponibilitaGrezza !== '') {
disponibilitaGiorni = Number(disponibilitaGrezza);
if (!Number.isInteger(disponibilitaGiorni) || disponibilitaGiorni < 0 || disponibilitaGiorni > 7) {
return { ok: false, errore: 'Disponibilità non valida: deve essere un numero intero fra 0 e 7.' };
}
}
const obiettivoSpecifico = nullSeVuoto(c.obiettivoSpecifico);
const noteMediche = nullSeVuoto(c.noteMediche);
const storiaSportiva = nullSeVuoto(c.storiaSportiva);
const limitazioni = nullSeVuoto(c.limitazioni);
const fasciaOraria = nullSeVuoto(c.fasciaOraria);
const noteScreening = nullSeVuoto(c.noteScreening);
for (const [nome, valore] of [
['Obiettivo specifico', obiettivoSpecifico], ['Note mediche', noteMediche],
['Storia sportiva', storiaSportiva], ['Limitazioni', limitazioni],
['Fascia oraria', fasciaOraria], ['Note screening', noteScreening],
] as const) {
if (!testoValido(valore)) {
return { ok: false, errore: `${nome} non può superare ${TESTO_MAX} caratteri.` };
}
}
db.prepare(
`INSERT INTO consulenza (
client_code, livello, obiettivo_primario, obiettivo_specifico, data_target,
note_mediche, storia_sportiva, limitazioni, disponibilita_giorni, fascia_oraria,
note_screening, aggiornata_da, aggiornata_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT (client_code) DO UPDATE SET
livello = excluded.livello, obiettivo_primario = excluded.obiettivo_primario,
obiettivo_specifico = excluded.obiettivo_specifico, data_target = excluded.data_target,
note_mediche = excluded.note_mediche, storia_sportiva = excluded.storia_sportiva,
limitazioni = excluded.limitazioni, disponibilita_giorni = excluded.disponibilita_giorni,
fascia_oraria = excluded.fascia_oraria, note_screening = excluded.note_screening,
aggiornata_da = excluded.aggiornata_da, aggiornata_at = excluded.aggiornata_at`
).run(
input.clientCode, livello, obiettivoPrimario, obiettivoSpecifico, dataTarget,
noteMediche, storiaSportiva, limitazioni, disponibilitaGiorni, fasciaOraria,
noteScreening, input.autore
);
return { ok: true };
}
+18
View File
@@ -122,6 +122,24 @@ CREATE TABLE IF NOT EXISTS giornale (
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_giornale_cliente ON giornale (client_code, data DESC); CREATE INDEX IF NOT EXISTS idx_giornale_cliente ON giornale (client_code, data DESC);
-- Prima consulenza (issue #15): una scheda sola per cliente, sovrascritta a ogni modifica —
-- nessuno storico (decisione di Adriano, 27/09/2026, come il giornale qui sopra ma senza
-- neanche l'aggiungi/cancella: qui è un UPSERT unico). Solo client_code, mai un nome.
CREATE TABLE IF NOT EXISTS consulenza (
client_code TEXT PRIMARY KEY REFERENCES soggetti(client_code),
livello INTEGER CHECK (livello IN (1,2,3)),
obiettivo_primario TEXT,
obiettivo_specifico TEXT,
data_target TEXT,
note_mediche TEXT,
storia_sportiva TEXT,
limitazioni TEXT,
disponibilita_giorni INTEGER CHECK (disponibilita_giorni BETWEEN 0 AND 7),
fascia_oraria TEXT,
note_screening TEXT,
aggiornata_da TEXT NOT NULL,
aggiornata_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`; `;
const SCHEMA_IDENTITY = ` const SCHEMA_IDENTITY = `
@@ -0,0 +1,281 @@
// Issue #15: prima consulenza per cliente (solo staff, una scheda sola — correggere e
// salvare sovrascrive, nessuno storico), e il flusso guidato per un cliente nuovo.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createLongevityDb, getLongevityDb } from '../../src/lib/longevity/db';
import { salvaCheckup } from '../../src/lib/longevity/gestionale';
import { consulenzaDi, salvaConsulenza, OBIETTIVI } from '../../src/lib/longevity/consulenza';
function dbPronto() {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
return db;
}
describe('salvaConsulenza e consulenzaDi', () => {
it('inserisce e legge la scheda', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, {
clientCode: 'ISL-0001', autore: 'nicola',
campi: { livello: '2', obiettivoPrimario: 'dimagrimento', dataTarget: '2027-01-01', disponibilitaGiorni: '3' },
});
expect(esito.ok).toBe(true);
const scheda = consulenzaDi(db, 'ISL-0001');
expect(scheda).not.toBeNull();
expect(scheda?.livello).toBe(2);
expect(scheda?.obiettivo_primario).toBe('dimagrimento');
expect(scheda?.data_target).toBe('2027-01-01');
expect(scheda?.disponibilita_giorni).toBe(3);
expect(scheda?.aggiornata_da).toBe('nicola');
});
it('null se non compilata', () => {
const db = dbPronto();
expect(consulenzaDi(db, 'ISL-0001')).toBeNull();
});
it('una seconda chiamata sovrascrive la prima: una riga sola, nessuno storico', () => {
const db = dbPronto();
salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '1' } });
salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'donata', campi: { livello: '3' } });
const righe = (db.prepare(`SELECT COUNT(*) n FROM consulenza`).get() as { n: number }).n;
expect(righe).toBe(1);
const scheda = consulenzaDi(db, 'ISL-0001');
expect(scheda?.livello).toBe(3);
expect(scheda?.aggiornata_da).toBe('donata');
});
it('stringhe vuote diventano NULL', () => {
const db = dbPronto();
salvaConsulenza(db, {
clientCode: 'ISL-0001', autore: 'nicola',
campi: { obiettivoSpecifico: ' ', noteMediche: '', fasciaOraria: undefined },
});
const scheda = consulenzaDi(db, 'ISL-0001');
expect(scheda?.obiettivo_specifico).toBeNull();
expect(scheda?.note_mediche).toBeNull();
expect(scheda?.fascia_oraria).toBeNull();
});
it('tutti i campi sono facoltativi: una scheda vuota è valida', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: {} });
expect(esito.ok).toBe(true);
});
});
describe('salvaConsulenza: rifiuti, nessuna scrittura', () => {
const righe = (db: ReturnType<typeof dbPronto>) =>
(db.prepare(`SELECT COUNT(*) n FROM consulenza`).get() as { n: number }).n;
it('rifiuta un cliente inesistente', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-9999', autore: 'nicola', campi: {} });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta un livello fuori 1-3', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '4' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta un livello non intero', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '2.5' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta un obiettivo primario fuori elenco', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { obiettivoPrimario: 'volare' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('accetta ogni chiave dell elenco OBIETTIVI', () => {
const db = dbPronto();
for (const o of OBIETTIVI) {
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { obiettivoPrimario: o.chiave } });
expect(esito.ok, o.chiave).toBe(true);
}
});
it('rifiuta una data target malformata', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { dataTarget: '01-01-2027' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta una disponibilita fuori 0-7', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { disponibilitaGiorni: '8' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta una disponibilita non intera', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { disponibilitaGiorni: '2,5' } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
it('rifiuta un testo libero troppo lungo', () => {
const db = dbPronto();
const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { noteMediche: 'x'.repeat(5001) } });
expect(esito.ok).toBe(false);
expect(righe(db)).toBe(0);
});
});
describe('schema: la tabella consulenza si crea su un longevity.db preesistente senza di essa', () => {
it('CREATE TABLE IF NOT EXISTS basta: nessuna migrazione a mano serve', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
expect(salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '1' } }).ok).toBe(true);
expect(() => createLongevityDb(':memory:')).not.toThrow();
});
});
// Le API sotto (consulenza.ts e checkup.ts) chiamano SEMPRE getLongevityDb(), il singleton
// di db.ts: aprirlo e chiuderlo dentro ogni singolo test (come fa correzione-checkup-api.test.ts
// da solo nel suo file) romperebbe i test successivi dello STESSO file, perché il singleton
// resta quello chiuso finché il modulo non si ricarica. Qui si apre una volta sola per
// tutte le API, con beforeAll/afterAll, e ogni test usa un client_code diverso per non
// pestarsi i piedi — stesso principio, applicato a un file con più test sullo stesso
// singleton invece di uno solo.
describe('API del flusso guidato (consulenza.ts, checkup.ts)', () => {
let dir: string;
let db: ReturnType<typeof getLongevityDb>;
let primaLg: string | undefined;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'lg-api-flusso-test-'));
primaLg = process.env.LONGEVITY_DB_PATH;
process.env.LONGEVITY_DB_PATH = join(dir, 'longevity.db');
db = getLongevityDb();
for (const code of ['ISL-0001', 'ISL-0002', 'ISL-0003', 'ISL-0004']) {
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, 'F')`).run(code);
}
});
afterAll(() => {
db.close(); // libera l'handle sul file: senza, rmSync fallisce con EPERM su Windows
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
rmSync(dir, { recursive: true, force: true });
});
it('consulenza.ts: salva con autore da locals.user (mai dal form) e torna alla scheda cliente, fuori dal flusso', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
const form = new FormData();
form.set('client_code', 'ISL-0001');
form.set('livello', '2');
form.set('autore', 'un-impostore'); // ignorato: deve vincere locals.user
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
expect(res.headers.get('Location')).toBe('/longevity/gestionale/cliente/ISL-0001?consulenza=salvata#consulenza');
const riga = db.prepare(`SELECT aggiornata_da FROM consulenza WHERE client_code = 'ISL-0001'`).get() as
{ aggiornata_da: string };
expect(riga.aggiornata_da).toBe('nicola');
});
it('consulenza.ts: con flusso=nuovo prosegue verso il check-up', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
const form = new FormData();
form.set('client_code', 'ISL-0002');
form.set('flusso', 'nuovo');
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.headers.get('Location')).toBe('/longevity/gestionale/cliente/ISL-0002/checkup?flusso=nuovo');
});
it('consulenza.ts: client_code inesistente, errore, torna al modulo conservando il flusso', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza');
const form = new FormData();
form.set('client_code', 'ISL-9999');
form.set('flusso', 'nuovo');
const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
const loc = res.headers.get('Location')!;
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-9999\/consulenza\?/);
expect(loc).toMatch(/flusso=nuovo/);
expect(loc).toMatch(/errore=/);
});
it('checkup.ts: un check-up NUOVO con flusso=nuovo redirige con flusso=questionario', async () => {
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0003');
form.set('data', '2026-09-26');
form.set('flusso', 'nuovo');
form.set('f_peso_kg', '80'); // salvaCheckup rifiuta un check-up senza nessuna misura
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
expect(res.status).toBe(303);
const loc = res.headers.get('Location')!;
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0003\?/);
expect(loc).toMatch(/salvato=/);
expect(loc).toMatch(/flusso=questionario/);
});
it('checkup.ts: una correzione (sessione_id) ignora flusso, nessun flusso nel redirect', async () => {
const esito = salvaCheckup(db, {
client_code: 'ISL-0004', data: '2026-09-20', eta: 40, operatore: 'nicola',
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
});
expect(esito.ok).toBe(true);
if (!esito.ok) throw new Error(esito.errore);
const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup');
const form = new FormData();
form.set('client_code', 'ISL-0004');
form.set('data', '2026-09-20');
form.set('sessione_id', String(esito.sessioneId));
form.set('f_peso_kg', '82');
form.set('flusso', 'nuovo'); // ignorato: una correzione non è mai nel flusso guidato
const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form });
const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any);
const loc = res.headers.get('Location')!;
expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0004\?/);
expect(loc).toMatch(/corretto=/);
expect(loc).not.toMatch(/flusso=/);
});
});
describe('clienti.ts: dopo la creazione il flusso guidato comincia dalla consulenza', () => {
it('redirige a .../consulenza?flusso=nuovo, non piu direttamente alla scheda', () => {
const fs = require('node:fs') as typeof import('node:fs');
const path = require('node:path') as typeof import('node:path');
const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/api/longevity/gestionale/clienti.ts'), 'utf8');
expect(src).toMatch(/\/consulenza\?flusso=nuovo/);
});
});
describe('nessuna pagina cliente importa la consulenza', () => {
it('io.astro, progressione.astro e questionario.astro non fanno riferimento a consulenza.ts', () => {
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 consulenza.ts`).not.toMatch(/consulenza/i);
}
});
});
@@ -129,6 +129,7 @@ describe('il gestionale del trainer (Unità 2b)', () => {
'src/pages/longevity/gestionale/nuovo.astro', 'src/pages/longevity/gestionale/nuovo.astro',
'src/pages/longevity/gestionale/cliente/[code].astro', 'src/pages/longevity/gestionale/cliente/[code].astro',
'src/pages/longevity/gestionale/cliente/[code]/checkup.astro', 'src/pages/longevity/gestionale/cliente/[code]/checkup.astro',
'src/pages/longevity/gestionale/cliente/[code]/consulenza.astro', // #15
]; ];
it('ogni pagina del gestionale esiste e usa la variante "gestionale" del layout', () => { it('ogni pagina del gestionale esiste e usa la variante "gestionale" del layout', () => {