longevity: API del gestionale (clienti, check-up) e test dell'unità 2a

pages/api/longevity/gestionale/clienti.ts: POST del nuovo fascicolo (nome,
cognome, sesso obbligatori; data di nascita/email/telefono facoltativi) →
apriFascicolo → 303 alla scheda cliente, o 303 al modulo con ?errore=.
pages/api/longevity/gestionale/checkup.ts: POST del check-up manuale, con
misureDalForm (funzione pura, esportata e testata) per il parsing del form —
virgola decimale accettata, campo vuoto = non misurato, valore non numerico
ferma tutto, chiavi che non sono campi f_* attivi ignorate. operatore sempre
da locals.user (mai dal corpo, stesso principio del client_code in
questionario.ts); eta da etaDelCliente. Entrambi riservati a admin+trainer da
RULES in auth.ts (già in vigore, verificato dal test su canAccessPath).

tests/longevity/gestionale.test.ts: 17 test — salvaCheckup e il congelamento
dei punteggi, il referto che combina check-up e questionario di sessioni
diverse (#1), i rifiuti prima della scrittura, le misure fuori range, le
versioni parametrizzate dell'anagrafica, apriFascicolo sul singleton (stesso
trucco env-var di questionario-pagina.test.ts), misureDalForm, canAccessPath.

vitest: 229 passed (212 di partenza + 17). tsc --noEmit: 0 errori.

Refs #2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 12:53:46 +00:00
parent 758463bd26
commit 95ffa08197
3 changed files with 427 additions and 0 deletions
@@ -0,0 +1,90 @@
import type { APIRoute } from 'astro';
import { getLongevityDb } from '../../../../lib/longevity/db';
import { etaDelCliente } from '../../../../lib/longevity/anagrafica';
import { testAttivi, type VoceRegistro } from '../../../../lib/longevity/registro';
import { salvaCheckup } from '../../../../lib/longevity/gestionale';
import type { MisuraIn } from '../../../../lib/longevity/misure';
export const prerender = false;
// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/).
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
const REGEX_CODE = /^ISL-\d{4}$/;
const redirect = (location: string) => new Response(null, { status: 303, headers: { Location: location } });
/**
* Il parsing del form del check-up, isolato dall'I/O (nessun DB, nessuna Request) apposta
* per essere testato senza fixture: riceve i campi ATTIVI (da `testAttivi`, filtrati su
* `f_*` da chi chiama) e il `FormData` grezzo, e produce le misure da passare a
* `salvaCheckup` — o l'errore che ferma tutto PRIMA che una sessione si apra.
*
* Regole, dalla spec (#2): un campo vuoto = non misurato (non un errore, non uno zero); la
* virgola decimale si accetta ("32,5" come "32.5", scrittura comune su un modulo cartaceo);
* un valore non numerico è un errore che blocca l'intero check-up, non solo quel campo —
* meglio fermarsi che salvare a metà con un tacito zero; le chiavi che non sono fra i campi
* attivi (client_code, data, un q_* del questionario, un refuso) si ignorano in silenzio,
* mai un errore per la loro sola presenza — un `<input>` nascosto o un campo dismesso non
* deve rompere il salvataggio del resto.
*/
export function misureDalForm(
form: FormData,
campiAttivi: Pick<VoceRegistro, 'test_id' | 'unita'>[]
): { ok: true; misure: MisuraIn[] } | { ok: false; errore: string } {
const perTestId = new Map(campiAttivi.map((c) => [c.test_id, c]));
const misure: MisuraIn[] = [];
for (const [chiave, valoreGrezzo] of form.entries()) {
const campo = perTestId.get(chiave);
if (!campo) continue; // chiave estranea: ignorata, non è un errore del form
const testo = String(valoreGrezzo).trim();
if (testo === '') continue; // vuoto = non misurato
const numero = Number(testo.replace(',', '.'));
if (!Number.isFinite(numero)) {
return { ok: false, errore: `Valore non numerico per ${chiave}: "${testo}"` };
}
misure.push({ test_id: chiave, valore_num: numero, unita: campo.unita });
}
return { ok: true, misure };
}
export const POST: APIRoute = async ({ request, locals }) => {
const form = await request.formData();
const clientCode = String(form.get('client_code') ?? '').trim();
const data = String(form.get('data') ?? '').trim();
// Senza un codice valido non c'è una scheda a cui tornare: l'unico ritorno sensato è
// l'indice del gestionale, non /cliente/undefined/checkup.
if (!REGEX_CODE.test(clientCode)) {
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`);
}
const erroreAllaScheda = (testo: string) =>
redirect(`/longevity/gestionale/cliente/${clientCode}/checkup?errore=${encodeURIComponent(testo)}`);
if (!REGEX_DATA.test(data)) return erroreAllaScheda('Data non valida (formato AAAA-MM-GG).');
const oggi = new Date().toISOString().slice(0, 10);
if (data > oggi) return erroreAllaScheda('La data del check-up non può essere nel futuro.');
const db = getLongevityDb();
const campiAttivi = testAttivi(db, data).filter((v) => v.test_id.startsWith('f_'));
const parse = misureDalForm(form, campiAttivi);
if (!parse.ok) return erroreAllaScheda(parse.errore);
// L'operatore è SEMPRE quello della sessione loggata (garantita da RULES + middleware:
// solo admin/trainer arrivano qui), mai un campo del form — lo stesso principio del
// client_code in pages/api/longevity/questionario.ts, applicato al campo che qui conta.
const operatore = locals.user!.username;
const eta = etaDelCliente(clientCode, data);
const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure: parse.misure });
if (!esito.ok) return erroreAllaScheda(esito.errore);
const params = new URLSearchParams({ salvato: String(esito.sessioneId) });
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`);
};