registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,669 @@
|
||||
# Review package: 2ea822a..HEAD
|
||||
|
||||
## Commits
|
||||
f0629db longevity: l'endpoint del questionario apre solo longevity, e il test anti-giunzione diventa strutturale
|
||||
f5ca841 longevity: il questionario in pagina, e il consenso che si puo negare davvero
|
||||
|
||||
## Files changed
|
||||
src/components/longevity/Questionario.tsx | 174 ++++++++++++++++++++++++++++
|
||||
src/lib/longevity/anagrafica.ts | 12 ++
|
||||
src/lib/longevity/db.ts | 15 +++
|
||||
src/lib/longevity/vista.ts | 99 ++++++++++++++++
|
||||
src/pages/api/longevity/questionario.ts | 55 +++++++++
|
||||
src/pages/longevity/questionario.astro | 22 ++++
|
||||
src/styles/longevity.css | 32 +++++
|
||||
tests/longevity/anagrafica.test.ts | 12 +-
|
||||
tests/longevity/questionario-pagina.test.ts | 87 ++++++++++++++
|
||||
9 files changed, 506 insertions(+), 2 deletions(-)
|
||||
|
||||
## Diff
|
||||
diff --git a/src/components/longevity/Questionario.tsx b/src/components/longevity/Questionario.tsx
|
||||
new file mode 100644
|
||||
index 0000000..e60cfb6
|
||||
--- /dev/null
|
||||
+++ b/src/components/longevity/Questionario.tsx
|
||||
@@ -0,0 +1,174 @@
|
||||
+// Il questionario in pagina: quattro schermate — le tre aree di domande punteggiate,
|
||||
+// lette dal registro (props `campi`, costruite da `campiDelQuestionario`), più le note
|
||||
+// libere e il consenso. Avanti/indietro fra i blocchi, invio solo sull'ultimo.
|
||||
+//
|
||||
+// Il consenso qui NON blocca lato client la scelta "No": bloccarlo qui ripeterebbe
|
||||
+// l'errore del prototipo del cliente in un altro punto (un controllo che sembra
|
||||
+// proteggere e non protegge). La verifica che conta è quella del server
|
||||
+// (src/lib/longevity/vista.ts, salvaDalForm): se il consenso non è affermativo, la
|
||||
+// compilazione non si salva e la risposta lo dice — questa isola si limita a mostrarlo.
|
||||
+import { useMemo, useState } from 'react';
|
||||
+import type { CampoQuestionario } from '../../lib/longevity/vista';
|
||||
+
|
||||
+interface Props {
|
||||
+ campi: CampoQuestionario[];
|
||||
+}
|
||||
+
|
||||
+const CAMPI_LIBERI: { id: string; etichetta: string }[] = [
|
||||
+ { id: 'farmaci', etichetta: 'Farmaci assunti abitualmente' },
|
||||
+ { id: 'problematiche_attuali', etichetta: 'Problematiche di salute attuali' },
|
||||
+ { id: 'problematiche_pregresse', etichetta: 'Problematiche di salute pregresse' },
|
||||
+ { id: 'obiettivi', etichetta: 'Obiettivi del percorso' },
|
||||
+];
|
||||
+
|
||||
+const BLOCCHI = [
|
||||
+ { titolo: 'Sonno & Recupero', sottoDominio: 'questionario_sonno' },
|
||||
+ { titolo: 'Energia & Stress', sottoDominio: 'questionario_energia_stress' },
|
||||
+ { titolo: 'Stile di Vita', sottoDominio: 'questionario_lifestyle' },
|
||||
+] as const;
|
||||
+
|
||||
+type Esito = { tipo: 'ok'; sessioneId: number } | { tipo: 'errore'; messaggio: string };
|
||||
+
|
||||
+export default function Questionario({ campi }: Props) {
|
||||
+ const [passo, setPasso] = useState(0);
|
||||
+ const [risposte, setRisposte] = useState<Record<string, string>>({});
|
||||
+ const [liberi, setLiberi] = useState<Record<string, string>>({});
|
||||
+ const [consenso, setConsenso] = useState<'' | 'Si' | 'No'>('');
|
||||
+ const [eta, setEta] = useState('');
|
||||
+ const [inviando, setInviando] = useState(false);
|
||||
+ const [esito, setEsito] = useState<Esito | null>(null);
|
||||
+
|
||||
+ // Le domande arrivano dal registro in ordine di test_id, non raggruppate: qui si
|
||||
+ // ricostruiscono i tre blocchi filtrando sul sotto-dominio, senza toccare i dati.
|
||||
+ const gruppi = useMemo(
|
||||
+ () => BLOCCHI.map((b) => ({ ...b, campi: campi.filter((c) => c.sottoDominio === b.sottoDominio) })),
|
||||
+ [campi]
|
||||
+ );
|
||||
+
|
||||
+ const totalePassi = gruppi.length + 1; // + il blocco note/consenso
|
||||
+ const ultimo = passo === totalePassi - 1;
|
||||
+
|
||||
+ async function invia() {
|
||||
+ setInviando(true);
|
||||
+ setEsito(null);
|
||||
+
|
||||
+ const risposteNumeriche: Record<string, number> = {};
|
||||
+ for (const [id, v] of Object.entries(risposte)) {
|
||||
+ const n = Number(v);
|
||||
+ if (v !== '' && Number.isFinite(n)) risposteNumeriche[id] = n;
|
||||
+ }
|
||||
+
|
||||
+ try {
|
||||
+ const res = await fetch('/api/longevity/questionario', {
|
||||
+ method: 'POST',
|
||||
+ headers: { 'Content-Type': 'application/json' },
|
||||
+ body: JSON.stringify({
|
||||
+ risposte: risposteNumeriche,
|
||||
+ liberi,
|
||||
+ consensoSanitario: consenso,
|
||||
+ eta: eta === '' ? undefined : Number(eta),
|
||||
+ }),
|
||||
+ });
|
||||
+ const corpo = await res.json();
|
||||
+ if (res.ok) setEsito({ tipo: 'ok', sessioneId: corpo.sessioneId });
|
||||
+ else setEsito({ tipo: 'errore', messaggio: corpo.error ?? 'Invio non riuscito.' });
|
||||
+ } catch {
|
||||
+ setEsito({ tipo: 'errore', messaggio: 'Invio non riuscito: verifica la connessione.' });
|
||||
+ } finally {
|
||||
+ setInviando(false);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ if (esito?.tipo === 'ok') {
|
||||
+ return (
|
||||
+ <div className="lg-card">
|
||||
+ <p>Questionario salvato, grazie.</p>
|
||||
+ </div>
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ return (
|
||||
+ <div className="lg-form">
|
||||
+ <p className="lg-form__passo">Passo {passo + 1} di {totalePassi}</p>
|
||||
+
|
||||
+ {passo < gruppi.length && (
|
||||
+ <section className="lg-form__blocco">
|
||||
+ <h2>{gruppi[passo].titolo}</h2>
|
||||
+ {gruppi[passo].campi.map((c) => (
|
||||
+ <label className="lg-field" key={c.id}>
|
||||
+ <span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
|
||||
+ <input
|
||||
+ type="number"
|
||||
+ min={c.min}
|
||||
+ max={c.max}
|
||||
+ step="any"
|
||||
+ value={risposte[c.id] ?? ''}
|
||||
+ onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
|
||||
+ />
|
||||
+ </label>
|
||||
+ ))}
|
||||
+ </section>
|
||||
+ )}
|
||||
+
|
||||
+ {ultimo && (
|
||||
+ <section className="lg-form__blocco">
|
||||
+ <h2>Note e consenso</h2>
|
||||
+
|
||||
+ {CAMPI_LIBERI.map((c) => (
|
||||
+ <label className="lg-field" key={c.id}>
|
||||
+ <span>{c.etichetta}</span>
|
||||
+ <textarea
|
||||
+ value={liberi[c.id] ?? ''}
|
||||
+ onChange={(e) => setLiberi((l) => ({ ...l, [c.id]: e.target.value }))}
|
||||
+ />
|
||||
+ </label>
|
||||
+ ))}
|
||||
+
|
||||
+ <label className="lg-field">
|
||||
+ <span>La tua età</span>
|
||||
+ <input type="number" min={0} max={120} value={eta} onChange={(e) => setEta(e.target.value)} />
|
||||
+ </label>
|
||||
+
|
||||
+ <fieldset className="lg-field lg-field--consenso">
|
||||
+ <legend>
|
||||
+ Acconsenti al trattamento dei tuoi dati sanitari (farmaci, patologie, pressione,
|
||||
+ composizione corporea) per il monitoraggio del percorso?
|
||||
+ </legend>
|
||||
+ <label>
|
||||
+ <input
|
||||
+ type="radio" name="consenso" checked={consenso === 'Si'}
|
||||
+ onChange={() => setConsenso('Si')}
|
||||
+ />
|
||||
+ Sì, acconsento
|
||||
+ </label>
|
||||
+ <label>
|
||||
+ <input
|
||||
+ type="radio" name="consenso" checked={consenso === 'No'}
|
||||
+ onChange={() => setConsenso('No')}
|
||||
+ />
|
||||
+ No, non acconsento
|
||||
+ </label>
|
||||
+ </fieldset>
|
||||
+
|
||||
+ {esito?.tipo === 'errore' && <p className="lg-form__errore">{esito.messaggio}</p>}
|
||||
+ </section>
|
||||
+ )}
|
||||
+
|
||||
+ <div className="lg-form__nav">
|
||||
+ <button type="button" disabled={passo === 0} onClick={() => setPasso((p) => Math.max(0, p - 1))}>
|
||||
+ Indietro
|
||||
+ </button>
|
||||
+ {!ultimo && (
|
||||
+ <button type="button" onClick={() => setPasso((p) => Math.min(totalePassi - 1, p + 1))}>
|
||||
+ Avanti
|
||||
+ </button>
|
||||
+ )}
|
||||
+ {ultimo && (
|
||||
+ <button type="button" disabled={inviando || consenso === ''} onClick={invia}>
|
||||
+ {inviando ? 'Invio…' : 'Invia'}
|
||||
+ </button>
|
||||
+ )}
|
||||
+ </div>
|
||||
+ </div>
|
||||
+ );
|
||||
+}
|
||||
diff --git a/src/lib/longevity/anagrafica.ts b/src/lib/longevity/anagrafica.ts
|
||||
index 5d60a0e..9bf8eb3 100644
|
||||
--- a/src/lib/longevity/anagrafica.ts
|
||||
+++ b/src/lib/longevity/anagrafica.ts
|
||||
@@ -1,16 +1,17 @@
|
||||
//
|
||||
// QUESTO è l'unico modulo autorizzato ad aprire insieme identity e longevity.
|
||||
// La separazione dei due database è la garanzia che chi legge i dati clinici non
|
||||
// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione
|
||||
// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica.
|
||||
import type Database from 'better-sqlite3';
|
||||
+import { getIdentityDb } from './db';
|
||||
|
||||
/**
|
||||
* Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in
|
||||
* longevity.soggetti. Dedurlo da una sola metà è il bug che permette di riassegnare il
|
||||
* codice di un cliente cancellato dall'anagrafica (le sue misure restano in longevity)
|
||||
* a una persona nuova: qui non torna mai disponibile.
|
||||
*/
|
||||
function prossimoCodice(identity: Database.Database, longevity: Database.Database): string {
|
||||
const numero = (code: string) => Number(code.slice(4));
|
||||
const ultimoIdentity = identity.prepare(
|
||||
@@ -55,19 +56,30 @@ export function creaCliente(
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
export function codicePerUtente(identity: Database.Database, userId: number): string | null {
|
||||
const r = identity.prepare(`SELECT client_code FROM clienti WHERE user_id = ?`).get(userId) as
|
||||
{ client_code: string } | undefined;
|
||||
return r?.client_code ?? null;
|
||||
}
|
||||
|
||||
+/**
|
||||
+ * Il codice cliente a partire dall'utente di sessione del sito: apre lei stessa la
|
||||
+ * connessione a identity (getIdentityDb), perché QUESTO è il modulo autorizzato a farlo.
|
||||
+ * Un endpoint che deve solo sapere "chi è" chiama questa funzione e non ha mai bisogno
|
||||
+ * di aprire identity da sé — riceve una stringa, mai una connessione che potrebbe
|
||||
+ * incrociarsi con quella di longevity nello stesso file.
|
||||
+ */
|
||||
+export function codicePerUtenteLoggato(userId: number): string | null {
|
||||
+ return codicePerUtente(getIdentityDb(), userId);
|
||||
+}
|
||||
+
|
||||
/** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
|
||||
export function etaAllaData(dataNascita: string, alla: string): number {
|
||||
const n = new Date(dataNascita);
|
||||
const d = new Date(alla);
|
||||
let eta = d.getFullYear() - n.getFullYear();
|
||||
const m = d.getMonth() - n.getMonth();
|
||||
if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--;
|
||||
return eta;
|
||||
}
|
||||
diff --git a/src/lib/longevity/db.ts b/src/lib/longevity/db.ts
|
||||
index 62737f1..a1cd251 100644
|
||||
--- a/src/lib/longevity/db.ts
|
||||
+++ b/src/lib/longevity/db.ts
|
||||
@@ -127,10 +127,25 @@ function apri(p: string, schema: string): Database.Database {
|
||||
return db;
|
||||
}
|
||||
|
||||
export function createLongevityDb(path?: string): Database.Database {
|
||||
return apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY);
|
||||
}
|
||||
|
||||
export function createIdentityDb(path?: string): Database.Database {
|
||||
return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY);
|
||||
}
|
||||
+
|
||||
+// Connessioni singole per processo, come getDb() in src/lib/db.ts: gli endpoint chiedono
|
||||
+// una connessione già aperta invece di aprirne una nuova a ogni richiesta.
|
||||
+let longevitySingleton: Database.Database | null = null;
|
||||
+let identitySingleton: Database.Database | null = null;
|
||||
+
|
||||
+export function getLongevityDb(): Database.Database {
|
||||
+ if (!longevitySingleton) longevitySingleton = createLongevityDb();
|
||||
+ return longevitySingleton;
|
||||
+}
|
||||
+
|
||||
+export function getIdentityDb(): Database.Database {
|
||||
+ if (!identitySingleton) identitySingleton = createIdentityDb();
|
||||
+ return identitySingleton;
|
||||
+}
|
||||
diff --git a/src/lib/longevity/vista.ts b/src/lib/longevity/vista.ts
|
||||
new file mode 100644
|
||||
index 0000000..4acc370
|
||||
--- /dev/null
|
||||
+++ b/src/lib/longevity/vista.ts
|
||||
@@ -0,0 +1,99 @@
|
||||
+//
|
||||
+// La vista che il questionario in pagina usa. Due responsabilità:
|
||||
+// - campiDelQuestionario: i campi del modulo, letti dal registro (mai una lista nel
|
||||
+// codice) — aggiungere o disattivare una domanda resta una modifica ai dati.
|
||||
+// - salvaDalForm: il salvataggio, con la sola regola che conta davvero — il consenso al
|
||||
+// trattamento dei dati sanitari va DATO, non solo compilato. Il prototipo del cliente
|
||||
+// salvava anche con "No" perché controllava solo che il campo non fosse vuoto: qui il
|
||||
+// consenso si verifica PRIMA di scrivere qualsiasi altra cosa, e un rifiuto non apre
|
||||
+// nessuna sessione. Il rifiuto stesso si registra comunque: è un fatto (chi, quando, a
|
||||
+// cosa) che un domani va dimostrato tanto quanto un assenso.
|
||||
+import type Database from 'better-sqlite3';
|
||||
+import { testAttivi } from './registro';
|
||||
+import { salvaCompilazione, QUEST_VERSION } from './questionario';
|
||||
+
|
||||
+export type CampoQuestionario = {
|
||||
+ id: string;
|
||||
+ etichetta: string;
|
||||
+ tipo: 'num' | 'txt';
|
||||
+ unita?: string;
|
||||
+ min?: number;
|
||||
+ max?: number;
|
||||
+ sottoDominio?: string;
|
||||
+};
|
||||
+
|
||||
+/** I campi del modulo alla data indicata: solo i test_id del questionario (prefisso
|
||||
+ * `q_`, per non confonderli con quelli dei test fisici), attivi a quella data. */
|
||||
+export function campiDelQuestionario(db: Database.Database, alla: string): CampoQuestionario[] {
|
||||
+ return testAttivi(db, alla)
|
||||
+ .filter((v) => v.test_id.startsWith('q_'))
|
||||
+ .map((v) => ({
|
||||
+ id: v.test_id,
|
||||
+ etichetta: v.etichetta,
|
||||
+ tipo: v.tipo_valore,
|
||||
+ unita: v.unita,
|
||||
+ min: v.range_min,
|
||||
+ max: v.range_max,
|
||||
+ sottoDominio: v.sotto_dominio,
|
||||
+ }));
|
||||
+}
|
||||
+
|
||||
+export type EsitoSalvataggio =
|
||||
+ | { ok: true; sessioneId: number }
|
||||
+ | { ok: false; errore: string };
|
||||
+
|
||||
+const CONSENSI_AFFERMATIVI = new Set(['si', 'sì']);
|
||||
+
|
||||
+function consensoDato(risposta: string): boolean {
|
||||
+ return CONSENSI_AFFERMATIVI.has(risposta.trim().toLowerCase());
|
||||
+}
|
||||
+
|
||||
+/** Registra il fatto del consenso — chi, quando, a che versione del questionario —
|
||||
+ * indipendentemente dall'esito. Legata al cliente sempre; alla sessione solo quando il
|
||||
+ * consenso è affermativo e la sessione esiste davvero. */
|
||||
+function registraConsenso(
|
||||
+ db: Database.Database, clientCode: string, sessioneId: number | null, risposta: string
|
||||
+): void {
|
||||
+ const esito = risposta.trim() === '' ? '(non risposto)' : risposta.trim();
|
||||
+ const oggi = new Date().toISOString().slice(0, 10);
|
||||
+ const testo = `Consenso al trattamento dei dati sanitari: ${esito} — questionario ${QUEST_VERSION}, registrato il ${oggi}`;
|
||||
+ db.prepare(
|
||||
+ `INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)`
|
||||
+ ).run(clientCode, sessioneId, 'consenso_sanitario', testo);
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Il salvataggio dal form del questionario. Il consenso si controlla PRIMA di scrivere
|
||||
+ * qualunque altra cosa: se non è affermativo, non si apre nessuna sessione — né misure né
|
||||
+ * note di profilo — e la compilazione non entra nel fascicolo. La nota del consenso si
|
||||
+ * scrive comunque, con l'esito letterale ricevuto.
|
||||
+ */
|
||||
+export function salvaDalForm(
|
||||
+ db: Database.Database,
|
||||
+ input: {
|
||||
+ client_code: string; data: string; eta?: number;
|
||||
+ risposte: Record<string, number>;
|
||||
+ consensoSanitario: string;
|
||||
+ liberi?: Record<string, string>;
|
||||
+ }
|
||||
+): EsitoSalvataggio {
|
||||
+ if (!consensoDato(input.consensoSanitario)) {
|
||||
+ registraConsenso(db, input.client_code, null, input.consensoSanitario);
|
||||
+ return {
|
||||
+ ok: false,
|
||||
+ errore: 'Senza consenso al trattamento dei dati sanitari la compilazione non viene salvata.',
|
||||
+ };
|
||||
+ }
|
||||
+
|
||||
+ const sessioneId = salvaCompilazione(db, {
|
||||
+ client_code: input.client_code,
|
||||
+ data: input.data,
|
||||
+ eta: input.eta,
|
||||
+ risposte: input.risposte,
|
||||
+ liberi: input.liberi,
|
||||
+ });
|
||||
+
|
||||
+ registraConsenso(db, input.client_code, sessioneId, input.consensoSanitario);
|
||||
+
|
||||
+ return { ok: true, sessioneId };
|
||||
+}
|
||||
diff --git a/src/pages/api/longevity/questionario.ts b/src/pages/api/longevity/questionario.ts
|
||||
new file mode 100644
|
||||
index 0000000..5f0909e
|
||||
--- /dev/null
|
||||
+++ b/src/pages/api/longevity/questionario.ts
|
||||
@@ -0,0 +1,55 @@
|
||||
+import type { APIRoute } from 'astro';
|
||||
+import { getLongevityDb } from '../../../lib/longevity/db';
|
||||
+import { codicePerUtenteLoggato } from '../../../lib/longevity/anagrafica';
|
||||
+import { salvaDalForm } from '../../../lib/longevity/vista';
|
||||
+import { CAMPI_LIBERI } from '../../../lib/longevity/questionario';
|
||||
+
|
||||
+export const prerender = false;
|
||||
+
|
||||
+const json = (status: number, body: object) =>
|
||||
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
+
|
||||
+export const POST: APIRoute = async ({ request, locals }) => {
|
||||
+ // Il codice cliente si ricava SEMPRE dalla sessione dell'utente loggato (spec §8), mai dal
|
||||
+ // corpo della richiesta: qui infatti il corpo non viene mai letto per questo campo. La
|
||||
+ // risoluzione passa da anagrafica.ts (l'unico modulo autorizzato a toccare quella
|
||||
+ // connessione): questo file apre solo longevity, per salvare.
|
||||
+ const codice = codicePerUtenteLoggato(locals.user!.id);
|
||||
+ if (!codice) return json(403, { error: 'Nessun fascicolo cliente associato a questo utente.' });
|
||||
+
|
||||
+ let data: Record<string, unknown>;
|
||||
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
+ if (typeof data !== 'object' || data === null) return json(400, { error: 'Dati non validi.' });
|
||||
+
|
||||
+ const risposteIn = data.risposte;
|
||||
+ if (typeof risposteIn !== 'object' || risposteIn === null) {
|
||||
+ return json(400, { error: 'Risposte mancanti.' });
|
||||
+ }
|
||||
+ const risposte: Record<string, number> = {};
|
||||
+ for (const [id, v] of Object.entries(risposteIn as Record<string, unknown>)) {
|
||||
+ if (typeof v === 'number' && Number.isFinite(v)) risposte[id] = v;
|
||||
+ }
|
||||
+
|
||||
+ // Solo i campi liberi dichiarati: un corpo con chiavi arbitrarie non ne inventa di nuove.
|
||||
+ const liberiIn = data.liberi;
|
||||
+ const liberi: Record<string, string> = {};
|
||||
+ if (typeof liberiIn === 'object' && liberiIn !== null) {
|
||||
+ for (const campo of CAMPI_LIBERI) {
|
||||
+ const v = (liberiIn as Record<string, unknown>)[campo];
|
||||
+ if (typeof v === 'string') liberi[campo] = v;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ const consensoSanitario = typeof data.consensoSanitario === 'string' ? data.consensoSanitario : '';
|
||||
+ const eta = typeof data.eta === 'number' && Number.isFinite(data.eta) ? data.eta : undefined;
|
||||
+
|
||||
+ // La data della compilazione è quella del server, non quella (manomettibile) del client.
|
||||
+ const oggi = new Date().toISOString().slice(0, 10);
|
||||
+
|
||||
+ const esito = salvaDalForm(getLongevityDb(), {
|
||||
+ client_code: codice, data: oggi, eta, risposte, consensoSanitario, liberi,
|
||||
+ });
|
||||
+
|
||||
+ if (!esito.ok) return json(422, { error: esito.errore });
|
||||
+ return json(201, { sessioneId: esito.sessioneId });
|
||||
+};
|
||||
diff --git a/src/pages/longevity/questionario.astro b/src/pages/longevity/questionario.astro
|
||||
new file mode 100644
|
||||
index 0000000..57dd505
|
||||
--- /dev/null
|
||||
+++ b/src/pages/longevity/questionario.astro
|
||||
@@ -0,0 +1,22 @@
|
||||
+---
|
||||
+// Il questionario in pagina, sostituisce il prototipo del cliente. La pagina non
|
||||
+// interroga il database da sola: chiede i campi a `campiDelQuestionario` (letti dal
|
||||
+// registro), l'isola React fa il resto — quattro blocchi, e l'invio va a
|
||||
+// /api/longevity/questionario, dove il consenso si verifica davvero.
|
||||
+import Longevity from '../../layouts/Longevity.astro';
|
||||
+import Questionario from '../../components/longevity/Questionario';
|
||||
+import { getLongevityDb } from '../../lib/longevity/db';
|
||||
+import { campiDelQuestionario } from '../../lib/longevity/vista';
|
||||
+
|
||||
+export const prerender = false;
|
||||
+
|
||||
+const oggi = new Date().toISOString().slice(0, 10);
|
||||
+const campi = campiDelQuestionario(getLongevityDb(), oggi);
|
||||
+---
|
||||
+<Longevity title="Questionario" crumbs={['Questionario']}>
|
||||
+ <div class="lg-head">
|
||||
+ <h1>Questionario</h1>
|
||||
+ <p>Rispondi con calma: puoi tornare indietro finché non invii.</p>
|
||||
+ </div>
|
||||
+ <Questionario client:load campi={campi} />
|
||||
+</Longevity>
|
||||
diff --git a/src/styles/longevity.css b/src/styles/longevity.css
|
||||
index 32362e3..d87b2f7 100644
|
||||
--- a/src/styles/longevity.css
|
||||
+++ b/src/styles/longevity.css
|
||||
@@ -72,13 +72,45 @@
|
||||
letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading);
|
||||
}
|
||||
|
||||
.lg-score__bar { height: 6px; background: var(--c-bg-alt); overflow: hidden; }
|
||||
.lg-score__bar-fill { height: 100%; background: var(--c-accent); opacity: var(--lg-intensita, 1); }
|
||||
|
||||
.lg-misura--fuori-range { color: var(--lg-fuori-range); font-weight: 600; }
|
||||
|
||||
.lg-muted { color: var(--c-text-light); }
|
||||
|
||||
+/* --- il questionario: un blocco alla volta, avanti/indietro --- */
|
||||
+.lg-form { display: flex; flex-direction: column; gap: 24px; }
|
||||
+.lg-form__passo {
|
||||
+ font-family: var(--font-heading); font-size: .74rem; letter-spacing: .12em;
|
||||
+ text-transform: uppercase; color: var(--c-text-light); margin: 0;
|
||||
+}
|
||||
+.lg-form__blocco { display: flex; flex-direction: column; gap: 16px; }
|
||||
+.lg-form__blocco h2 { font-size: 1.1rem; margin: 0; }
|
||||
+
|
||||
+.lg-field { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; }
|
||||
+.lg-field > span { color: var(--c-heading); font-weight: 500; }
|
||||
+.lg-field input[type="number"],
|
||||
+.lg-field textarea {
|
||||
+ border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface);
|
||||
+}
|
||||
+.lg-field textarea { min-height: 68px; resize: vertical; }
|
||||
+
|
||||
+.lg-field--consenso { border: 1px solid var(--lg-line); padding: 16px 18px; gap: 10px; }
|
||||
+.lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); }
|
||||
+.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
||||
+
|
||||
+.lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; }
|
||||
+
|
||||
+.lg-form__nav { display: flex; justify-content: space-between; gap: 12px; }
|
||||
+.lg-form__nav button {
|
||||
+ font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .08em;
|
||||
+ text-transform: uppercase; padding: 11px 22px; border: 1px solid var(--c-heading);
|
||||
+ background: transparent; color: var(--c-heading); cursor: pointer;
|
||||
+}
|
||||
+.lg-form__nav button:disabled { opacity: .35; cursor: not-allowed; }
|
||||
+.lg-form__nav button:last-child:not(:only-child) { background: var(--c-accent); border-color: var(--c-accent); color: #fff; }
|
||||
+
|
||||
@media (max-width: 640px) {
|
||||
.lg-top, .lg-main { padding-left: 20px; padding-right: 20px; }
|
||||
}
|
||||
diff --git a/tests/longevity/anagrafica.test.ts b/tests/longevity/anagrafica.test.ts
|
||||
index bf232be..4dfcd0f 100644
|
||||
--- a/tests/longevity/anagrafica.test.ts
|
||||
+++ b/tests/longevity/anagrafica.test.ts
|
||||
@@ -81,35 +81,43 @@ describe('anagrafica pseudonimizzata', () => {
|
||||
// su longevity fallisce per costruzione, dopo che quella su identity è già avvenuta.
|
||||
expect(() =>
|
||||
creaCliente(id, lg, { nome: 'Errato', cognome: 'Errato', sesso: 'Z' as unknown as 'M' | 'F' })
|
||||
).toThrow();
|
||||
|
||||
const dopoIlTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n;
|
||||
expect(dopoIlTentativo).toBe(primaDelTentativo); // nessun cliente orfano rimasto in identity
|
||||
expect(id.prepare(`SELECT * FROM clienti WHERE nome = 'Errato'`).get()).toBeUndefined();
|
||||
});
|
||||
|
||||
- it('nessun file in src/ (escluso anagrafica.ts e db.ts) apre entrambe le connessioni', () => {
|
||||
+ it('nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento sia a identity sia a longevity', () => {
|
||||
+ // Non cerchiamo due nomi di funzione precisi (createIdentityDb/createLongevityDb): un
|
||||
+ // domani qualcuno aggiunge getIdentityDb, o un terzo accessore con un nome diverso, e
|
||||
+ // l'elenco invecchia senza che nessuno se ne accorga (è già successo: getIdentityDb e
|
||||
+ // getLongevityDb sono nati DOPO questo test e sono passati indisturbati). Il verso
|
||||
+ // giusto è strutturale: qualunque accesso alle due connessioni passa comunque per una
|
||||
+ // funzione esportata da db.ts il cui nome contiene "Identity" o "Longevity" — quindi
|
||||
+ // basta cercare quei due riferimenti letterali, in QUALUNQUE forma compaiano, e
|
||||
+ // segnalare i file che li hanno tutti e due.
|
||||
const srcDir = join(process.cwd(), 'src');
|
||||
const colpevoli: string[] = [];
|
||||
|
||||
const files = readdirSync(srcDir, { recursive: true, withFileTypes: false }) as string[];
|
||||
for (const f of files) {
|
||||
const path = join(srcDir, f);
|
||||
|
||||
// Salta node_modules e dist
|
||||
if (f.includes('node_modules') || f.includes('dist')) continue;
|
||||
|
||||
// Accetta solo .ts e .astro
|
||||
if (!f.endsWith('.ts') && !f.endsWith('.astro')) continue;
|
||||
|
||||
// Esclude i moduli autorizzati
|
||||
if (f === 'lib/longevity/anagrafica.ts' || f === 'lib/longevity/db.ts') continue;
|
||||
|
||||
const src = readFileSync(path, 'utf8');
|
||||
- if (src.includes('createIdentityDb') && src.includes('createLongevityDb')) {
|
||||
+ if (src.includes('Identity') && src.includes('Longevity')) {
|
||||
colpevoli.push(f);
|
||||
}
|
||||
}
|
||||
expect(colpevoli).toEqual([]);
|
||||
});
|
||||
});
|
||||
diff --git a/tests/longevity/questionario-pagina.test.ts b/tests/longevity/questionario-pagina.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..225de9b
|
||||
--- /dev/null
|
||||
+++ b/tests/longevity/questionario-pagina.test.ts
|
||||
@@ -0,0 +1,87 @@
|
||||
+import { describe, it, expect } from 'vitest';
|
||||
+import { readFileSync } from 'node:fs';
|
||||
+import { join } from 'node:path';
|
||||
+import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||
+import { seedRegistro } from '../../src/lib/longevity/registro';
|
||||
+import { campiDelQuestionario, salvaDalForm } from '../../src/lib/longevity/vista';
|
||||
+
|
||||
+function dbPronto() {
|
||||
+ const db = createLongevityDb(':memory:');
|
||||
+ seedRegistro(db);
|
||||
+ db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||
+ return db;
|
||||
+}
|
||||
+
|
||||
+describe('i campi del questionario vengono dal registro', () => {
|
||||
+ it('costruisce i campi leggendo il registro, non una lista nel codice', () => {
|
||||
+ const db = dbPronto();
|
||||
+ const campi = campiDelQuestionario(db, '2026-08-22');
|
||||
+ expect(campi.length).toBe(20);
|
||||
+ const alcol = campi.find((c) => c.id === 'q_alcol_life')!;
|
||||
+ expect(alcol.etichetta).toMatch(/alcoliche/i);
|
||||
+ expect(alcol.max).toBe(30);
|
||||
+ });
|
||||
+
|
||||
+ it('una domanda disattivata sparisce dal modulo senza toccare il codice', () => {
|
||||
+ const db = dbPronto();
|
||||
+ db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_sigarette'`).run();
|
||||
+ const campi = campiDelQuestionario(db, '2026-08-22');
|
||||
+ expect(campi.map((c) => c.id)).not.toContain('q_sigarette');
|
||||
+ expect(campi.length).toBe(19);
|
||||
+ });
|
||||
+});
|
||||
+
|
||||
+describe('il salvataggio, e il consenso', () => {
|
||||
+ const risposte = { q_riposato: 8, q_ore_sonno: 7.5 };
|
||||
+
|
||||
+ it('salva una compilazione quando il consenso e dato', () => {
|
||||
+ const db = dbPronto();
|
||||
+ const esito = salvaDalForm(db, {
|
||||
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
+ risposte, consensoSanitario: 'Si', liberi: {},
|
||||
+ });
|
||||
+ expect(esito.ok).toBe(true);
|
||||
+ const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
|
||||
+ expect(n.n).toBe(1);
|
||||
+ });
|
||||
+
|
||||
+ it('NON salva se il consenso e rifiutato: "No" non e un consenso', () => {
|
||||
+ const db = dbPronto();
|
||||
+ const esito = salvaDalForm(db, {
|
||||
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
+ risposte, consensoSanitario: 'No', liberi: {},
|
||||
+ });
|
||||
+ expect(esito.ok).toBe(false);
|
||||
+ const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
|
||||
+ expect(n.n).toBe(0); // niente sessione, niente misure
|
||||
+ });
|
||||
+
|
||||
+ it('NON salva se il consenso manca del tutto', () => {
|
||||
+ const db = dbPronto();
|
||||
+ const esito = salvaDalForm(db, {
|
||||
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
+ risposte, consensoSanitario: '', liberi: {},
|
||||
+ });
|
||||
+ expect(esito.ok).toBe(false);
|
||||
+ });
|
||||
+
|
||||
+ it('registra il consenso anche quando e rifiutato: il rifiuto va dimostrato', () => {
|
||||
+ const db = dbPronto();
|
||||
+ salvaDalForm(db, {
|
||||
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||
+ risposte, consensoSanitario: 'No', liberi: {},
|
||||
+ });
|
||||
+ const note = db.prepare(
|
||||
+ `SELECT campo_id, testo FROM profilo_note WHERE campo_id LIKE 'consenso%'`
|
||||
+ ).all() as { campo_id: string; testo: string }[];
|
||||
+ expect(note.length).toBeGreaterThan(0);
|
||||
+ expect(note[0].testo).toMatch(/No/);
|
||||
+ });
|
||||
+
|
||||
+ it('il codice cliente NON puo arrivare da chi chiama la pagina', () => {
|
||||
+ const src = readFileSync(join(process.cwd(), 'src/pages/api/longevity/questionario.ts'), 'utf8');
|
||||
+ // il codice si prende dalla sessione dell'utente, mai dal corpo della richiesta
|
||||
+ expect(src).toMatch(/codicePerUtente/);
|
||||
+ expect(src).not.toMatch(/body\.client_code|body\.clientId|corpo\.client_code/);
|
||||
+ });
|
||||
+});
|
||||
Reference in New Issue
Block a user