Files
InsanityLab-web/apps/platforms/src/pages/api/longevity/gestionale/checkup.ts
T
Adriano b0f30f9e4c longevity: correzioni passo 3 su #13 unità B — scarto alternative e nota
testIdScartatiDalleAlternative: una sezione senza scelta in `scelte` (radio
assente dal form) non scarta più nulla, invece di ripiegare in silenzio
sulla prima alternativa (perdeva campi come f_plank_sec senza alt_core).

API checkup.ts: la nota operatore si valida (lunghezza max 5000, TESTO_MAX
esportato da giornale.ts) PRIMA di salvare il check-up, non più dopo — una
nota troppo lunga non faceva più fallire aggiungiVoce in silenzio ma
perdeva comunque la nota col check-up già scritto. maxlength sulla
textarea in checkup.astro.

Suite: 314 verdi, 4 rossi di piattaforma (baseline, invariati). tsc e
build puliti.

Refs #13
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:59:19 +02:00

170 lines
8.6 KiB
TypeScript

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 `<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 };
}
/**
* Le scelte del trainer nelle sezioni ad alternative del modulo (#13): legge il radio
* `alt_<chiave sezione>` 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<string, string> {
const scelte = new Map<string, string>();
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=<id>.
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()}`);
};