Separazione: le aree riservate diventano un'app propria (apps/platforms)

Campus, Stress Index e Longevity escono dal sito e diventano
apps.insanitylab.it, con utenti, sessioni, login e database propri.
Deciso da Adriano il 03/09/2026: una repo sola, due applicazioni
autosufficienti, cosi' che ognuna resti estraibile.

Misura che ha guidato il taglio: le tre aree condividevano col sito
SOLO l'identita' - ~170 righe fra auth.ts, middleware.ts e safe-next.ts.
Layout, librerie e dati erano gia' disgiunti.

Lato piattaforme:
- ruoli piattaforme/cliente/trainer/admin, senza quelli redazionali
- canAccessPath con default CHIUSO (nel sito era aperto: li' le rotte non
  elencate erano di chiunque fosse loggato, qui ogni sezione e' di qualcuno)
- tutta l'app protetta, si elencano le eccezioni invece dei prefissi protetti
- /api risponde 401/403, non un redirect verso una pagina di login
- tre database distinti: utenti, dati clinici senza nomi, mappatura coi nomi

Lato sito:
- ROLES tornano i tre redazionali; via le regole e il login pubblico
- il login del pannello RESPINGE un account emigrato invece di aprirgli una
  sessione: senza, landingFor lo mandava su /admin, il middleware glielo
  negava, e si formava un ciclo di redirect che sembra un guasto del sito
- l'header non mostra piu' un menu delle piattaforme: un link che esce

Verificato: sito 20 file / 146 test, piattaforme 26 file / 197 test, entrambi
i build passano, e l'app girata davvero in locale - il ruolo piattaforme entra
in Campus e viene rimbalzato da Longevity, il cliente il contrario.

Non ancora in produzione: manca il record DNS di apps.insanitylab.it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
This commit is contained in:
AdrianoDev
2026-09-03 11:21:12 +00:00
parent 510d7ca4ec
commit 6301f521fd
383 changed files with 8477 additions and 350 deletions
+150
View File
@@ -0,0 +1,150 @@
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.
* 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;
}
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);
}
// 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;
}