Merge branch 'lavoro/goldrake-i7' into lavoro/goldrake

This commit is contained in:
2026-09-26 13:06:22 +00:00
5 changed files with 427 additions and 6 deletions
+38
View File
@@ -54,6 +54,13 @@ CREATE TABLE IF NOT EXISTS misure (
fonte TEXT NOT NULL CHECK (fonte IN
('manuale','questionario','wellness_tower','vald','calibre','stress_index')),
fuori_range INTEGER NOT NULL DEFAULT 0,
-- Verifica di una misura fuori range (issue #7): il trainer la conferma (entra nello
-- score come le altre, vedi misureValideAlla in motore/index.ts) o la scarta (resta
-- esclusa, per sempre). NULL = non ancora verificata. Su un longevity.db creato PRIMA
-- di questa modifica queste tre colonne NON ci sono: le aggiunge migra(), qui sotto.
verifica TEXT CHECK (verifica IN ('confermata','scartata')),
verificata_da TEXT,
verificata_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id);
@@ -128,6 +135,36 @@ function apri(p: string, schema: string): Database.Database {
return db;
}
/**
* Migrazione idempotente per un `longevity.db` aperto PRIMA di questa modifica (issue #7):
* `CREATE TABLE IF NOT EXISTS` (sopra) non aggiunge colonne a una tabella che esiste già, e
* in produzione un longevity.db esiste dal 03/09/2026 (VPS,
* /opt/docker/insanitylab-apps/apps/platforms/data/longevity.db) — la stessa trappola già
* documentata sul vincolo di `score`, qui sotto. Senza questa funzione le tre colonne di
* verifica resterebbero assenti in produzione mentre ogni database creato da zero (ogni
* test, e un domani un secondo deploy) le avrebbe già: un disallineamento silenzioso fra
* ambienti, non un errore che si vede subito.
* `PRAGMA table_info` legge le colonne DAVVERO presenti sul file aperto, non lo schema
* dichiarato sopra: per ognuna delle tre assente, un `ALTER TABLE ... ADD COLUMN` la
* aggiunge (nullable, quindi le righe esistenti restano valide contro il CHECK — NULL non
* fa mai fallire un CHECK). Su un database che le ha già il controllo salta tutto: girarla
* a ogni apertura (come seedRegistro/seedPesi) è sicuro.
*/
function migra(db: Database.Database): void {
const colonne = new Set(
(db.prepare(`PRAGMA table_info(misure)`).all() as { name: string }[]).map((c) => c.name)
);
if (!colonne.has('verifica')) {
db.exec(`ALTER TABLE misure ADD COLUMN verifica TEXT CHECK (verifica IN ('confermata','scartata'))`);
}
if (!colonne.has('verificata_da')) {
db.exec(`ALTER TABLE misure ADD COLUMN verificata_da TEXT`);
}
if (!colonne.has('verificata_at')) {
db.exec(`ALTER TABLE misure ADD COLUMN verificata_at TEXT`);
}
}
/**
* ⚠️ Il registro dei test e i pesi si seminano QUI, all'apertura del database.
*
@@ -147,6 +184,7 @@ function apri(p: string, schema: string): Database.Database {
*/
export function createLongevityDb(path?: string): Database.Database {
const db = apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY);
migra(db);
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
return db;
+103
View File
@@ -1,5 +1,6 @@
import type Database from 'better-sqlite3';
import { esisteTest } from './registro';
import { calcolaSessione, salvaScore } from './motore';
export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index';
@@ -72,3 +73,105 @@ export function registraMisure(
return { scritte: misure.length, fuoriRange };
}
/** Una misura fuori range in attesa che il trainer decida se e' un dato vero o uno
* strumento/protocollo sbagliato (§9 della spec). L'etichetta e il range vengono dal
* registro (dato di prodotto), non duplicati qui; `data` e' quella della SESSIONE in cui
* la misura e' stata presa, utile a ordinare la lista senza un altro giro sul database. */
export type MisuraDaVerificare = {
id: number;
test_id: string;
etichetta: string;
valore_num: number | null;
unita: string | null;
range_min: number | null;
range_max: number | null;
data: string;
};
/**
* Le misure di un cliente ancora da verificare: fuori range (`fuori_range = 1`) e mai
* verificate (`verifica IS NULL`) — una gia' confermata o scartata non torna qui, la
* decisione del trainer e' definitiva (vedi `verificaMisura`). Ordine per data della
* sessione decrescente: la piu' recente prima, come le altre liste del gestionale.
*/
export function misureDaVerificare(db: Database.Database, clientCode: string): MisuraDaVerificare[] {
return db.prepare(
`SELECT m.id AS id, m.test_id AS test_id, r.etichetta AS etichetta,
m.valore_num AS valore_num, m.unita AS unita,
r.range_min AS range_min, r.range_max AS range_max,
s.data AS data
FROM misure m
JOIN registro_test r ON r.test_id = m.test_id
JOIN sessioni s ON s.id = m.sessione_id
WHERE m.client_code = ? AND m.fuori_range = 1 AND m.verifica IS NULL
ORDER BY s.data DESC, s.id DESC, m.id DESC`
).all(clientCode) as MisuraDaVerificare[];
}
export type EsitoVerifica =
| { ok: true; sessioniRicalcolate: number[] }
| { ok: false; errore: string };
/**
* La decisione del trainer su una misura fuori range: confermarla (e' un dato vero, deve
* contare) o scartarla (e' uno strumento/protocollo sbagliato, resta fuori per sempre).
* Rifiuta una misura inesistente, una gia' dentro il range (non c'era niente da
* verificare) o gia' verificata (la decisione non si cambia scrivendoci sopra — chi
* sbaglia riapre una nuova misura, non riscrive la storia di questa).
*
* Solo la CONFERMA ricalcola: da quel momento `misureValideAlla` la lascia passare, quindi
* ogni punteggio congelato che l'ha ignorata e' cambiato sotto di lui. Si ricongela la
* sessione della misura stessa e ogni sessione SUCCESSIVA dello stesso cliente (stesso
* criterio data/id di `misureValideAlla`): anche quelle possono aver ereditato il valore
* — o la sua assenza — tramite la propagazione in avanti. Una sessione precedente non puo'
* mai vedere una misura futura, quindi non ha bisogno di ricalcolo. Lo SCARTO non tocca
* `score`: la misura restava esclusa anche prima, niente e' cambiato per il motore.
*/
export function verificaMisura(
db: Database.Database,
input: { misuraId: number; esito: 'confermata' | 'scartata'; operatore: string }
): EsitoVerifica {
const misura = db.prepare(
`SELECT m.id AS id, m.client_code AS client_code, m.sessione_id AS sessione_id,
m.fuori_range AS fuori_range, m.verifica AS verifica, s.data AS data
FROM misure m
JOIN sessioni s ON s.id = m.sessione_id
WHERE m.id = ?`
).get(input.misuraId) as
{ id: number; client_code: string; sessione_id: number; fuori_range: number; verifica: string | null; data: string }
| undefined;
if (!misura) return { ok: false, errore: `misura ${input.misuraId} inesistente` };
if (misura.fuori_range === 0) return { ok: false, errore: 'la misura e nel range: non c e niente da verificare' };
if (misura.verifica !== null) return { ok: false, errore: 'misura gia verificata' };
const sessioniRicalcolate: number[] = [];
const tx = db.transaction(() => {
db.prepare(
`UPDATE misure SET verifica = ?, verificata_da = ?, verificata_at = datetime('now') WHERE id = ?`
).run(input.esito, input.operatore, misura.id);
if (input.esito === 'confermata') {
// La sessione della misura stessa (id = sessione_id) piu' ogni sessione successiva
// dello stesso cliente (data > D, o stessa data e id maggiore) — stesso confronto
// di misureValideAlla, cosi' la platea dei ricalcoli e' esattamente quella dei
// punteggi che possono essere cambiati da questa conferma.
const sessioni = db.prepare(
`SELECT id FROM sessioni
WHERE client_code = ?
AND (id = ? OR data > ? OR (data = ? AND id > ?))
ORDER BY data ASC, id ASC`
).all(misura.client_code, misura.sessione_id, misura.data, misura.data, misura.sessione_id) as { id: number }[];
for (const s of sessioni) {
salvaScore(db, s.id, calcolaSessione(db, s.id));
sessioniRicalcolate.push(s.id);
}
}
});
tx();
return { ok: true, sessioniRicalcolate };
}
@@ -40,8 +40,8 @@
* applicarla, e restituisce `null` se il registro (dato modificabile senza
* migrazioni) ha una riga incompleta, invece del punteggio pieno che dava
* `lerp` con estremi indefiniti; e la lettura delle misure esclude
* `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma
* (punto aperto in §12).
* `fuori_range = 1` (§9 della spec) salvo conferma esplicita del trainer
* (`misure.verifica = 'confermata'`, issue #7: `verificaMisura` in `misure.ts`).
*/
import type Database from 'better-sqlite3';
@@ -156,9 +156,11 @@ export const FINESTRA_GIORNI: number | null = null;
*
* Stessa esclusione di `fuori_range = 1` che c'era prima nella lettura diretta (§9 della
* spec): una misura fuori dal range dichiarato non entra, né dalla sessione corrente né da
* una precedente. La colonna della conferma non esiste ancora (§12 della spec, issue #7):
* quando ci sarà, questo filtro andrà rilassato per lasciar passare le misure confermate,
* non tolto del tutto.
* una precedente — A MENO che il trainer l'abbia confermata (`verifica = 'confermata'`,
* colonna aggiunta dall'issue #7, scritta da `verificaMisura` in `misure.ts`): da quel
* momento in poi entra nello score come qualunque altra misura, qui e in ogni sessione
* successiva che la eredita. `verifica = 'scartata'`, o mai verificata (NULL), resta
* esclusa come prima.
*/
export function misureValideAlla(db: Database.Database, sessioneId: number): Map<string, number> {
const sessione = db.prepare(
@@ -170,7 +172,7 @@ export function misureValideAlla(db: Database.Database, sessioneId: number): Map
`SELECT m.test_id AS test_id, m.valore_num AS valore_num, s2.data AS data
FROM misure m
JOIN sessioni s2 ON s2.id = m.sessione_id
WHERE m.client_code = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL
WHERE m.client_code = ? AND (m.fuori_range = 0 OR m.verifica = 'confermata') AND m.valore_num IS NOT NULL
AND (s2.data < ? OR (s2.data = ? AND s2.id <= ?))
ORDER BY s2.data DESC, s2.id DESC, m.id DESC`
).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as
@@ -0,0 +1,52 @@
import type { APIRoute } from 'astro';
import { getLongevityDb } from '../../../../lib/longevity/db';
import { verificaMisura } from '../../../../lib/longevity/misure';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
/**
* La decisione del trainer su una misura fuori range (issue #7): confermarla o scartarla,
* dalla scheda cliente del gestionale (le pagine arrivano con #2, questo endpoint no).
* `canAccessPath` (auth.ts) gia' chiude /api/longevity/gestionale/** a chi non e' trainer
* o admin: qui non c'e' nessun controllo di ruolo da ripetere.
*
* Il codice cliente per il redirect si legge SEMPRE dalla misura nel database, mai da un
* campo del form — stesso principio di questionario.ts (il client_code non si fida di chi
* scrive la richiesta), applicato qui al lato trainer: un id di misura non e' un fascicolo
* che si possa dirottare scrivendo un altro codice nel form.
*/
export const POST: APIRoute = async ({ request, locals }) => {
const form = await request.formData();
const misuraIdRaw = form.get('misura_id');
const misuraId = typeof misuraIdRaw === 'string' ? Number(misuraIdRaw) : NaN;
if (!Number.isInteger(misuraId)) return json(404, { error: 'misura inesistente' });
const db = getLongevityDb();
// Si legge il client_code PRIMA di chiamare verificaMisura: serve per il redirect anche
// quando la verifica viene rifiutata (gia' verificata, nel range...) — l'unico caso senza
// una scheda cliente su cui tornare e' proprio l'id che non esiste.
const riga = db.prepare(`SELECT client_code FROM misure WHERE id = ?`).get(misuraId) as
{ client_code: string } | undefined;
if (!riga) return json(404, { error: `misura ${misuraId} inesistente` });
const tornaA = (query: string): Response => new Response(null, {
status: 303,
headers: { Location: `/longevity/gestionale/cliente/${riga.client_code}${query}` },
});
const esitoRaw = form.get('esito');
const esito: 'confermata' | 'scartata' | null =
esitoRaw === 'confermata' || esitoRaw === 'scartata' ? esitoRaw : null;
if (esito === null) {
return tornaA(`?errore=${encodeURIComponent('Esito non valido: deve essere confermata o scartata.')}`);
}
const risultato = verificaMisura(db, { misuraId, esito, operatore: locals.user!.username });
if (!risultato.ok) return tornaA(`?errore=${encodeURIComponent(risultato.errore)}`);
return tornaA(`?verificata=${esito}`);
};