From 77829b2d12719ad11bd66b882e306907a89940ff Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sat, 26 Sep 2026 13:00:48 +0000 Subject: [PATCH] =?UTF-8?q?longevity:=20crea=20l'accesso=20al=20sito=20per?= =?UTF-8?q?=20un=20cliente=20che=20ha=20gi=C3=A0=20il=20fascicolo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accessoDi/collegaAccesso in anagrafica.ts (e le varianti sul singleton accessoDelCliente/collegaAccessoCliente) leggono e scrivono il legame clienti.user_id restando l'unico punto che apre identity. accesso.ts aggiunge creaAccessoCliente: crea l'utente cliente, lo collega, e se il collegamento fallisce cancella l'utente appena creato (compensazione, due database, stesso schema di creaCliente). Nuova pagina del gestionale pages/longevity/gestionale/cliente/[code]/accesso.astro: genera username e password una volta sola, niente redirect, no-store. Refs #8 Co-Authored-By: Claude Sonnet 5 --- apps/platforms/src/lib/longevity/accesso.ts | 88 +++++++++++ .../platforms/src/lib/longevity/anagrafica.ts | 40 +++++ .../gestionale/cliente/[code]/accesso.astro | 82 ++++++++++ .../platforms/tests/longevity/accesso.test.ts | 140 ++++++++++++++++++ 4 files changed, 350 insertions(+) create mode 100644 apps/platforms/src/lib/longevity/accesso.ts create mode 100644 apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro create mode 100644 apps/platforms/tests/longevity/accesso.test.ts diff --git a/apps/platforms/src/lib/longevity/accesso.ts b/apps/platforms/src/lib/longevity/accesso.ts new file mode 100644 index 0000000..51801de --- /dev/null +++ b/apps/platforms/src/lib/longevity/accesso.ts @@ -0,0 +1,88 @@ +// +// Crea l'account di accesso al sito per un cliente che ha GIA' un fascicolo aperto +// (il percorso inverso di crea-fascicolo.ts, che parte da un utente esistente e apre il +// fascicolo). Qui si parte dal fascicolo — creato dal trainer al check-up, spesso prima +// che il cliente abbia un account — e si crea l'utente che gli permette di entrare da +// /longevity a leggere il proprio referto e compilare il questionario. +// +// ⚠️ Non apre identity da sé: passa dal solo modulo autorizzato a farlo (anagrafica.ts), +// che è anche l'unico a sapere cosa sia "un accesso" per un fascicolo. Questo file tocca +// due database (users in piattaforme.db, il collegamento in identity.db) ma non li apre +// mai insieme: riceve `utenti` da fuori e delega identity alle funzioni di anagrafica.ts. +import type Database from 'better-sqlite3'; +import { getDb } from '../db'; +import { createUser, deleteUser, randomPassword } from '../auth'; +import { accessoDelCliente, collegaAccessoCliente } from './anagrafica'; + +/** Solo lettere minuscole, cifre e ".", "_", "@", "-": stesso alfabeto permissivo di create-user.mjs. */ +const USERNAME_VALIDO = /^[a-z0-9._@-]{3,64}$/; + +/** + * Le dipendenze di creaAccessoCliente, iniettabili per i test (DB in memoria, doppi finti + * per accessoDi/collega) invece del singleton reale. In produzione si passa `undefined` e + * si prende il default qui sotto — lo stesso schema del singleton di getDb(): una + * connessione per processo, non riaperta a ogni chiamata. + */ +export interface DipendenzeAccesso { + utenti: Database.Database; + accessoDi: (code: string) => { userId: number | null; email: string | null } | null; + collega: (code: string, userId: number) => boolean; +} + +function dipendenzeDiDefault(): DipendenzeAccesso { + return { utenti: getDb(), accessoDi: accessoDelCliente, collega: collegaAccessoCliente }; +} + +export type EsitoAccesso = + | { ok: true; username: string; password: string } + | { ok: false; errore: string }; + +/** + * Crea l'utente `cliente` e lo collega al fascicolo `code`. Ordine dei controlli: prima + * tutto ciò che si può verificare SENZA scrivere (username valido, fascicolo esistente e + * ancora senza accesso, username libero), poi si crea l'utente, poi si collega. + * + * Se il collegamento fallisce (fascicolo sparito o già collegato nel frattempo, o + * l'update lancia per un motivo diverso) l'utente appena creato si cancella: due database + * distinti, quindi nessuna transazione unica possibile — stessa compensazione di + * creaCliente in anagrafica.ts. Un utente `cliente` senza fascicolo collegato non + * servirebbe a niente e resterebbe un accesso fantasma. + * + * La password è generata qui (randomPassword, mai scelta da chi chiama) e torna in + * chiaro UNA volta sola nell'esito: non viene salvata da nessuna parte se non nel suo + * hash dentro users.password_hash. Chi chiama non la deve loggare. + */ +export function creaAccessoCliente( + deps: DipendenzeAccesso = dipendenzeDiDefault(), + { code, username }: { code: string; username: string } +): EsitoAccesso { + const nome = username.trim().toLowerCase(); + if (!USERNAME_VALIDO.test(nome)) { + return { ok: false, errore: 'Username non valido: 3-64 caratteri fra lettere minuscole, cifre, ". _ @ -".' }; + } + + const fascicolo = deps.accessoDi(code); + if (!fascicolo) return { ok: false, errore: `Nessun fascicolo con codice ${code}.` }; + if (fascicolo.userId !== null) { + return { ok: false, errore: 'Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.' }; + } + + const usernameOccupato = deps.utenti.prepare('SELECT 1 FROM users WHERE username = ?').get(nome); + if (usernameOccupato) return { ok: false, errore: `L'username "${nome}" è già in uso.` }; + + const password = randomPassword(); + const userId = createUser(deps.utenti, nome, password, 'cliente'); + + let collegato: boolean; + try { + collegato = deps.collega(code, userId); + } catch { + collegato = false; + } + if (!collegato) { + deleteUser(deps.utenti, userId); + return { ok: false, errore: 'Il fascicolo non è più collegabile (cancellato o già collegato nel frattempo).' }; + } + + return { ok: true, username: nome, password }; +} diff --git a/apps/platforms/src/lib/longevity/anagrafica.ts b/apps/platforms/src/lib/longevity/anagrafica.ts index cfea267..e103022 100644 --- a/apps/platforms/src/lib/longevity/anagrafica.ts +++ b/apps/platforms/src/lib/longevity/anagrafica.ts @@ -85,6 +85,46 @@ export function codicePerUtenteLoggato(userId: number): string | null { return codicePerUtente(getIdentityDb(), userId); } +/** + * Lo stato di accesso di un fascicolo: chi (se qualcuno) lo può aprire da sito, e l'email + * già in anagrafica (serve solo a precompilare l'username in fase di creazione, non è + * usata per altro). `null` vuol dire codice inesistente — diverso da "esiste ma senza + * accesso", che è `{ userId: null, email }`. + */ +export function accessoDi( + identity: Database.Database, + code: string +): { userId: number | null; email: string | null } | null { + const r = identity.prepare(`SELECT user_id, email FROM clienti WHERE client_code = ?`).get(code) as + { user_id: number | null; email: string | null } | undefined; + if (!r) return null; + return { userId: r.user_id, email: r.email }; +} + +/** Come accessoDi, ma sulla connessione singleton: per il gestionale, che identity non la apre mai da sé. */ +export function accessoDelCliente(code: string): { userId: number | null; email: string | null } | null { + return accessoDi(getIdentityDb(), code); +} + +/** + * Lega un utente già creato al fascicolo: vero solo se il fascicolo esiste ed era ancora + * senza accesso (`user_id IS NULL` nel WHERE, non un controllo prima separato) — così due + * chiamate concorrenti sullo stesso fascicolo non possono collegare due utenti diversi, + * la seconda trova zero righe e torna false. Chi chiama con false deve considerare + * l'utente appena creato orfano e cancellarlo (vedi accesso.ts). + */ +export function collegaAccesso(identity: Database.Database, code: string, userId: number): boolean { + const res = identity.prepare( + `UPDATE clienti SET user_id = ? WHERE client_code = ? AND user_id IS NULL` + ).run(userId, code); + return res.changes === 1; +} + +/** Come collegaAccesso, ma sulla connessione singleton. */ +export function collegaAccessoCliente(code: string, userId: number): boolean { + return collegaAccesso(getIdentityDb(), code, userId); +} + /** 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); diff --git a/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro new file mode 100644 index 0000000..f27e423 --- /dev/null +++ b/apps/platforms/src/pages/longevity/gestionale/cliente/[code]/accesso.astro @@ -0,0 +1,82 @@ +--- +// Crea l'accesso al sito per un cliente che ha già il fascicolo: il trainer sceglie +// l'username, la password la genera il sistema e si mostra UNA volta sola, qui, mai +// via redirect (finirebbe nella query string dell'URL e nella cronologia del browser). +// ⚠️ Questa pagina non apre mai il database anagrafico da sé: passa solo da +// anagrafica.ts e accesso.ts, gli unici moduli autorizzati (li sorveglia un test). +import Longevity from '../../../../../layouts/Longevity.astro'; +import { accessoDelCliente } from '../../../../../lib/longevity/anagrafica'; +import { creaAccessoCliente } from '../../../../../lib/longevity/accesso'; + +export const prerender = false; + +const { code } = Astro.params as { code: string }; + +// La password passa da qui una volta sola: niente cache, né del browser né di un proxy +// davanti a Node, che altrimenti la servirebbe di nuovo a chi ripete la stessa GET. +Astro.response.headers.set('Cache-Control', 'no-store'); + +const fascicolo = accessoDelCliente(code); +if (!fascicolo) Astro.response.status = 404; + +let errore = ''; +let creato: { username: string; password: string } | null = null; + +if (fascicolo && fascicolo.userId === null && Astro.request.method === 'POST') { + const form = await Astro.request.formData(); + const username = String(form.get('username') ?? ''); + const esito = creaAccessoCliente(undefined, { code, username }); + if (esito.ok) creato = { username: esito.username, password: esito.password }; + else errore = esito.errore; +} +--- + + {!fascicolo && ( +
+

