import Database from 'better-sqlite3'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { seedRegistro, seedPesi, MODEL_VERSION } from './registro'; /** * Sotto questa copertura di peso un elemento calcolato e' 'insufficiente' e non ha valore. * Vale a ogni livello E dentro il questionario: soglia unica, confermata da Nicola il * 22/08 — due criteri diversi sarebbero due cose da spiegare e da ricordare. */ 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')), elemento 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')), -- Il tipo Punteggio protegge il codice ma non la tabella: l'interfaccia leggera' -- 'score' direttamente, dove quel tipo non c'e' piu'. Questo vincolo lo riscrive -- a livello di dato: o lo stato e' 'ok' e il valore c'e', o e' 'insufficiente' e -- il valore e' NULL, mai un numero scritto sotto uno stato che lo nega. -- ATTENZIONE al limite, che non e' quello che sembra: lo schema qui sopra gira con -- CREATE TABLE IF NOT EXISTS, che su un file gia' esistente e' un'operazione a vuoto. -- Quindi un longevity.db creato prima di questa modifica NON ha questo vincolo, e -- continuera' a non averlo finche' qualcuno non lo migra a mano. -- (Non e' un limite di SQLite: la versione imbarcata da better-sqlite3 e' la 3.53, -- che ALTER TABLE ... ADD CONSTRAINT lo supporta. E' che qui una migrazione non c'e'. -- Verificato il 22/08: la CLI di sistema e' la 3.37 e non lo supporta, quindi provarlo -- da riga di comando da' un errore fuorviante.) -- Oggi nessun longevity.db esiste ancora, quindi il caso e' teorico: lo diventa il -- giorno del primo deploy, ed e' quello il momento di ricordarselo. CHECK ((stato = 'ok' AND valore IS NOT NULL) OR (stato = 'insufficiente' AND valore IS NULL)) ); 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; } /** * ⚠️ Il registro dei test e i pesi si seminano QUI, all'apertura del database. * * Fino al 03/09/2026 `seedRegistro` e `seedPesi` esistevano, erano giusti, ed erano * chiamati **solo dai test**: su un database nuovo il registro restava vuoto, quindi * `campiDelQuestionario` non trovava niente e la pagina del questionario si apriva * **senza domande**. Non era un difetto della demo: era il primo avvio in produzione. * È lo stesso difetto già visto una volta in questo ramo — «una funzione corretta che * nessuno chiama» — e la correzione è la stessa: agganciarla dove non si può dimenticare. * * Entrambe le semine sono `INSERT OR REPLACE`, quindi rilanciarle è a vuoto; e il * registro è **dato di prodotto** (le domande e i pesi decisi con Nicola), non dato di * un cliente: riallinearlo a ogni avvio è voluto, così una domanda modificata in * `registro.ts` entra col rilascio invece che con una migrazione a mano. * ⚠️ I punteggi già emessi non cambiano: sono congelati in `score` e il referto legge * quelli, mai un ricalcolo (vedi `refertoDi`). */ export function createLongevityDb(path?: string): Database.Database { const db = apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); seedRegistro(db); seedPesi(db, MODEL_VERSION); return db; } export function createIdentityDb(path?: string): Database.Database { return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY); } // Connessione singola per processo, come getDb() in src/lib/db.ts: gli endpoint chiedono // una connessione già aperta invece di aprirne una nuova a ogni richiesta. // ⚠️ Qui c'è SOLO longevity (il database senza nomi: può usarlo chiunque). Un singleton // analogo per identity non si esporta da qui — vive privato dentro anagrafica.ts, l'unico // modulo autorizzato a tenerlo: un controllo testuale file-per-file non vede una giunzione // che passa da un re-export su piu' file, quindi la connessione condivisa a identity non // deve essere raggiungibile da fuori quel modulo, punto. let longevitySingleton: Database.Database | null = null; export function getLongevityDb(): Database.Database { if (!longevitySingleton) longevitySingleton = createLongevityDb(); return longevitySingleton; }