longevity: anagrafica, unico punto di giunzione fra identita e clinica

Implementa il modulo che unisce i due database (identity e longevity)
rispettando la separazione richiesta dal cliente: pseudonimizzazione
dei dati clinici. Il modulo esporta tre funzioni:

- creaCliente: registra un soggetto con il suo codice progressivo ISL-NNNN
- codicePerUtente: risale dal user_id del sito al client_code
- etaAllaData: calcola l'eta' al momento della misura (non la data di nascita)

Un test verifica che nessun altro modulo apra entrambe le connessioni,
preservando il firewall fra dati clinici e identita'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:33:23 +02:00
parent 7779c6b830
commit 9943312071
2 changed files with 106 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
//
// QUESTO è l'unico modulo autorizzato ad aprire insieme identity e longevity.
// La separazione dei due database è 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: è ciò che serve al motore, e non è 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;
}
+59
View File
@@ -0,0 +1,59 @@
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([]);
});
});