longevity: il questionario in pagina, e il consenso che si puo negare davvero

Sostituisce il prototipo del cliente (salvava anche con "No" al consenso
sanitario, perche' controllava solo che il campo non fosse vuoto). Qui il
consenso si verifica PRIMA di scrivere qualunque dato: se non e' affermativo
non si apre nessuna sessione, e la risposta lo dice. Il rifiuto si registra
comunque fra le note di profilo, con esito e versione del questionario: va
dimostrato tanto quanto un assenso.

- src/lib/longevity/vista.ts: campiDelQuestionario (dal registro, non da una
  lista nel codice) e salvaDalForm (consenso prima di tutto, poi delega a
  salvaCompilazione).
- src/pages/api/longevity/questionario.ts: il client_code arriva SEMPRE da
  codicePerUtente sulla sessione, mai dal corpo della richiesta.
- src/lib/longevity/db.ts: getLongevityDb/getIdentityDb, connessioni singole
  per processo (come getDb() del sito), cosi' l'endpoint non apre una nuova
  connessione a ogni richiesta.
- src/components/longevity/Questionario.tsx + pages/longevity/questionario.astro:
  isola React a quattro blocchi (le tre aree di domande + note/consenso), la
  pagina non interroga mai il database da sola.
This commit is contained in:
2026-08-22 13:55:16 +02:00
parent 2ea822a84d
commit f5ca841d92
7 changed files with 482 additions and 0 deletions
+174
View File
@@ -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')}
/>
, 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>
);
}
+15
View File
@@ -134,3 +134,18 @@ export function createLongevityDb(path?: string): Database.Database {
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;
}
+99
View File
@@ -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 };
}
+53
View File
@@ -0,0 +1,53 @@
import type { APIRoute } from 'astro';
import { getIdentityDb, getLongevityDb } from '../../../lib/longevity/db';
import { codicePerUtente } 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.
const codice = codicePerUtente(getIdentityDb(), 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 });
};
+22
View File
@@ -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>
+32
View File
@@ -79,6 +79,38 @@
.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; }
}