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:
@@ -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 };
|
||||
}
|
||||
@@ -122,6 +122,24 @@ CREATE TABLE IF NOT EXISTS giornale (
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
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 = `
|
||||
|
||||
Reference in New Issue
Block a user