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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -134,3 +134,18 @@ export function createLongevityDb(path?: string): Database.Database {
|
|||||||
export function createIdentityDb(path?: string): Database.Database {
|
export function createIdentityDb(path?: string): Database.Database {
|
||||||
return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -79,6 +79,38 @@
|
|||||||
|
|
||||||
.lg-muted { color: var(--c-text-light); }
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.lg-top, .lg-main { padding-left: 20px; padding-right: 20px; }
|
.lg-top, .lg-main { padding-left: 20px; padding-right: 20px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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