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, correggiCheckup, strutturaCheckup, testIdScartatiDalleAlternative, type SezioneCheckup, } from '../../../../lib/longevity/gestionale'; import { aggiungiVoce, TESTO_MAX as NOTA_TESTO_MAX } from '../../../../lib/longevity/giornale'; 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 `` nascosto o un campo dismesso non * deve rompere il salvataggio del resto. */ export function misureDalForm( form: FormData, campiAttivi: Pick[] ): { 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 }; } /** * Le scelte del trainer nelle sezioni ad alternative del modulo (#13): legge il radio * `alt_` dal form. Una sezione senza radio nel form (JS disattivato che non * ha ancora marcato nulla, o un refuso) resta assente dalla mappa: `testIdScartatiDalleAlternative` * non scarta NULLA per quella sezione, invece di ripiegare sulla prima alternativa e buttare * via in silenzio campi compilati di un'altra — correzione del passo 3 su #13. */ function sceltePerAlternative(sezioni: SezioneCheckup[], form: FormData): Map { const scelte = new Map(); for (const s of sezioni) { if (s.tipo !== 'alternative') continue; const v = form.get(`alt_${s.chiave}`); if (typeof v === 'string' && v !== '') scelte.set(s.chiave, v); } return scelte; } export const POST: APIRoute = async ({ request, locals }) => { const form = await request.formData(); const clientCodeForm = String(form.get('client_code') ?? '').trim(); const data = String(form.get('data') ?? '').trim(); // Un sessione_id nel form (issue #12) è una correzione: stesso form, stessa API, solo // l'hidden field in più — checkup.astro lo mette quando arriva con ?sessione=. const sessioneIdRaw = String(form.get('sessione_id') ?? '').trim(); const sessioneId = sessioneIdRaw !== '' ? Number(sessioneIdRaw) : null; // 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(clientCodeForm)) { return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`); } const db = getLongevityDb(); // In correzione il client_code per validazioni e redirect è SEMPRE quello letto dalla // sessione nel database, mai quello del form (stesso principio di verifica.ts): un // sessione_id non è un fascicolo che si possa dirottare scrivendo un altro codice cliente. let clientCode = clientCodeForm; if (sessioneId !== null) { if (!Number.isInteger(sessioneId)) { return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Sessione non valida.')}`); } const sessione = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as { client_code: string } | undefined; if (!sessione) { return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Sessione inesistente.')}`); } clientCode = sessione.client_code; if (clientCodeForm !== clientCode) { return redirect( `/longevity/gestionale/cliente/${clientCode}?errore=${encodeURIComponent('Codice cliente non coerente con la sessione.')}` ); } } const erroreAllaScheda = (testo: string) => { const query = new URLSearchParams({ errore: testo }); if (sessioneId !== null) query.set('sessione', String(sessioneId)); return redirect(`/longevity/gestionale/cliente/${clientCode}/checkup?${query.toString()}`); }; 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 campiAttivi = testAttivi(db, data).filter((v) => v.test_id.startsWith('f_')); const parse = misureDalForm(form, campiAttivi); if (!parse.ok) return erroreAllaScheda(parse.errore); // Sezioni ad alternative (#13): si tengono solo i campi dell'alternativa scelta dalla // radio, i compilati di altre alternative della stessa sezione si scartano — anche se il // trainer li ha riempiti (JS disattivato, o un fieldset lasciato visibile per errore). const { sezioni } = strutturaCheckup(db, data); const scartati = testIdScartatiDalleAlternative(sezioni, sceltePerAlternative(sezioni, form)); const misure = parse.misure.filter((m) => !scartati.has(m.test_id)); // 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); // Nota operatore (#13, #16): il form la mostra solo per un check-up nuovo (checkup.astro), // quindi in correzione la chiave è assente e questo resta vuoto — nessuna voce si aggiunge. const nota = String(form.get('nota') ?? '').trim(); // Validata PRIMA di salvare (correzione del passo 3 su #13): se aggiungiVoce fallisse DOPO // il check-up (nota troppo lunga), il check-up resterebbe salvato ma la nota andrebbe persa // in silenzio — stesso limite di aggiungiVoce (giornale.ts), non un numero duplicato qui. if (nota.length > NOTA_TESTO_MAX) { return erroreAllaScheda(`Il testo della nota non può superare ${NOTA_TESTO_MAX} caratteri.`); } if (sessioneId !== null) { const esito = correggiCheckup(db, { sessioneId, data, eta, operatore, misure }); if (!esito.ok) return erroreAllaScheda(esito.errore); const params = new URLSearchParams({ corretto: String(esito.sessioneId) }); if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length)); return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`); } const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure }); if (!esito.ok) return erroreAllaScheda(esito.errore); // Voce di giornale SOLO dopo il salvataggio riuscito (#13): la lunghezza è già validata // sopra, la data è la stessa già validata per il check-up — aggiungiVoce qui non dovrebbe // più rifiutare nulla. if (nota !== '') aggiungiVoce(db, { clientCode, data, autore: operatore, testo: nota }); 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()}`); };