longevity: il motore legge curve e pesi dal registro, e congela i punteggi
Task 5: applicaCurva smista sulla curva dichiarata in registro_test (non
scritta nel codice); calcolaSessione aggrega le misure attive per
sotto-dominio (media quando piu' test contribuiscono allo stesso
sotto-dominio) e usa cascata.ts coi pesi letti da pesi (pesiDi) per i
sette assi, i tre macro-score e la Fitness Age; salvaScore congela il
risultato in score, con valore a null quando lo stato e' insufficiente.
Corretto anche un refuso nel test del brief ('un test disattivato non
entra nel calcolo'): disattivava q_ore_sonno, ma quella voce sta nel
sotto-dominio questionario_sonno (peso 0.20 su Recupero & Sistema
Nervoso, sempre sotto COPERTURA_MINIMA=0.4 finche' hrv/pressione/hrr non
hanno una voce nel registro), quindi l'asse restava insufficiente prima
e dopo e il JSON risultava identico (verificato: il test falliva con le
due stringhe uguali). Corretto usando q_attivita, l'unica voce del
sotto-dominio questionario_lifestyle che pesa 1.00 su Stile di Vita &
Sonno: disattivarla fa cadere la copertura da 1.00 a 0 e lo stato passa
da ok a insufficiente, una differenza vera.
This commit is contained in:
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Il motore che legge dal registro, non da costanti: curve, parametri e pesi
|
||||||
|
* sono dato (`registro_test`, `pesi`), non codice. Aggiungere o togliere un
|
||||||
|
* test, o cambiare un parametro, resta una modifica ai dati.
|
||||||
|
*
|
||||||
|
* Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel
|
||||||
|
* registro (`registro_test.curva`), coi parametri dichiarati lì
|
||||||
|
* (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un
|
||||||
|
* test senza curva dichiarata (es. un campo testuale) non contribuisce al
|
||||||
|
* calcolo.
|
||||||
|
*
|
||||||
|
* Livelli 2-4 (`calcolaSessione`): raggruppa le misure attive della sessione
|
||||||
|
* per sotto-dominio (media dei test dello stesso sotto-dominio — più test
|
||||||
|
* possono contribuire allo stesso sotto-dominio, es. spinta via push-up o
|
||||||
|
* panca), poi usa `cascata.ts` (`calcolaAsse`, `calcolaMacro`,
|
||||||
|
* `calcolaFitnessAge`) coi pesi letti da `pesi` (via `pesiDi`) per i sette
|
||||||
|
* assi, i tre macro-score e la Fitness Age.
|
||||||
|
*
|
||||||
|
* `salvaScore` congela il risultato nella tabella `score`, una riga per
|
||||||
|
* asse/macro/Fitness Age, con `valore` a `null` quando lo stato è
|
||||||
|
* insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import {
|
||||||
|
curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
|
||||||
|
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, arrotonda1,
|
||||||
|
} from './curve';
|
||||||
|
import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata';
|
||||||
|
import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro';
|
||||||
|
|
||||||
|
/** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */
|
||||||
|
type ParamsCurva = {
|
||||||
|
low?: number; peakLow?: number; peakHigh?: number; high?: number;
|
||||||
|
best?: number; worst?: number; plateauStart?: number;
|
||||||
|
steps?: [number, number][]; zeroVal?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizza un valore grezzo secondo la curva dichiarata nel registro per
|
||||||
|
* quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel
|
||||||
|
* motore. Un test senza curva dichiarata restituisce `null`: non contribuisce
|
||||||
|
* al calcolo di sotto-dominio.
|
||||||
|
*/
|
||||||
|
export function applicaCurva(voce: VoceRegistro, valore: number): number | null {
|
||||||
|
if (!voce.curva) return null;
|
||||||
|
const p = (voce.params ?? {}) as ParamsCurva;
|
||||||
|
switch (voce.curva) {
|
||||||
|
case 'bell':
|
||||||
|
return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number);
|
||||||
|
case 'lin_dec':
|
||||||
|
return curvaDecrescente(valore, p.best as number, p.worst as number);
|
||||||
|
case 'inc_plateau':
|
||||||
|
return curvaCrescenteConPlateau(valore, p.worst as number, p.plateauStart as number);
|
||||||
|
case 'x10':
|
||||||
|
return curvaDirettaX10(valore);
|
||||||
|
case 'x10_inv':
|
||||||
|
return curvaDirettaX10Invertita(valore);
|
||||||
|
case 'decstep':
|
||||||
|
return curvaGradini(valore, p.steps ?? [], p.zeroVal, true);
|
||||||
|
case 'incstep':
|
||||||
|
return curvaGradini(valore, p.steps ?? [], p.zeroVal, false);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age. */
|
||||||
|
export type RisultatoSessione = {
|
||||||
|
assi: Record<string, Punteggio>;
|
||||||
|
macro: Record<string, Punteggio>;
|
||||||
|
fitnessAge: Punteggio;
|
||||||
|
modelVersion: string;
|
||||||
|
questVersion: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da
|
||||||
|
* costanti: `testAttivi` alla data della sessione, i valori grezzi da
|
||||||
|
* `misure`, i pesi da `pesi` (tramite `pesiDi`).
|
||||||
|
*/
|
||||||
|
export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione {
|
||||||
|
const sessione = db.prepare(
|
||||||
|
`SELECT data, eta_alla_data, quest_version FROM sessioni WHERE id = ?`
|
||||||
|
).get(sessioneId) as { data: string; eta_alla_data: number | null; quest_version: string | null } | undefined;
|
||||||
|
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
|
||||||
|
|
||||||
|
const attivi = testAttivi(db, sessione.data);
|
||||||
|
const misurate = new Map<string, number>(
|
||||||
|
(db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ?`).all(sessioneId) as
|
||||||
|
{ test_id: string; valore_num: number | null }[])
|
||||||
|
.filter((m) => m.valore_num !== null)
|
||||||
|
.map((m) => [m.test_id, m.valore_num as number])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per
|
||||||
|
// sotto-dominio — più test nello stesso sotto-dominio contano come media.
|
||||||
|
const perSottoDominio = new Map<string, number[]>();
|
||||||
|
for (const voce of attivi) {
|
||||||
|
if (!voce.sotto_dominio) continue;
|
||||||
|
const grezzo = misurate.get(voce.test_id);
|
||||||
|
if (grezzo === undefined) continue;
|
||||||
|
const punteggio = applicaCurva(voce, grezzo);
|
||||||
|
if (punteggio === null) continue;
|
||||||
|
const lista = perSottoDominio.get(voce.sotto_dominio) ?? [];
|
||||||
|
lista.push(punteggio);
|
||||||
|
perSottoDominio.set(voce.sotto_dominio, lista);
|
||||||
|
}
|
||||||
|
const sottoDominio: Record<string, number> = {};
|
||||||
|
for (const [nome, valori] of perSottoDominio) {
|
||||||
|
sottoDominio[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso.
|
||||||
|
const nomiAssi = (db.prepare(
|
||||||
|
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse'`
|
||||||
|
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
|
||||||
|
|
||||||
|
const assi: Record<string, Punteggio> = {};
|
||||||
|
for (const nomeAsse of nomiAssi) {
|
||||||
|
assi[nomeAsse] = calcolaAsse(pesiDi(db, MODEL_VERSION, 'asse', nomeAsse), sottoDominio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Livello 3: i tre macro-score (Performance/Energy/Recovery).
|
||||||
|
const nomiMacro = (db.prepare(
|
||||||
|
`SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'macro'`
|
||||||
|
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
|
||||||
|
|
||||||
|
const macro: Record<string, Punteggio> = {};
|
||||||
|
for (const nomeMacro of nomiMacro) {
|
||||||
|
macro[nomeMacro] = calcolaMacro(pesiDi(db, MODEL_VERSION, 'macro', nomeMacro), assi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Livello 4: Fitness Age. cardio/forza_resto/composizione/stabilita vengono
|
||||||
|
// dal punteggio intero dell'asse; handgrip_isolato e hrv_isolato dal
|
||||||
|
// sotto-dominio isolato, prima che entri nell'aggregazione del suo asse —
|
||||||
|
// come nell'oracolo, che guarda quei due sotto-domini da soli.
|
||||||
|
const valoreAsse = (nome: string): number | null => {
|
||||||
|
const a = assi[nome];
|
||||||
|
return a && a.stato === 'ok' ? a.valore : null;
|
||||||
|
};
|
||||||
|
const vociFitnessAge: Record<string, number | null> = {
|
||||||
|
cardio: valoreAsse('Cardio-Respiratorio'),
|
||||||
|
handgrip_isolato: sottoDominio.handgrip ?? null,
|
||||||
|
hrv_isolato: sottoDominio.hrv ?? null,
|
||||||
|
forza_resto: valoreAsse('Forza & Struttura'),
|
||||||
|
composizione: valoreAsse('Composizione Corporea'),
|
||||||
|
stabilita: valoreAsse('Stabilità & Mobilità Funzionale'),
|
||||||
|
};
|
||||||
|
const { fitnessAge, composito } = calcolaFitnessAge(
|
||||||
|
sessione.eta_alla_data ?? 0,
|
||||||
|
pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'),
|
||||||
|
vociFitnessAge
|
||||||
|
);
|
||||||
|
const fitnessAgePunteggio: Punteggio = composito.stato === 'insufficiente'
|
||||||
|
? { stato: 'insufficiente', copertura: composito.copertura }
|
||||||
|
: { stato: 'ok', valore: fitnessAge as number, copertura: composito.copertura };
|
||||||
|
|
||||||
|
return {
|
||||||
|
assi, macro, fitnessAge: fitnessAgePunteggio,
|
||||||
|
modelVersion: MODEL_VERSION, questVersion: sessione.quest_version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Congela il risultato di `calcolaSessione` nella tabella `score`: una riga
|
||||||
|
* per asse, una per macro-score, una per la Fitness Age. `valore` va a
|
||||||
|
* `null` quando lo stato è insufficiente — il tipo `Punteggio` non porta un
|
||||||
|
* `valore` in quel ramo, quindi non c'è modo di scrivere un numero fasullo.
|
||||||
|
*/
|
||||||
|
export function salvaScore(db: Database.Database, sessioneId: number, risultato: RisultatoSessione): void {
|
||||||
|
const sessione = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as
|
||||||
|
{ client_code: string } | undefined;
|
||||||
|
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
|
||||||
|
|
||||||
|
const ins = db.prepare(
|
||||||
|
`INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, quest_version, model_version)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const riga = (tipo: 'asse' | 'macro' | 'fitness_age', elemento: string, p: Punteggio): void => {
|
||||||
|
ins.run(
|
||||||
|
sessione.client_code, sessioneId, tipo, elemento,
|
||||||
|
p.stato === 'ok' ? p.valore : null,
|
||||||
|
p.copertura, p.stato, risultato.questVersion, risultato.modelVersion
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const [nome, p] of Object.entries(risultato.assi)) riga('asse', nome, p);
|
||||||
|
for (const [nome, p] of Object.entries(risultato.macro)) riga('macro', nome, p);
|
||||||
|
riga('fitness_age', 'fitness_age', risultato.fitnessAge);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import type Database from 'better-sqlite3';
|
|||||||
|
|
||||||
export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep';
|
export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep';
|
||||||
|
|
||||||
|
/** Versione del modello di calcolo: curve e pesi. Distinta da QUEST_VERSION, che versiona le domande. */
|
||||||
|
export const MODEL_VERSION = 'v1.0';
|
||||||
|
|
||||||
export type VoceRegistro = {
|
export type VoceRegistro = {
|
||||||
test_id: string;
|
test_id: string;
|
||||||
etichetta: string;
|
etichetta: string;
|
||||||
@@ -183,3 +186,14 @@ export function esisteTest(db: Database.Database, testId: string): boolean {
|
|||||||
const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId);
|
const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId);
|
||||||
return r !== undefined;
|
return r !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** I pesi di un contenitore (un asse, un macro-score o la Fitness Age) per la versione del modello data. */
|
||||||
|
export function pesiDi(
|
||||||
|
db: Database.Database, modelVersion: string,
|
||||||
|
livello: 'asse' | 'macro' | 'fitness_age', contenitore: string
|
||||||
|
): Record<string, number> {
|
||||||
|
const righe = db.prepare(
|
||||||
|
`SELECT elemento, peso FROM pesi WHERE model_version = ? AND livello = ? AND contenitore = ?`
|
||||||
|
).all(modelVersion, livello, contenitore) as { elemento: string; peso: number }[];
|
||||||
|
return Object.fromEntries(righe.map((r) => [r.elemento, r.peso]));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createLongevityDb } from '../../src/lib/longevity/db';
|
||||||
|
import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro';
|
||||||
|
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
|
||||||
|
import { applicaCurva, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
|
||||||
|
|
||||||
|
function dbPronto() {
|
||||||
|
const db = createLongevityDb(':memory:');
|
||||||
|
seedRegistro(db);
|
||||||
|
seedPesi(db, MODEL_VERSION);
|
||||||
|
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voce = (db: ReturnType<typeof dbPronto>, id: string) =>
|
||||||
|
db.prepare(`SELECT * FROM registro_test WHERE test_id = ?`).get(id) as Record<string, unknown>;
|
||||||
|
|
||||||
|
describe('il motore legge le curve dal registro', () => {
|
||||||
|
it('applica la curva dichiarata per il test, non una scritta nel codice', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) };
|
||||||
|
expect(applicaCurva(v as never, 8)).toBe(100); // dentro il picco 7-9
|
||||||
|
expect(applicaCurva(v as never, 4)).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rispetta la soglia dell alcol come e scritta nel registro', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
|
||||||
|
expect(applicaCurva(v as never, 0)).toBe(100);
|
||||||
|
expect(applicaCurva(v as never, 7)).toBe(100);
|
||||||
|
expect(applicaCurva(v as never, 14)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cambiare i parametri nel registro cambia il punteggio, senza toccare il codice', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
db.prepare(`UPDATE registro_test SET params = ? WHERE test_id = 'q_alcol_life'`)
|
||||||
|
.run(JSON.stringify({ best: 0, worst: 7 }));
|
||||||
|
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
|
||||||
|
expect(applicaCurva(v as never, 7)).toBe(0); // con i parametri nuovi, 7 non vale piu 100
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un test disattivato non entra nel calcolo', () => {
|
||||||
|
// NOTA: la versione originale di questo test disattivava 'q_ore_sonno' e si
|
||||||
|
// aspettava una differenza. Ma 'q_ore_sonno' e' uno delle SEI voci del
|
||||||
|
// sotto-dominio 'questionario_sonno' (peso 0.20 dentro "Recupero & Sistema
|
||||||
|
// Nervoso", che pesa in tutto 1.00: hrv 0.50 + pressione 0.15 + hrr 0.15 +
|
||||||
|
// questionario_sonno 0.20). Quell'asse resta 'insufficiente' con o senza
|
||||||
|
// 'q_ore_sonno' (0.20 < COPERTURA_MINIMA 0.4, e hrv/pressione/hrr non hanno
|
||||||
|
// ancora una voce nel registro): siccome un asse insufficiente non porta
|
||||||
|
// 'valore' nel tipo Punteggio, il JSON prima/dopo risultava IDENTICO — la
|
||||||
|
// suite lo ha confermato (verificato eseguendo il test: fallisce con le due
|
||||||
|
// stringhe uguali). Corretto qui usando 'q_attivita', l'unica voce del
|
||||||
|
// sotto-dominio 'questionario_lifestyle' che pesa 1.00 dentro "Stile di Vita
|
||||||
|
// & Sonno": disattivarla fa cadere la copertura di quell'asse da 1.00 a 0 e
|
||||||
|
// lo stato passa da 'ok' a 'insufficiente' — una differenza vera e osservabile.
|
||||||
|
const db = dbPronto();
|
||||||
|
salvaCompilazione(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte: {
|
||||||
|
q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 0,
|
||||||
|
q_attivita: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const prima = calcolaSessione(db, 1);
|
||||||
|
expect(prima.assi['Stile di Vita & Sonno'].stato).toBe('ok');
|
||||||
|
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_attivita'`).run();
|
||||||
|
const dopo = calcolaSessione(db, 1);
|
||||||
|
expect(dopo.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente');
|
||||||
|
expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calcolo e congelamento di una sessione', () => {
|
||||||
|
it('produce i sette assi, e quelli senza dati sono insufficienti', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
salvaCompilazione(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 1 },
|
||||||
|
});
|
||||||
|
const r = calcolaSessione(db, 1);
|
||||||
|
expect(Object.keys(r.assi).length).toBe(7);
|
||||||
|
// il questionario copre il sonno, che pesa 0.20 dentro Recupero: sotto il 40%
|
||||||
|
expect(r.assi['Recupero & Sistema Nervoso'].stato).toBe('insufficiente');
|
||||||
|
// Stile di Vita e coperto al 100% dal solo questionario, ma qui non abbiamo risposto
|
||||||
|
expect(r.assi['Forza & Struttura'].stato).toBe('insufficiente');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('congela i punteggi con le DUE versioni', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
salvaCompilazione(db, {
|
||||||
|
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
|
||||||
|
risposte: { q_riposato: 7 },
|
||||||
|
});
|
||||||
|
salvaScore(db, 1, calcolaSessione(db, 1));
|
||||||
|
const righe = db.prepare(`SELECT tipo, elemento, valore, stato, quest_version, model_version FROM score`).all() as Record<string, unknown>[];
|
||||||
|
expect(righe.length).toBeGreaterThan(0);
|
||||||
|
for (const r of righe) {
|
||||||
|
expect(r.model_version).toBe(MODEL_VERSION);
|
||||||
|
expect(r.quest_version).toBe('v1.0');
|
||||||
|
if (r.stato === 'insufficiente') expect(r.valore).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('i pesi arrivano dal registro e sono quelli della versione richiesta', () => {
|
||||||
|
const db = dbPronto();
|
||||||
|
const pesi = pesiDi(db, MODEL_VERSION, 'asse', 'Forza & Struttura');
|
||||||
|
expect(pesi.handgrip).toBe(0.25);
|
||||||
|
expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user