longevity: schema dei due database, identity separato da longevity
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user