Files
InsanityLab-web/apps/sito/docs/plans/2026-08-21-longevity-strato-dati.md
T
AdrianoDev 510d7ca4ec Struttura: il sito esistente si sposta sotto apps/sito
Primo passo della separazione sito / piattaforme decisa da Adriano il 03/09:
due applicazioni autosufficienti, senza workspace di root, cosi' che ognuna
resti un progetto Astro completo ed estraibile.

Qui non cambia niente del codice: solo la radice. I pattern del .gitignore
che avevano una barra erano ancorati alla radice e dopo lo spostamento non
avrebbero piu' coperto niente (data/*.db*, uploads/*): resi validi a
qualunque profondita'.

Suite: 45 file, 347 test verdi prima e dopo lo spostamento.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 11:05:54 +00:00

1295 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Longevity — strato dati: piano di implementazione
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** costruire lo strato dati del fascicolo cliente — i due database, il registro dei test, la registrazione delle misure, l'anagrafica pseudonimizzata e il salvataggio del questionario — testato e senza interfaccia.
**Architecture:** due file SQLite affiancati a quello del sito. `identity.db` tiene il legame fra codice cliente e persona; `longevity.db` tiene misure, questionari e punteggi e non contiene mai un nome. Le misure sono un registro (una riga per valore, da qualunque sorgente) validato contro una tabella di test che è dato e non codice. Un solo modulo apre entrambe le connessioni.
**Tech Stack:** TypeScript, better-sqlite3, vitest. Nessuna dipendenza nuova.
**Spec:** `docs/specs/2026-08-21-longevity-design.md`
## Global Constraints
- Branch **`feat/longevity`**. `main` non si tocca, non si deploya, non si pusha senza che Adriano lo chieda.
- Nessuna dipendenza npm nuova: better-sqlite3 e vitest ci sono già.
- Si segue il pattern di `src/lib/db.ts`: funzione `createXDb(path?)`, `journal_mode = WAL`, `foreign_keys = ON`, schema idempotente eseguito all'apertura.
- Test in `tests/longevity/`, eseguiti con `npm test` (vitest). I test usano `':memory:'`.
- **Un solo modulo** (`src/lib/longevity/anagrafica.ts`) può importare insieme le due connessioni. Un test lo verifica.
- **Nessun dato reale di persone nelle fixture.** I casi di prova sono sintetici.
- Soglia di copertura: **0.40**, dichiarata come costante esportata, mai scritta a mano nei rami.
- `model_version` iniziale: **`v1.0`**. `quest_version` iniziale: **`v1.0`** (è quella che Nicola ha già messo nel prototipo).
## Perimetro di questo piano
Questo piano costruisce **lo strato dati e basta**: alla fine esiste una libreria testata che sa registrare un cliente, registrare misure da qualunque sorgente, salvare una compilazione di questionario ed esportare i dati pseudonimizzati.
**Fuori da questo piano**, e ognuno avrà il suo: il motore di calcolo degli score (porting delle curve dal Python, con verificatore), le pagine web del questionario e della dashboard, gli import da Wellness Tower/VALD/Calibre.
Il motivo dell'ordine è che tutto il resto legge o scrive questi dati: costruirlo per primo evita che tre pezzi inventino tre modelli diversi.
## Struttura dei file
| File | Responsabilità |
|---|---|
| `src/lib/longevity/db.ts` | apertura e schema dei due database |
| `src/lib/longevity/registro.ts` | registro dei test e tabella pesi: seed e query |
| `src/lib/longevity/misure.ts` | `registraMisure`, l'unico varco di scrittura |
| `src/lib/longevity/anagrafica.ts` | **l'unico** modulo che unisce identity e longevity |
| `src/lib/longevity/questionario-def.ts` | i 20 campi punteggiati come dato |
| `src/lib/longevity/questionario.ts` | salvataggio di una compilazione |
| `src/lib/longevity/export.ts` | export pseudonimizzato |
| `src/lib/auth.ts` | *(modifica)* ruoli `cliente` e `trainer` |
---
### Task 1: Schema dei due database
**Files:**
- Create: `src/lib/longevity/db.ts`
- Test: `tests/longevity/db.test.ts`
**Interfaces:**
- Consumes: niente
- Produces: `createLongevityDb(path?: string): Database.Database`, `createIdentityDb(path?: string): Database.Database`, `COPERTURA_MINIMA: number` (0.40)
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/db.test.ts
import { describe, it, expect } from 'vitest';
import { createLongevityDb, createIdentityDb, COPERTURA_MINIMA } from '../../src/lib/longevity/db';
describe('schema longevity', () => {
it('crea le tabelle previste dalla spec', () => {
const db = createLongevityDb(':memory:');
const nomi = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[])
.map((r) => r.name);
expect(nomi).toEqual(
expect.arrayContaining(['soggetti', 'sessioni', 'misure', 'registro_test', 'pesi', 'profilo_note', 'score'])
);
});
it('rifiuta un sesso non previsto', () => {
const db = createLongevityDb(':memory:');
expect(() =>
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'X')`).run()
).toThrow();
});
it('rifiuta una misura con test_id non nel registro', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
expect(() =>
db.prepare(
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, fonte)
VALUES (1, 'ISL-0001', 'test_inventato', 10, 'manuale')`
).run()
).toThrow();
});
it('uno score insufficiente puo non avere valore', () => {
const db = createLongevityDb(':memory:');
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
db.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001', '2026-08-21', 'checkup')`).run();
db.prepare(
`INSERT INTO score (client_code, sessione_id, tipo, nome, valore, copertura, stato, model_version)
VALUES ('ISL-0001', 1, 'asse', 'Forza & Struttura', NULL, 0.2, 'insufficiente', 'v1.0')`
).run();
const row = db.prepare(`SELECT valore, stato FROM score`).get() as { valore: number | null; stato: string };
expect(row.valore).toBeNull();
expect(row.stato).toBe('insufficiente');
});
it('identity tiene la persona, longevity non la conosce', () => {
const id = createIdentityDb(':memory:');
const cols = (id.prepare(`PRAGMA table_info(clienti)`).all() as { name: string }[]).map((c) => c.name);
expect(cols).toEqual(expect.arrayContaining(['client_code', 'user_id', 'nome', 'cognome', 'data_nascita']));
const lg = createLongevityDb(':memory:');
const tutte = (lg.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[])
.flatMap((t) => (lg.prepare(`PRAGMA table_info(${t.name})`).all() as { name: string }[]).map((c) => c.name));
expect(tutte).not.toContain('nome');
expect(tutte).not.toContain('cognome');
expect(tutte).not.toContain('data_nascita');
});
it('la soglia di copertura e 0.40', () => {
expect(COPERTURA_MINIMA).toBe(0.4);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/db.test.ts`
Expected: FAIL — "Cannot find module '../../src/lib/longevity/db'"
- [ ] **Step 3: Write minimal implementation**
```ts
// src/lib/longevity/db.ts
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
/** Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore. */
export const COPERTURA_MINIMA = 0.4;
const SCHEMA_LONGEVITY = `
CREATE TABLE IF NOT EXISTS soggetti (
client_code TEXT PRIMARY KEY,
sesso TEXT NOT NULL CHECK (sesso IN ('M','F')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS registro_test (
test_id TEXT PRIMARY KEY,
etichetta TEXT NOT NULL,
unita TEXT,
tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')),
curva TEXT,
params TEXT,
asse TEXT,
sotto_dominio TEXT,
range_min REAL,
range_max REAL,
attivo_da TEXT NOT NULL,
attivo_a TEXT,
note TEXT
);
CREATE TABLE IF NOT EXISTS sessioni (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
data TEXT NOT NULL,
tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')),
eta_alla_data INTEGER,
operatore TEXT,
note TEXT,
quest_version TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_sessioni_cliente ON sessioni (client_code, data DESC);
CREATE TABLE IF NOT EXISTS misure (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
test_id TEXT NOT NULL REFERENCES registro_test(test_id),
valore_num REAL,
valore_txt TEXT,
unita TEXT,
fonte TEXT NOT NULL CHECK (fonte IN
('manuale','questionario','wellness_tower','vald','calibre','stress_index')),
fuori_range INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id);
CREATE INDEX IF NOT EXISTS idx_misure_sessione ON misure (sessione_id);
CREATE TABLE IF NOT EXISTS pesi (
model_version TEXT NOT NULL,
livello TEXT NOT NULL CHECK (livello IN ('asse','macro','fitness_age')),
contenitore TEXT NOT NULL,
elemento TEXT NOT NULL,
peso REAL NOT NULL,
PRIMARY KEY (model_version, livello, contenitore, elemento)
);
CREATE TABLE IF NOT EXISTS profilo_note (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
sessione_id INTEGER REFERENCES sessioni(id),
campo_id TEXT NOT NULL,
testo TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS score (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_code TEXT NOT NULL REFERENCES soggetti(client_code),
sessione_id INTEGER NOT NULL REFERENCES sessioni(id),
tipo TEXT NOT NULL CHECK (tipo IN ('asse','macro','fitness_age')),
nome TEXT NOT NULL,
valore REAL,
copertura REAL NOT NULL,
stato TEXT NOT NULL CHECK (stato IN ('ok','insufficiente')),
quest_version TEXT,
model_version TEXT NOT NULL,
calcolato_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_score_cliente ON score (client_code, calcolato_at DESC);
`;
const SCHEMA_IDENTITY = `
CREATE TABLE IF NOT EXISTS clienti (
client_code TEXT PRIMARY KEY,
user_id INTEGER,
nome TEXT NOT NULL,
cognome TEXT NOT NULL,
data_nascita TEXT,
email TEXT,
telefono TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_clienti_user ON clienti (user_id);
`;
function apri(p: string, schema: string): Database.Database {
if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true });
const db = new Database(p);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(schema);
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);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/db.test.ts`
Expected: PASS, 6 test
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/db.ts tests/longevity/db.test.ts
git commit -m "longevity: schema dei due database, identity separato da longevity"
```
---
### Task 2: Registro dei test e pesi — seed e query
**Files:**
- Create: `src/lib/longevity/registro.ts`
- Test: `tests/longevity/registro.test.ts`
**Interfaces:**
- Consumes: `createLongevityDb` da Task 1
- Produces:
- `type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep'`
- `type VoceRegistro = { test_id: string; etichetta: string; unita?: string; tipo_valore: 'num'|'txt'; curva?: Curva; params?: unknown; asse?: string; sotto_dominio?: string; range_min?: number; range_max?: number; attivo_da: string; attivo_a?: string }`
- `seedRegistro(db: Database.Database): void`
- `seedPesi(db: Database.Database, modelVersion: string): void`
- `testAttivi(db: Database.Database, alla: string): VoceRegistro[]`
- `esisteTest(db: Database.Database, testId: string): boolean`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/registro.test.ts
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, testAttivi, esisteTest } from '../../src/lib/longevity/registro';
describe('registro dei test', () => {
it('carica i 20 campi punteggiati del questionario', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
const q = db.prepare(`SELECT COUNT(*) n FROM registro_test WHERE test_id LIKE 'q_%'`).get() as { n: number };
expect(q.n).toBe(20);
});
it('alcol_life porta la soglia 7-14 voluta da Nicola', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
const row = db.prepare(`SELECT curva, params FROM registro_test WHERE test_id = 'q_alcol_life'`)
.get() as { curva: string; params: string };
expect(row.curva).toBe('lin_dec');
expect(JSON.parse(row.params)).toEqual({ best: 7, worst: 14 });
});
it('calo_pomeridiano usa la curva invertita, la quinta che al Python manca', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
const row = db.prepare(`SELECT curva FROM registro_test WHERE test_id = 'q_calo_pomeridiano'`)
.get() as { curva: string };
expect(row.curva).toBe('x10_inv');
});
it('un test disattivato non e piu attivo dopo la sua data', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`UPDATE registro_test SET attivo_a = '2026-08-01' WHERE test_id = 'q_sigarette'`).run();
const attivi = testAttivi(db, '2026-08-21').map((t) => t.test_id);
expect(attivi).not.toContain('q_sigarette');
expect(testAttivi(db, '2026-07-01').map((t) => t.test_id)).toContain('q_sigarette');
});
it('esisteTest distingue un test vero da uno inventato', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
expect(esisteTest(db, 'q_ore_sonno')).toBe(true);
expect(esisteTest(db, 'q_inventato')).toBe(false);
});
it('i pesi di ogni asse sommano a 1', () => {
const db = createLongevityDb(':memory:');
seedPesi(db, 'v1.0');
const righe = db.prepare(
`SELECT contenitore, ROUND(SUM(peso), 6) tot FROM pesi
WHERE model_version = 'v1.0' AND livello = 'asse' GROUP BY contenitore`
).all() as { contenitore: string; tot: number }[];
expect(righe.length).toBe(7);
for (const r of righe) expect(r.tot).toBe(1);
});
it('i pesi sono legati alla versione del modello', () => {
const db = createLongevityDb(':memory:');
seedPesi(db, 'v1.0');
seedPesi(db, 'v2.0');
const n = db.prepare(`SELECT COUNT(DISTINCT model_version) n FROM pesi`).get() as { n: number };
expect(n.n).toBe(2);
});
it('il plank pesa su due assi: va visto, non nascosto', () => {
const db = createLongevityDb(':memory:');
seedPesi(db, 'v1.0');
const righe = db.prepare(
`SELECT contenitore FROM pesi WHERE model_version='v1.0' AND livello='asse' AND elemento IN ('core','plank')`
).all() as { contenitore: string }[];
expect(righe.map((r) => r.contenitore).sort())
.toEqual(['Forza & Struttura', 'Stabilità & Mobilità Funzionale']);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/registro.test.ts`
Expected: FAIL — modulo `registro` non trovato
- [ ] **Step 3: Write minimal implementation**
```ts
// src/lib/longevity/registro.ts
import type Database from 'better-sqlite3';
export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep';
export type VoceRegistro = {
test_id: string;
etichetta: string;
unita?: string;
tipo_valore: 'num' | 'txt';
curva?: Curva;
params?: unknown;
asse?: string;
sotto_dominio?: string;
range_min?: number;
range_max?: number;
attivo_da: string;
attivo_a?: string;
};
const DA = '2026-01-01';
// I 20 campi punteggiati, presi dal prototipo del cliente (questionario_longevity_score.html).
// Gli id portano il prefisso q_ per distinguerli dai test fisici; i params sono nella forma
// dichiarata per ciascuna curva, non nell'array posizionale del prototipo.
const QUESTIONARIO: VoceRegistro[] = [
{ test_id: 'q_ore_sonno', etichetta: 'Ore di sonno per notte (media)', unita: 'h', tipo_valore: 'num',
curva: 'bell', params: { low: 4, peakLow: 7, peakHigh: 9, high: 12 },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_min_addorm', etichetta: 'Minuti per addormentarti', unita: 'min', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[15, 100], [30, 75], [60, 50], [999, 25]] },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 180, attivo_da: DA },
{ test_id: 'q_risvegli', etichetta: 'Risvegli notturni (numero)', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[0, 100], [1, 80], [2, 50], [999, 25]] },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_riposato', etichetta: 'Quanto ti senti riposato al risveglio', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_caffeina', etichetta: 'Caffeina dopo le 16:00 (volte/settimana)', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_sonnolenza_diurna', etichetta: 'Sonnolenza/fatica a concentrarti durante il giorno (volte/settimana)',
tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Recupero & Sistema Nervoso', sotto_dominio: 'questionario_sonno', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_energia_media', etichetta: 'Energia media nella giornata, ultima settimana', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_esaurimento', etichetta: 'Episodi di esaurimento senza motivo fisico (volte/sett.)', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_calo_pomeridiano', etichetta: 'Quanto forte e il calo di energia dal mattino al pomeriggio',
tipo_valore: 'num', curva: 'x10_inv', params: {},
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_sopraffatto', etichetta: 'Sopraffatto da imprevisti (volte/mese)', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[2, 100], [5, 70], [10, 40], [999, 20]] },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 30, attivo_da: DA },
{ test_id: 'q_controllo', etichetta: 'Percezione di controllo sulla tua vita', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_sicurezza_gestione', etichetta: 'Ti senti sicuro/a nella tua capacita di gestire i problemi personali',
tipo_valore: 'num', curva: 'x10', params: {},
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_tensione', etichetta: 'Tensione/irritabilita (volte/settimana)', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 0, worst: 7 },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 14, attivo_da: DA },
{ test_id: 'q_pensieri_lavoro', etichetta: 'Ore/giorno di pensieri di lavoro fuori orario', unita: 'h',
tipo_valore: 'num', curva: 'lin_dec', params: { best: 0, worst: 4 },
asse: 'Energia & Regolazione Stress', sotto_dominio: 'questionario_energia_stress', range_min: 0, range_max: 8, attivo_da: DA },
{ test_id: 'q_attivita', etichetta: 'Giorni/settimana attivita fisica extra ISL', tipo_valore: 'num',
curva: 'incstep', params: { steps: [[0, 20], [2, 50], [4, 80], [999, 100]] },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 7, attivo_da: DA },
{ test_id: 'q_alimentazione', etichetta: 'Qualita percepita alimentazione abituale', tipo_valore: 'num',
curva: 'x10', params: {}, asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle',
range_min: 0, range_max: 10, attivo_da: DA },
{ test_id: 'q_sigarette', etichetta: 'Sigarette al giorno', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]] },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 40, attivo_da: DA },
// Soglia 0-7 = punteggio pieno: scelta deliberata del cliente (basso rischio NIAAA/WHO),
// confermata il 21/08. Non e' un refuso per 0=100 -> 7=0.
{ test_id: 'q_alcol_life', etichetta: 'Unita alcoliche/settimana', tipo_valore: 'num',
curva: 'lin_dec', params: { best: 7, worst: 14 },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 30, attivo_da: DA },
{ test_id: 'q_luce', etichetta: 'Ore/giorno luce naturale/esterno', unita: 'h', tipo_valore: 'num',
curva: 'incstep', params: { steps: [[0.5, 60], [1, 90], [999, 100]], zeroVal: 20 },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 8, attivo_da: DA },
{ test_id: 'q_schermi', etichetta: 'Minuti schermi nell\'ora pre-sonno', unita: 'min', tipo_valore: 'num',
curva: 'decstep', params: { steps: [[15, 85], [30, 70], [60, 40], [999, 10]], zeroVal: 100 },
asse: 'Stile di Vita & Sonno', sotto_dominio: 'questionario_lifestyle', range_min: 0, range_max: 90, attivo_da: DA },
];
export function seedRegistro(db: Database.Database): void {
const ins = db.prepare(
`INSERT OR REPLACE INTO registro_test
(test_id, etichetta, unita, tipo_valore, curva, params, asse, sotto_dominio,
range_min, range_max, attivo_da, attivo_a, note)
VALUES (@test_id, @etichetta, @unita, @tipo_valore, @curva, @params, @asse, @sotto_dominio,
@range_min, @range_max, @attivo_da, @attivo_a, @note)`
);
const tx = db.transaction((voci: VoceRegistro[]) => {
for (const v of voci) {
ins.run({
test_id: v.test_id, etichetta: v.etichetta, unita: v.unita ?? null,
tipo_valore: v.tipo_valore, curva: v.curva ?? null,
params: v.params === undefined ? null : JSON.stringify(v.params),
asse: v.asse ?? null, sotto_dominio: v.sotto_dominio ?? null,
range_min: v.range_min ?? null, range_max: v.range_max ?? null,
attivo_da: v.attivo_da, attivo_a: v.attivo_a ?? null, note: null,
});
}
});
tx(QUESTIONARIO);
}
// Pesi presi dal motore del cliente (AXIS_SUBDOMAIN_WEIGHTS, MACRO_SCORE_WEIGHTS,
// FITNESS_AGE_WEIGHTS). I macro con peso 0 non si inseriscono: assenza e zero sono
// la stessa cosa per la rinormalizzazione, e una riga a zero confonde chi legge.
const PESI_ASSE: Record<string, Record<string, number>> = {
'Forza & Struttura': { handgrip: 0.25, spinta: 0.20, trazione: 0.20, arti_inferiori: 0.20, core: 0.15 },
'Composizione Corporea': { grasso: 0.40, muscolo: 0.35, whr: 0.25 },
'Cardio-Respiratorio': { vo2max: 0.60, spirometria: 0.20, wellness_tower_cardio: 0.20 },
'Recupero & Sistema Nervoso': { hrv: 0.50, pressione: 0.15, hrr: 0.15, questionario_sonno: 0.20 },
'Energia & Regolazione Stress': { hrv: 0.50, questionario_energia_stress: 0.50 },
'Stabilità & Mobilità Funzionale': { flamingo: 0.35, sit_and_reach: 0.25, plank: 0.20, back_scratch: 0.14, wellness_tower_shoulder: 0.06 },
'Stile di Vita & Sonno': { questionario_lifestyle: 1.00 },
};
const PESI_MACRO: Record<string, Record<string, number>> = {
PERFORMANCE: {
'Forza & Struttura': 0.25, 'Cardio-Respiratorio': 0.25, 'Composizione Corporea': 0.15,
'Stabilità & Mobilità Funzionale': 0.20, 'Recupero & Sistema Nervoso': 0.10,
'Energia & Regolazione Stress': 0.05,
},
ENERGY: {
'Energia & Regolazione Stress': 0.30, 'Stile di Vita & Sonno': 0.20,
'Recupero & Sistema Nervoso': 0.25, 'Cardio-Respiratorio': 0.15, 'Composizione Corporea': 0.10,
},
RECOVERY: {
'Recupero & Sistema Nervoso': 0.45, 'Energia & Regolazione Stress': 0.15,
'Stile di Vita & Sonno': 0.20, 'Stabilità & Mobilità Funzionale': 0.15, 'Forza & Struttura': 0.05,
},
};
const PESI_FITNESS_AGE: Record<string, number> = {
cardio: 0.30, handgrip_isolato: 0.20, hrv_isolato: 0.20,
forza_resto: 0.15, composizione: 0.10, stabilita: 0.05,
};
export function seedPesi(db: Database.Database, modelVersion: string): void {
const ins = db.prepare(
`INSERT OR REPLACE INTO pesi (model_version, livello, contenitore, elemento, peso)
VALUES (?, ?, ?, ?, ?)`
);
const tx = db.transaction(() => {
for (const [asse, sd] of Object.entries(PESI_ASSE))
for (const [el, p] of Object.entries(sd)) ins.run(modelVersion, 'asse', asse, el, p);
for (const [macro, assi] of Object.entries(PESI_MACRO))
for (const [el, p] of Object.entries(assi)) ins.run(modelVersion, 'macro', macro, el, p);
for (const [el, p] of Object.entries(PESI_FITNESS_AGE))
ins.run(modelVersion, 'fitness_age', 'fitness_age', el, p);
});
tx();
}
export function testAttivi(db: Database.Database, alla: string): VoceRegistro[] {
const righe = db.prepare(
`SELECT * FROM registro_test
WHERE attivo_da <= ? AND (attivo_a IS NULL OR attivo_a > ?)
ORDER BY test_id`
).all(alla, alla) as Record<string, unknown>[];
return righe.map((r) => ({
...r,
params: r.params ? JSON.parse(r.params as string) : undefined,
})) as VoceRegistro[];
}
export function esisteTest(db: Database.Database, testId: string): boolean {
const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId);
return r !== undefined;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/registro.test.ts`
Expected: PASS, 8 test
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/registro.ts tests/longevity/registro.test.ts
git commit -m "longevity: registro dei test e pesi versionati come dato"
```
---
### Task 3: registraMisure — l'unico varco di scrittura
**Files:**
- Create: `src/lib/longevity/misure.ts`
- Test: `tests/longevity/misure.test.ts`
**Interfaces:**
- Consumes: `createLongevityDb` (Task 1), `esisteTest`, `seedRegistro` (Task 2)
- Produces:
- `type Fonte = 'manuale'|'questionario'|'wellness_tower'|'vald'|'calibre'|'stress_index'`
- `type MisuraIn = { test_id: string; valore_num?: number; valore_txt?: string; unita?: string }`
- `apriSessione(db, s: { client_code: string; data: string; tipo: 'checkup'|'questionario'; eta_alla_data?: number; operatore?: string; quest_version?: string }): number`
- `registraMisure(db, sessioneId: number, fonte: Fonte, misure: MisuraIn[]): { scritte: number; fuoriRange: string[] }`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/misure.test.ts
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
return db;
}
describe('registrazione delle misure', () => {
it('scrive le misure di una sessione', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' });
const esito = registraMisure(db, s, 'questionario', [
{ test_id: 'q_ore_sonno', valore_num: 7.5 },
{ test_id: 'q_riposato', valore_num: 8 },
]);
expect(esito.scritte).toBe(2);
const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ?`).get(s) as { n: number };
expect(n.n).toBe(2);
});
it('rifiuta un test sconosciuto e non scrive niente del lotto', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'checkup' });
expect(() =>
registraMisure(db, s, 'manuale', [
{ test_id: 'q_ore_sonno', valore_num: 7 },
{ test_id: 'test_inventato', valore_num: 1 },
])
).toThrow(/test_inventato/);
const n = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number };
expect(n.n).toBe(0);
});
it('un valore fuori dal range atteso si scrive e si marca', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario' });
const esito = registraMisure(db, s, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]);
expect(esito.fuoriRange).toEqual(['q_ore_sonno']);
const row = db.prepare(`SELECT fuori_range FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { fuori_range: number };
expect(row.fuori_range).toBe(1);
});
it('la sessione porta la versione del questionario', () => {
const db = dbPronto();
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-21', tipo: 'questionario', quest_version: 'v1.0' });
const row = db.prepare(`SELECT quest_version FROM sessioni WHERE id = ?`).get(s) as { quest_version: string };
expect(row.quest_version).toBe('v1.0');
});
it('il client_code della misura viene dalla sessione, non da chi chiama', () => {
const db = dbPronto();
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002', 'M')`).run();
const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-21', tipo: 'checkup' });
registraMisure(db, s, 'manuale', [{ test_id: 'q_riposato', valore_num: 5 }]);
const row = db.prepare(`SELECT client_code FROM misure`).get() as { client_code: string };
expect(row.client_code).toBe('ISL-0002');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/misure.test.ts`
Expected: FAIL — modulo `misure` non trovato
- [ ] **Step 3: Write minimal implementation**
```ts
// src/lib/longevity/misure.ts
import type Database from 'better-sqlite3';
import { esisteTest } from './registro';
export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index';
export type MisuraIn = {
test_id: string;
valore_num?: number;
valore_txt?: string;
unita?: string;
};
export function apriSessione(
db: Database.Database,
s: {
client_code: string; data: string; tipo: 'checkup' | 'questionario';
eta_alla_data?: number; operatore?: string; quest_version?: string;
}
): number {
const r = db.prepare(
`INSERT INTO sessioni (client_code, data, tipo, eta_alla_data, operatore, quest_version)
VALUES (?, ?, ?, ?, ?, ?)`
).run(s.client_code, s.data, s.tipo, s.eta_alla_data ?? null, s.operatore ?? null, s.quest_version ?? null);
return Number(r.lastInsertRowid);
}
/**
* L'unico punto in cui si scrive nella tabella `misure`.
* Un test_id sconosciuto fa fallire l'intero lotto: meglio un errore subito che una
* sessione scritta a meta'. Un valore fuori dal range atteso invece si scrive e si
* marca — scartarlo perderebbe un dato vero, clamparlo lo falserebbe.
*/
export function registraMisure(
db: Database.Database,
sessioneId: number,
fonte: Fonte,
misure: MisuraIn[]
): { scritte: number; fuoriRange: string[] } {
const sess = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
{ client_code: string } | undefined;
if (!sess) throw new Error(`sessione ${sessioneId} inesistente`);
for (const m of misure) {
if (!esisteTest(db, m.test_id)) {
throw new Error(`test_id non nel registro: ${m.test_id}`);
}
}
const range = db.prepare(`SELECT range_min, range_max FROM registro_test WHERE test_id = ?`);
const ins = db.prepare(
`INSERT INTO misure (sessione_id, client_code, test_id, valore_num, valore_txt, unita, fonte, fuori_range)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
const fuoriRange: string[] = [];
const tx = db.transaction(() => {
for (const m of misure) {
const r = range.get(m.test_id) as { range_min: number | null; range_max: number | null };
let fuori = 0;
if (m.valore_num !== undefined) {
if ((r.range_min !== null && m.valore_num < r.range_min) ||
(r.range_max !== null && m.valore_num > r.range_max)) {
fuori = 1;
fuoriRange.push(m.test_id);
}
}
ins.run(sessioneId, sess.client_code, m.test_id,
m.valore_num ?? null, m.valore_txt ?? null, m.unita ?? null, fonte, fuori);
}
});
tx();
return { scritte: misure.length, fuoriRange };
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/misure.test.ts`
Expected: PASS, 5 test
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/misure.ts tests/longevity/misure.test.ts
git commit -m "longevity: registraMisure, unico varco con validazione sul registro"
```
---
### Task 4: Anagrafica — l'unico modulo che unisce identity e longevity
**Files:**
- Create: `src/lib/longevity/anagrafica.ts`
- Test: `tests/longevity/anagrafica.test.ts`
**Interfaces:**
- Consumes: `createLongevityDb`, `createIdentityDb` (Task 1)
- Produces:
- `creaCliente(identity, longevity, dati: { nome: string; cognome: string; sesso: 'M'|'F'; data_nascita?: string; email?: string; telefono?: string; user_id?: number }): string` — restituisce il `client_code`
- `codicePerUtente(identity, userId: number): string | null`
- `etaAllaData(dataNascita: string, alla: string): number`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/anagrafica.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db';
import { creaCliente, codicePerUtente, etaAllaData } from '../../src/lib/longevity/anagrafica';
describe('anagrafica pseudonimizzata', () => {
it('il nome sta in identity, il sesso in longevity, e i due non si mescolano', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const code = creaCliente(id, lg, { nome: 'Mario', cognome: 'Rossi', sesso: 'M', data_nascita: '1988-03-04' });
const inIdentity = id.prepare(`SELECT nome, cognome FROM clienti WHERE client_code = ?`).get(code) as
{ nome: string; cognome: string };
expect(inIdentity.nome).toBe('Mario');
const inLongevity = lg.prepare(`SELECT sesso FROM soggetti WHERE client_code = ?`).get(code) as { sesso: string };
expect(inLongevity.sesso).toBe('M');
// in longevity non deve esistere nessuna colonna che contenga il nome
const dump = JSON.stringify(lg.prepare(`SELECT * FROM soggetti`).all());
expect(dump).not.toContain('Mario');
expect(dump).not.toContain('Rossi');
expect(dump).not.toContain('1988-03-04');
});
it('assegna codici progressivi e non riusa i vecchi', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const a = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F' });
const b = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' });
expect(a).toMatch(/^ISL-\d{4}$/);
expect(b).not.toBe(a);
});
it('ritrova il codice a partire dall utente del sito', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const code = creaCliente(id, lg, { nome: 'C', cognome: 'C', sesso: 'F', user_id: 42 });
expect(codicePerUtente(id, 42)).toBe(code);
expect(codicePerUtente(id, 99)).toBeNull();
});
it('calcola l eta alla data della sessione', () => {
expect(etaAllaData('1988-03-04', '2026-08-21')).toBe(38);
expect(etaAllaData('1988-12-31', '2026-08-21')).toBe(37); // compleanno non ancora passato
});
it('nessun altro modulo apre entrambe le connessioni', () => {
const dir = join(process.cwd(), 'src/lib/longevity');
const colpevoli: string[] = [];
for (const f of readdirSync(dir)) {
if (!f.endsWith('.ts') || f === 'anagrafica.ts' || f === 'db.ts') continue;
const src = readFileSync(join(dir, f), 'utf8');
if (src.includes('createIdentityDb') && src.includes('createLongevityDb')) colpevoli.push(f);
}
expect(colpevoli).toEqual([]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/anagrafica.test.ts`
Expected: FAIL — modulo `anagrafica` non trovato
- [ ] **Step 3: Write minimal implementation**
```ts
// src/lib/longevity/anagrafica.ts
//
// QUESTO e' l'unico modulo autorizzato ad aprire insieme identity e longevity.
// La separazione dei due database e' 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';
export function creaCliente(
identity: Database.Database,
longevity: Database.Database,
dati: {
nome: string; cognome: string; sesso: 'M' | 'F';
data_nascita?: string; email?: string; telefono?: string; user_id?: number;
}
): string {
const ultimo = identity.prepare(
`SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1`
).get() as { client_code: string } | undefined;
const n = ultimo ? Number(ultimo.client_code.slice(4)) + 1 : 1;
const code = `ISL-${String(n).padStart(4, '0')}`;
identity.prepare(
`INSERT INTO clienti (client_code, user_id, nome, cognome, data_nascita, email, telefono)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(code, dati.user_id ?? null, dati.nome, dati.cognome,
dati.data_nascita ?? null, dati.email ?? null, dati.telefono ?? null);
longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso);
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;
}
/** L'eta' al momento della misura: e' cio' che serve al motore, e non e' 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;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/anagrafica.test.ts`
Expected: PASS, 5 test
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/anagrafica.ts tests/longevity/anagrafica.test.ts
git commit -m "longevity: anagrafica, unico punto di giunzione fra identita e clinica"
```
---
### Task 5: Ruoli `cliente` e `trainer`
**Files:**
- Modify: `src/lib/auth.ts` (type `Role` alla riga 8, array `RULES` alla riga ~56, `canAccessAdminPath`)
- Test: `tests/longevity/ruoli.test.ts`
**Interfaces:**
- Consumes: `canAccessAdminPath` esistente
- Produces: `Role` esteso con `'cliente' | 'trainer'`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/ruoli.test.ts
import { describe, it, expect } from 'vitest';
import { canAccessAdminPath } from '../../src/lib/auth';
describe('accesso alle rotte longevity', () => {
it('il cliente entra nel proprio spazio', () => {
expect(canAccessAdminPath('cliente', '/longevity/io')).toBe(true);
});
it('il cliente NON entra nel gestionale', () => {
expect(canAccessAdminPath('cliente', '/longevity/gestionale')).toBe(false);
});
it('il trainer entra in entrambi', () => {
expect(canAccessAdminPath('trainer', '/longevity/io')).toBe(true);
expect(canAccessAdminPath('trainer', '/longevity/gestionale')).toBe(true);
});
it('il cliente non entra nelle altre piattaforme ne nel blog', () => {
expect(canAccessAdminPath('cliente', '/campus')).toBe(false);
expect(canAccessAdminPath('cliente', '/admin/content')).toBe(false);
expect(canAccessAdminPath('cliente', '/admin/posts')).toBe(false);
});
it('il ruolo piattaforme non eredita longevity', () => {
expect(canAccessAdminPath('piattaforme', '/longevity/gestionale')).toBe(false);
});
it('admin passa sempre', () => {
expect(canAccessAdminPath('admin', '/longevity/gestionale')).toBe(true);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/ruoli.test.ts`
Expected: FAIL — `canAccessAdminPath('cliente', '/longevity/gestionale')` restituisce `true` (la regola finale lascia passare i loggati)
- [ ] **Step 3: Write minimal implementation**
In `src/lib/auth.ts`, riga 8, estendere il tipo:
```ts
export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme' | 'cliente' | 'trainer';
```
Nell'array `RULES`, aggiungere in fondo (prima della chiusura `];`):
```ts
// Longevity: il cliente vede solo il proprio spazio, il gestionale e' del trainer.
[/^\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
[/^\/api\/longevity\/gestionale(\/|$)/, ['admin', 'trainer']],
[/^\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']],
[/^\/api\/longevity(\/|$)/, ['admin', 'trainer', 'cliente']],
```
In `canAccessAdminPath`, prima del `return true` finale, aggiungere la stessa clausola che esiste per `piattaforme`:
```ts
// cliente e trainer non sono utenti del sito: fuori dalle rotte elencate non passano.
if (role === 'cliente' || role === 'trainer') return pathname === '/admin/logout';
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS — i 6 test nuovi e **tutti quelli esistenti**, in particolare `tests/auth.test.ts` e `tests/auth-piattaforme.test.ts`
- [ ] **Step 5: Commit**
```bash
git add src/lib/auth.ts tests/longevity/ruoli.test.ts
git commit -m "longevity: ruoli cliente e trainer con le rispettive rotte"
```
---
### Task 6: Salvataggio di una compilazione del questionario
**Files:**
- Create: `src/lib/longevity/questionario.ts`
- Test: `tests/longevity/questionario.test.ts`
**Interfaces:**
- Consumes: `apriSessione`, `registraMisure` (Task 3), `seedRegistro` (Task 2)
- Produces:
- `QUEST_VERSION: string` (`'v1.0'`)
- `CAMPI_LIBERI: string[]` (`['farmaci','problematiche_attuali','problematiche_pregresse','obiettivi']`)
- `salvaCompilazione(db, input: { client_code: string; data: string; eta?: number; risposte: Record<string, number>; liberi?: Record<string, string> }): number`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/questionario.test.ts
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { salvaCompilazione, QUEST_VERSION } from '../../src/lib/longevity/questionario';
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
return db;
}
describe('compilazione del questionario', () => {
it('salva le risposte punteggiate come misure', () => {
const db = dbPronto();
const s = salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21',
risposte: { q_ore_sonno: 7.5, q_riposato: 8, q_alcol_life: 3 },
});
const n = db.prepare(`SELECT COUNT(*) n FROM misure WHERE sessione_id = ? AND fonte = 'questionario'`)
.get(s) as { n: number };
expect(n.n).toBe(3);
});
it('marchia la compilazione con la versione del questionario', () => {
const db = dbPronto();
const s = salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } });
const row = db.prepare(`SELECT tipo, quest_version FROM sessioni WHERE id = ?`).get(s) as
{ tipo: string; quest_version: string };
expect(row.tipo).toBe('questionario');
expect(row.quest_version).toBe(QUEST_VERSION);
});
it('i campi liberi vanno in profilo_note, non fra le misure', () => {
const db = dbPronto();
const s = salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21',
risposte: { q_riposato: 6 },
liberi: { farmaci: 'nessuno', obiettivi: 'dormire meglio' },
});
const note = db.prepare(`SELECT campo_id, testo FROM profilo_note WHERE sessione_id = ? ORDER BY campo_id`)
.all(s) as { campo_id: string; testo: string }[];
expect(note.map((n) => n.campo_id)).toEqual(['farmaci', 'obiettivi']);
const misure = db.prepare(`SELECT test_id FROM misure WHERE sessione_id = ?`).all(s) as { test_id: string }[];
expect(misure.map((m) => m.test_id)).toEqual(['q_riposato']);
});
it('ogni compilazione e un record nuovo, non un aggiornamento', () => {
const db = dbPronto();
salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-08-21', risposte: { q_riposato: 6 } });
salvaCompilazione(db, { client_code: 'ISL-0001', data: '2026-09-21', risposte: { q_riposato: 9 } });
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni WHERE tipo = 'questionario'`).get() as { n: number };
expect(n.n).toBe(2);
});
it('una risposta con id sconosciuto fa fallire la compilazione intera', () => {
const db = dbPronto();
expect(() =>
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21',
risposte: { q_riposato: 6, q_domanda_nuova: 3 },
})
).toThrow(/q_domanda_nuova/);
const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
expect(n.n).toBe(0);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/questionario.test.ts`
Expected: FAIL — modulo `questionario` non trovato
- [ ] **Step 3: Write minimal implementation**
```ts
// src/lib/longevity/questionario.ts
import type Database from 'better-sqlite3';
import { apriSessione, registraMisure } from './misure';
import { esisteTest } from './registro';
/** Versione del set di domande. Si incrementa quando cambia una domanda. */
export const QUEST_VERSION = 'v1.0';
/**
* Campi testuali che si salvano ma non entrano MAI in una formula
* (indicazione esplicita del cliente, punto 7 della specifica).
*/
export const CAMPI_LIBERI = ['farmaci', 'problematiche_attuali', 'problematiche_pregresse', 'obiettivi'];
export function salvaCompilazione(
db: Database.Database,
input: {
client_code: string; data: string; eta?: number;
risposte: Record<string, number>;
liberi?: Record<string, string>;
}
): number {
// Validare PRIMA di aprire la sessione: altrimenti un id sbagliato lascia
// in giro una sessione vuota.
for (const id of Object.keys(input.risposte)) {
if (!esisteTest(db, id)) throw new Error(`test_id non nel registro: ${id}`);
}
const sessioneId = apriSessione(db, {
client_code: input.client_code,
data: input.data,
tipo: 'questionario',
eta_alla_data: input.eta,
quest_version: QUEST_VERSION,
});
registraMisure(
db, sessioneId, 'questionario',
Object.entries(input.risposte).map(([test_id, valore_num]) => ({ test_id, valore_num }))
);
if (input.liberi) {
const ins = db.prepare(
`INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)`
);
const tx = db.transaction(() => {
for (const [campo, testo] of Object.entries(input.liberi!)) {
if (testo.trim() === '') continue;
ins.run(input.client_code, sessioneId, campo, testo);
}
});
tx();
}
return sessioneId;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/questionario.test.ts`
Expected: PASS, 5 test
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/questionario.ts tests/longevity/questionario.test.ts
git commit -m "longevity: salvataggio compilazione questionario, versionata"
```
---
### Task 7: Export pseudonimizzato
**Files:**
- Create: `src/lib/longevity/export.ts`
- Test: `tests/longevity/export.test.ts`
**Interfaces:**
- Consumes: `createLongevityDb` (Task 1)
- Produces: `esportaMisure(db): { intestazioni: string[]; righe: (string|number|null)[][] }`, `esportaCsv(db): string`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/export.test.ts
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro } from '../../src/lib/longevity/registro';
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
import { esportaMisure, esportaCsv } from '../../src/lib/longevity/export';
function dbConDati() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-21', eta: 35,
risposte: { q_ore_sonno: 7, q_riposato: 8 },
liberi: { farmaci: 'nessuno' },
});
return db;
}
describe('export per le statistiche', () => {
it('esporta una riga per misura con codice, non con nome', () => {
const db = dbConDati();
const out = esportaMisure(db);
expect(out.intestazioni).toEqual(
['client_code', 'sesso', 'eta', 'data', 'tipo_sessione', 'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range']
);
expect(out.righe.length).toBe(2);
expect(out.righe[0][0]).toBe('ISL-0001');
});
it('l export non contiene campi identificativi', () => {
const db = dbConDati();
const testo = JSON.stringify(esportaMisure(db));
for (const vietato of ['nome', 'cognome', 'email', 'telefono', 'data_nascita']) {
expect(testo).not.toContain(vietato);
}
});
it('i campi liberi restano fuori dall export statistico', () => {
const db = dbConDati();
expect(JSON.stringify(esportaMisure(db))).not.toContain('nessuno');
});
it('produce un CSV con intestazione e una riga per misura', () => {
const db = dbConDati();
const righe = esportaCsv(db).trim().split('\n');
expect(righe.length).toBe(3); // intestazione + 2 misure
expect(righe[0]).toBe('client_code,sesso,eta,data,tipo_sessione,quest_version,test_id,valore,unita,fonte,fuori_range');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/export.test.ts`
Expected: FAIL — modulo `export` non trovato
- [ ] **Step 3: Write minimal implementation**
```ts
// src/lib/longevity/export.ts
//
// L'export per le statistiche legge SOLO longevity.db, che per costruzione non contiene
// nomi: non serve nessuna funzione di anonimizzazione, e non c'e' niente da ricordarsi.
// I campi liberi (farmaci, patologie) restano fuori: sono per il coach, non per l'analisi.
import type Database from 'better-sqlite3';
const INTESTAZIONI = [
'client_code', 'sesso', 'eta', 'data', 'tipo_sessione',
'quest_version', 'test_id', 'valore', 'unita', 'fonte', 'fuori_range',
];
export function esportaMisure(db: Database.Database): {
intestazioni: string[];
righe: (string | number | null)[][];
} {
const righe = db.prepare(
`SELECT sg.client_code, sg.sesso, s.eta_alla_data, s.data, s.tipo, s.quest_version,
m.test_id, COALESCE(m.valore_num, m.valore_txt) AS valore, m.unita, m.fonte, m.fuori_range
FROM misure m
JOIN sessioni s ON s.id = m.sessione_id
JOIN soggetti sg ON sg.client_code = m.client_code
ORDER BY s.data, m.id`
).raw().all() as (string | number | null)[][];
return { intestazioni: INTESTAZIONI, righe };
}
export function esportaCsv(db: Database.Database): string {
const { intestazioni, righe } = esportaMisure(db);
const cella = (v: string | number | null) => {
if (v === null) return '';
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return [intestazioni.join(','), ...righe.map((r) => r.map(cella).join(','))].join('\n') + '\n';
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS — tutta la suite, vecchia e nuova
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/export.ts tests/longevity/export.test.ts
git commit -m "longevity: export pseudonimizzato per costruzione"
```
---
## Cosa esiste alla fine di questo piano
Una libreria testata che sa: aprire i due database, tenere il registro dei test e i pesi versionati, registrare misure da qualunque sorgente validandole, creare un cliente tenendo separata l'identità dalla clinica, salvare una compilazione di questionario con la sua versione, ed esportare i dati già pseudonimizzati.
**Non esiste ancora**: nessuna pagina, nessun punteggio calcolato. Sono i due piani successivi — prima il motore (porting delle curve dal Python con il verificatore), poi le pagine.
## Nota per chi eseguirà il piano successivo
Il registro creato al Task 2 contiene `curva` e `params` per tutti e 20 i campi del questionario, nella forma dichiarata per tipo di curva (`bell`, `lin_dec`, `decstep`, `incstep`, `x10`, `x10_inv`). Il motore leggerà di lì, non da costanti nel codice.
⚠️ `x10_inv` è **la quinta curva che manca al motore Python**: serve a `q_calo_pomeridiano` e nel prototipo è `scale10_inv`, cioè `(10 - valore) × 10`. Il porting deve implementarla, non cercarla nel file del cliente.