Nessun fascicolo con codice {code}.

+
+ )} + + {fascicolo && fascicolo.userId !== null && ( +
+

Questo fascicolo ha già un accesso collegato: non se ne crea un secondo.

+
+ )} + + {fascicolo && fascicolo.userId === null && creato && ( +
+

Accesso creato. Comunicala al cliente: non verrà mostrata di nuovo.

+

Utente: {creato.username}

+

Password: {creato.password}

+
+ )} + + {fascicolo && fascicolo.userId === null && !creato && ( +
+ {errore &&

{errore}

} +
+ Nome utente + +
+
+ +
+
+ )} + + +
diff --git a/apps/platforms/tests/longevity/accesso.test.ts b/apps/platforms/tests/longevity/accesso.test.ts new file mode 100644 index 0000000..3d50649 --- /dev/null +++ b/apps/platforms/tests/longevity/accesso.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createDb } from '../../src/lib/db'; +import { login } from '../../src/lib/auth'; +import { createIdentityDb, createLongevityDb } from '../../src/lib/longevity/db'; +import { creaCliente, accessoDi, collegaAccesso } from '../../src/lib/longevity/anagrafica'; +import { creaAccessoCliente, type DipendenzeAccesso } from '../../src/lib/longevity/accesso'; + +// Doppio delle dipendenze su DB in memoria: come in anagrafica.test.ts, connessioni proprie +// passate a mano, mai il singleton reale — questo è l'unico modo in cui accesso.ts si +// prova senza toccare data/identity.db o data/piattaforme.db. +function dipendenzeDiTest(identity: ReturnType, utenti: ReturnType): DipendenzeAccesso { + return { + utenti, + accessoDi: (code) => accessoDi(identity, code), + collega: (code, userId) => collegaAccesso(identity, code, userId), + }; +} + +function fascicoloPronto() { + const identity = createIdentityDb(':memory:'); + const longevity = createLongevityDb(':memory:'); + const utenti = createDb(':memory:'); + const code = creaCliente(identity, longevity, { nome: 'Mario', cognome: 'Rossi', sesso: 'M' }); + return { identity, longevity, utenti, code }; +} + +describe('creaAccessoCliente', () => { + it('crea l utente cliente, lo collega, e la password permette il login', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps = dipendenzeDiTest(identity, utenti); + + const esito = creaAccessoCliente(deps, { code, username: 'MarioRossi' }); + expect(esito.ok).toBe(true); + if (!esito.ok) return; + + expect(esito.username).toBe('mariorossi'); // normalizzato minuscolo + + const riga = utenti.prepare('SELECT id, password_hash, role FROM users WHERE username = ?').get('mariorossi') as + { id: number; password_hash: string; role: string }; + expect(riga.role).toBe('cliente'); + // la password torna in chiaro nell'esito, ma non è quella salvata: quella è l'hash. + expect(riga.password_hash).not.toBe(esito.password); + + // il collegamento è avvenuto davvero: accessoDi lo ritrova. + const dopo = accessoDi(identity, code); + expect(dopo?.userId).toBe(riga.id); + + // e la password restituita fa davvero login. + const token = login(utenti, 'mariorossi', esito.password); + expect(token).not.toBeNull(); + }); + + it('fascicolo già con un accesso: errore, nessun utente creato', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps = dipendenzeDiTest(identity, utenti); + + const primo = creaAccessoCliente(deps, { code, username: 'uno' }); + expect(primo.ok).toBe(true); + + const secondo = creaAccessoCliente(deps, { code, username: 'due' }); + expect(secondo.ok).toBe(false); + + const utentiDopo = utenti.prepare('SELECT COUNT(*) n FROM users').get() as { n: number }; + expect(utentiDopo.n).toBe(1); // solo il primo, "due" non è mai stato creato + }); + + it('codice inesistente: errore', () => { + const { identity, utenti } = fascicoloPronto(); + const deps = dipendenzeDiTest(identity, utenti); + + const esito = creaAccessoCliente(deps, { code: 'ISL-9999', username: 'fantasma' }); + expect(esito.ok).toBe(false); + }); + + it('username già esistente: errore, niente collegato', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps = dipendenzeDiTest(identity, utenti); + utenti.prepare("INSERT INTO users (username, password_hash, role) VALUES ('occupato', 'x', 'admin')").run(); + + const esito = creaAccessoCliente(deps, { code, username: 'occupato' }); + expect(esito.ok).toBe(false); + + const fascicolo = accessoDi(identity, code); + expect(fascicolo?.userId).toBeNull(); // resta senza accesso + }); + + it('username non valido (maiuscole non ammesse dopo la normalizzazione, o troppo corto): errore', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps = dipendenzeDiTest(identity, utenti); + + const corto = creaAccessoCliente(deps, { code, username: 'ab' }); + expect(corto.ok).toBe(false); + + const conSpazio = creaAccessoCliente(deps, { code, username: 'ha spazi' }); + expect(conSpazio.ok).toBe(false); + + const utentiDopo = utenti.prepare('SELECT COUNT(*) n FROM users').get() as { n: number }; + expect(utentiDopo.n).toBe(0); + }); + + it('se il collegamento fallisce, l utente appena creato viene cancellato (compensazione)', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps: DipendenzeAccesso = { + utenti, + accessoDi: (c) => accessoDi(identity, c), + collega: () => false, // simula un collegamento rifiutato (es. fascicolo cambiato nel frattempo) + }; + + const esito = creaAccessoCliente(deps, { code, username: 'sfortunato' }); + expect(esito.ok).toBe(false); + + const rimasto = utenti.prepare('SELECT * FROM users WHERE username = ?').get('sfortunato'); + expect(rimasto).toBeUndefined(); // nessun utente orfano + }); + + it('se il collegamento lancia, l utente appena creato viene comunque cancellato', () => { + const { identity, utenti, code } = fascicoloPronto(); + const deps: DipendenzeAccesso = { + utenti, + accessoDi: (c) => accessoDi(identity, c), + collega: () => { throw new Error('identity irraggiungibile'); }, + }; + + const esito = creaAccessoCliente(deps, { code, username: 'sfortunato2' }); + expect(esito.ok).toBe(false); + + const rimasto = utenti.prepare('SELECT * FROM users WHERE username = ?').get('sfortunato2'); + expect(rimasto).toBeUndefined(); + }); + + it('rete secondaria: la pagina del gestionale non mette la password in cache e non nomina identity', () => { + const path = join(process.cwd(), 'src/pages/longevity/gestionale/cliente/[code]/accesso.astro'); + const src = readFileSync(path, 'utf8'); + expect(src).toContain('no-store'); + expect(src).not.toContain('Identity'); + expect(src).not.toContain('.prepare('); + }); +});