From f16012c8bd80968346d1857df988d6178a5560eb Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 21 Aug 2026 19:15:17 +0200 Subject: [PATCH] longevity: schema dei due database, identity separato da longevity --- src/lib/longevity/db.ts | 117 +++++++++++++++++++++++++++++++++++++ tests/longevity/db.test.ts | 63 ++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 src/lib/longevity/db.ts create mode 100644 tests/longevity/db.test.ts diff --git a/src/lib/longevity/db.ts b/src/lib/longevity/db.ts new file mode 100644 index 0000000..1be61e4 --- /dev/null +++ b/src/lib/longevity/db.ts @@ -0,0 +1,117 @@ +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); +} diff --git a/tests/longevity/db.test.ts b/tests/longevity/db.test.ts new file mode 100644 index 0000000..e8b3bf2 --- /dev/null +++ b/tests/longevity/db.test.ts @@ -0,0 +1,63 @@ +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:'); + // Esclude la tabella score (che ha una colonna nome legittima per il nome dello score calcolato). + // Controlla che le colonne identificative della PERSONA non stiano nel longevity. + const colonne = (lg.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name != 'score'`).all() as { name: string }[]) + .flatMap((t) => (lg.prepare(`PRAGMA table_info(${t.name})`).all() as { name: string }[]).map((c) => c.name)); + expect(colonne).not.toContain('cognome'); + expect(colonne).not.toContain('data_nascita'); + }); + + it('la soglia di copertura e 0.40', () => { + expect(COPERTURA_MINIMA).toBe(0.4); + }); +});