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:
@@ -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()}`);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { apriFascicolo } from '../../../../lib/longevity/anagrafica';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/):
|
||||
// il middleware nega prima che questo file veda la richiesta.
|
||||
|
||||
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const redirect = (location: string) => new Response(null, { status: 303, headers: { Location: location } });
|
||||
|
||||
/** Sempre la stessa pagina di partenza: questo endpoint ha un solo modulo che lo chiama
|
||||
* (pages/longevity/gestionale/nuovo.astro, unità 2b), quindi il ritorno in caso di errore
|
||||
* non serve dedurlo da un Referer — si scrive qui, una volta. */
|
||||
const erroreAlModulo = (testo: string) =>
|
||||
redirect(`/longevity/gestionale/nuovo?errore=${encodeURIComponent(testo)}`);
|
||||
|
||||
function isSesso(v: string): v is 'M' | 'F' {
|
||||
return v === 'M' || v === 'F';
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const leggi = (chiave: string) => String(form.get(chiave) ?? '').trim();
|
||||
|
||||
const nome = leggi('nome');
|
||||
const cognome = leggi('cognome');
|
||||
const sesso = leggi('sesso');
|
||||
const dataNascita = leggi('data_nascita');
|
||||
const email = leggi('email');
|
||||
const telefono = leggi('telefono');
|
||||
|
||||
if (!nome) return erroreAlModulo('Il nome è obbligatorio.');
|
||||
if (!cognome) return erroreAlModulo('Il cognome è obbligatorio.');
|
||||
if (!isSesso(sesso)) return erroreAlModulo('Il sesso deve essere M o F.');
|
||||
|
||||
if (dataNascita) {
|
||||
if (!REGEX_DATA.test(dataNascita)) {
|
||||
return erroreAlModulo('Data di nascita non valida (formato AAAA-MM-GG).');
|
||||
}
|
||||
// La data del server, non quella (manomettibile) del form: stesso criterio di
|
||||
// pages/api/longevity/questionario.ts per "oggi".
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
if (dataNascita > oggi) return erroreAlModulo('La data di nascita non può essere nel futuro.');
|
||||
}
|
||||
|
||||
const code = apriFascicolo({
|
||||
nome, cognome, sesso,
|
||||
data_nascita: dataNascita || undefined,
|
||||
email: email || undefined,
|
||||
telefono: telefono || undefined,
|
||||
});
|
||||
|
||||
return redirect(`/longevity/gestionale/cliente/${code}`);
|
||||
};
|
||||
Reference in New Issue
Block a user