mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'v143/fixdata' into release/v1.43.0
This commit is contained in:
commit
a806676a71
62 files changed
+2018
-209
No files matched your search
+19
-7
@@ -24630,8 +24630,11 @@ paths:
|
||||
tags:
|
||||
- Anamnesis
|
||||
summary: Record an AUDIT-C (v1.43)
|
||||
description: Scores the three items on the server. A replay with the same `externalId` answers 200 with the stored
|
||||
result. 422 `anamnesis.auditC.invalid`; 429 `anamnesis.auditC.rateLimited` after 30 in an hour.
|
||||
description: Scores the three items on the server. The entry is stored in the language of the wording the read route
|
||||
offers this account (`auditC.instrument.locale`), and the cut-off follows that wording; `locale`, when sent,
|
||||
must name that same wording. A replay with the same `externalId` answers 200 with the stored result. 422
|
||||
`anamnesis.auditC.invalid` (also for another wording than the one offered, and for a `takenAt` more than five
|
||||
minutes ahead of the server clock); 429 `anamnesis.auditC.rateLimited` after 30 in an hour.
|
||||
parameters:
|
||||
- *a6
|
||||
requestBody:
|
||||
@@ -38945,6 +38948,9 @@ components:
|
||||
id:
|
||||
type: string
|
||||
minLength: 1
|
||||
ref:
|
||||
type: string
|
||||
minLength: 1
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -40524,6 +40530,9 @@ components:
|
||||
id:
|
||||
type: string
|
||||
minLength: 1
|
||||
ref:
|
||||
type: string
|
||||
minLength: 1
|
||||
panel:
|
||||
type:
|
||||
- string
|
||||
@@ -81038,11 +81047,12 @@ components:
|
||||
- standard
|
||||
- full
|
||||
description: "How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at
|
||||
most every 24 h after a sync and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a
|
||||
short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic
|
||||
document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a
|
||||
hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43).
|
||||
The interactive Coach is the same at every level."
|
||||
most every 24 h after a sync (or the operator's sync gap when that is longer, so never more often than
|
||||
`standard`) and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily
|
||||
briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading,
|
||||
no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry,
|
||||
background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The
|
||||
interactive Coach is the same at every level."
|
||||
CoachReasoningState:
|
||||
type: object
|
||||
properties:
|
||||
@@ -86447,6 +86457,7 @@ components:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/AuditCResult"
|
||||
- type: "null"
|
||||
description: The newest entry whose answers this host can open; an entry it cannot open is listed in `history` only.
|
||||
history:
|
||||
type: array
|
||||
items:
|
||||
@@ -86470,6 +86481,7 @@ components:
|
||||
description: Newest first, at most 20.
|
||||
offerDue:
|
||||
type: boolean
|
||||
description: No readable entry, or the newest one whose answers open (the one in `latest`) is 365 days old or older.
|
||||
instrument:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+17
-2
@@ -8223,7 +8223,15 @@
|
||||
"busy": "Ein Probelauf oder eine Neuberechnung läuft noch. Versuche es erneut, sobald er fertig ist.",
|
||||
"dryRunRequired": "Starte zuerst einen Probelauf. Zum Anwenden braucht es einen, der in den letzten sieben Tagen abgeschlossen wurde.",
|
||||
"generic": "Die Anfrage ist nicht durchgegangen."
|
||||
}
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "Die Neuberechnung wartet auf den Hintergrunddienst.",
|
||||
"running": "Die Pulstage werden neu berechnet.",
|
||||
"done": "Pulstage am {at} für {accounts} Konten neu berechnet.",
|
||||
"failed": "Die Neuberechnung ist abgebrochen. Das App-Log nennt die Ursache; starte sie erneut."
|
||||
},
|
||||
"rebuildAgain": "Erneut berechnen",
|
||||
"rebuildStarted": "Neuberechnung gestartet."
|
||||
}
|
||||
},
|
||||
"carrier": "Netzbetreiber",
|
||||
@@ -11279,7 +11287,14 @@
|
||||
"removeConfirmBody": "Der Eintrag verschwindet aus deinem Verlauf. Das lässt sich hier nicht rückgängig machen.",
|
||||
"removedToast": "Eintrag entfernt",
|
||||
"removeError": "Der Eintrag konnte nicht entfernt werden.",
|
||||
"historyEntry": "{date}: {score} von 12"
|
||||
"historyEntry": "{date}: {score} von 12",
|
||||
"legacyLabel": "Frühere Angabe: {value}",
|
||||
"legacyHint": "Aus der kurzen Alkoholfrage vor diesem Fragebogen. Sobald ein Fragebogen-Ergebnis vorliegt, liest der Coach sie nicht mehr.",
|
||||
"legacyRemove": "Frühere Angabe entfernen",
|
||||
"legacyRemoveConfirmTitle": "Frühere Angabe entfernen?",
|
||||
"legacyRemoveConfirmBody": "Die frühere Angabe verschwindet aus deinen Daten. Fragebogen-Ergebnisse bleiben.",
|
||||
"legacyRemovedToast": "Frühere Angabe entfernt",
|
||||
"legacyRemoveError": "Die frühere Angabe konnte nicht entfernt werden."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "In Zustandstagebuch übernehmen",
|
||||
|
||||
+17
-2
@@ -8223,7 +8223,15 @@
|
||||
"busy": "A dry run or recalculation is still running. Try again once it has finished.",
|
||||
"dryRunRequired": "Run a dry run first. Applying needs one that finished in the last seven days.",
|
||||
"generic": "The request did not go through."
|
||||
}
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "The recalculation is waiting for the background worker.",
|
||||
"running": "The pulse days are being recalculated.",
|
||||
"done": "Pulse days recalculated on {at} for {accounts} accounts.",
|
||||
"failed": "The recalculation stopped. The app log names the cause; run it again."
|
||||
},
|
||||
"rebuildAgain": "Recalculate again",
|
||||
"rebuildStarted": "Recalculation started."
|
||||
}
|
||||
},
|
||||
"carrier": "Carrier",
|
||||
@@ -11279,7 +11287,14 @@
|
||||
"removeConfirmBody": "The entry leaves your history. This cannot be undone here.",
|
||||
"removedToast": "Entry removed",
|
||||
"removeError": "The entry could not be removed.",
|
||||
"historyEntry": "{date}: {score} of 12"
|
||||
"historyEntry": "{date}: {score} of 12",
|
||||
"legacyLabel": "Earlier answer: {value}",
|
||||
"legacyHint": "From the short alcohol question asked before this questionnaire. Once a questionnaire result is on file, the Coach no longer reads it.",
|
||||
"legacyRemove": "Remove earlier answer",
|
||||
"legacyRemoveConfirmTitle": "Remove the earlier answer?",
|
||||
"legacyRemoveConfirmBody": "The earlier answer leaves your record. Questionnaire results stay.",
|
||||
"legacyRemovedToast": "Earlier answer removed",
|
||||
"legacyRemoveError": "The earlier answer could not be removed."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Add to condition journal",
|
||||
|
||||
+17
-2
@@ -8223,7 +8223,15 @@
|
||||
"busy": "Todavía hay una prueba o un recálculo en marcha. Inténtalo de nuevo cuando termine.",
|
||||
"dryRunRequired": "Ejecuta primero una prueba. Para aplicar hace falta una terminada en los últimos siete días.",
|
||||
"generic": "La solicitud no se completó."
|
||||
}
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "El recálculo espera al proceso en segundo plano.",
|
||||
"running": "Se están recalculando los días de pulso.",
|
||||
"done": "Días de pulso recalculados el {at} para {accounts} cuentas.",
|
||||
"failed": "El recálculo se detuvo. El registro de la app indica la causa; vuelve a ejecutarlo."
|
||||
},
|
||||
"rebuildAgain": "Recalcular de nuevo",
|
||||
"rebuildStarted": "Recálculo iniciado."
|
||||
}
|
||||
},
|
||||
"carrier": "Operador",
|
||||
@@ -11279,7 +11287,14 @@
|
||||
"removeConfirmBody": "La entrada sale de tu historial. Aquí no se puede deshacer.",
|
||||
"removedToast": "Entrada eliminada",
|
||||
"removeError": "No se pudo eliminar la entrada.",
|
||||
"historyEntry": "{date}: {score} de 12"
|
||||
"historyEntry": "{date}: {score} de 12",
|
||||
"legacyLabel": "Respuesta anterior: {value}",
|
||||
"legacyHint": "De la pregunta breve sobre alcohol anterior a este cuestionario. En cuanto hay un resultado del cuestionario, el Coach deja de leerla.",
|
||||
"legacyRemove": "Eliminar respuesta anterior",
|
||||
"legacyRemoveConfirmTitle": "¿Eliminar la respuesta anterior?",
|
||||
"legacyRemoveConfirmBody": "La respuesta anterior sale de tu registro. Los resultados del cuestionario se mantienen.",
|
||||
"legacyRemovedToast": "Respuesta anterior eliminada",
|
||||
"legacyRemoveError": "No se pudo eliminar la respuesta anterior."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Pasar al diario de afecciones",
|
||||
|
||||
+17
-2
@@ -8223,7 +8223,15 @@
|
||||
"busy": "Une simulation ou un recalcul est encore en cours. Réessayez une fois terminé.",
|
||||
"dryRunRequired": "Lancez d'abord une simulation. L'application en exige une terminée au cours des sept derniers jours.",
|
||||
"generic": "La requête n'a pas abouti."
|
||||
}
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "Le recalcul attend le processus en arrière-plan.",
|
||||
"running": "Les jours de pouls sont en cours de recalcul.",
|
||||
"done": "Jours de pouls recalculés le {at} pour {accounts} comptes.",
|
||||
"failed": "Le recalcul s’est arrêté. Le journal de l’app en indique la cause ; relancez-le."
|
||||
},
|
||||
"rebuildAgain": "Recalculer à nouveau",
|
||||
"rebuildStarted": "Recalcul lancé."
|
||||
}
|
||||
},
|
||||
"carrier": "Opérateur",
|
||||
@@ -11279,7 +11287,14 @@
|
||||
"removeConfirmBody": "L'entrée quitte votre historique. Impossible d'annuler ici.",
|
||||
"removedToast": "Entrée supprimée",
|
||||
"removeError": "Impossible de supprimer l'entrée.",
|
||||
"historyEntry": "{date} : {score} sur 12"
|
||||
"historyEntry": "{date} : {score} sur 12",
|
||||
"legacyLabel": "Réponse précédente : {value}",
|
||||
"legacyHint": "Issue de la courte question sur l’alcool posée avant ce questionnaire. Dès qu’un résultat du questionnaire existe, le Coach ne la lit plus.",
|
||||
"legacyRemove": "Supprimer la réponse précédente",
|
||||
"legacyRemoveConfirmTitle": "Supprimer la réponse précédente ?",
|
||||
"legacyRemoveConfirmBody": "La réponse précédente quitte votre dossier. Les résultats du questionnaire restent.",
|
||||
"legacyRemovedToast": "Réponse précédente supprimée",
|
||||
"legacyRemoveError": "La réponse précédente n’a pas pu être supprimée."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Reprendre dans le journal des affections",
|
||||
|
||||
+17
-2
@@ -8223,7 +8223,15 @@
|
||||
"busy": "Una prova o un ricalcolo è ancora in corso. Riprova quando è terminato.",
|
||||
"dryRunRequired": "Avvia prima una prova. Per applicare ne serve una terminata negli ultimi sette giorni.",
|
||||
"generic": "La richiesta non è andata a buon fine."
|
||||
}
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "Il ricalcolo attende il processo in background.",
|
||||
"running": "I giorni del polso sono in fase di ricalcolo.",
|
||||
"done": "Giorni del polso ricalcolati il {at} per {accounts} account.",
|
||||
"failed": "Il ricalcolo si è interrotto. Il log dell’app indica la causa; avvialo di nuovo."
|
||||
},
|
||||
"rebuildAgain": "Ricalcola di nuovo",
|
||||
"rebuildStarted": "Ricalcolo avviato."
|
||||
}
|
||||
},
|
||||
"carrier": "Operatore",
|
||||
@@ -11279,7 +11287,14 @@
|
||||
"removeConfirmBody": "La voce esce dalla tua cronologia. Qui non si può annullare.",
|
||||
"removedToast": "Voce rimossa",
|
||||
"removeError": "Impossibile rimuovere la voce.",
|
||||
"historyEntry": "{date}: {score} su 12"
|
||||
"historyEntry": "{date}: {score} su 12",
|
||||
"legacyLabel": "Risposta precedente: {value}",
|
||||
"legacyHint": "Dalla breve domanda sull’alcol posta prima di questo questionario. Quando c’è un risultato del questionario, il Coach non la legge più.",
|
||||
"legacyRemove": "Rimuovi risposta precedente",
|
||||
"legacyRemoveConfirmTitle": "Rimuovere la risposta precedente?",
|
||||
"legacyRemoveConfirmBody": "La risposta precedente esce dai tuoi dati. I risultati del questionario restano.",
|
||||
"legacyRemovedToast": "Risposta precedente rimossa",
|
||||
"legacyRemoveError": "Non è stato possibile rimuovere la risposta precedente."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Riporta nel diario delle condizioni",
|
||||
|
||||
+17
-2
@@ -8198,7 +8198,15 @@
|
||||
"busy": "시험 실행이나 재계산이 아직 진행 중입니다. 끝난 뒤 다시 시도하세요.",
|
||||
"dryRunRequired": "먼저 시험 실행을 하세요. 적용하려면 최근 7일 안에 끝난 시험 실행이 필요합니다.",
|
||||
"generic": "요청이 처리되지 않았습니다."
|
||||
}
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "재계산이 백그라운드 작업을 기다리고 있습니다.",
|
||||
"running": "맥박 일별 값을 재계산하고 있습니다.",
|
||||
"done": "{at}에 계정 {accounts}개의 맥박 일별 값을 재계산했습니다.",
|
||||
"failed": "재계산이 중단되었습니다. 앱 로그에 원인이 있습니다. 다시 실행하세요."
|
||||
},
|
||||
"rebuildAgain": "다시 계산",
|
||||
"rebuildStarted": "재계산을 시작했습니다."
|
||||
}
|
||||
},
|
||||
"carrier": "통신사",
|
||||
@@ -11279,7 +11287,14 @@
|
||||
"removeConfirmBody": "기록이 내역에서 사라져요. 여기서는 되돌릴 수 없어요.",
|
||||
"removedToast": "기록을 삭제했어요",
|
||||
"removeError": "기록을 삭제하지 못했어요.",
|
||||
"historyEntry": "{date}: 12점 중 {score}점"
|
||||
"historyEntry": "{date}: 12점 중 {score}점",
|
||||
"legacyLabel": "이전 답변: {value}",
|
||||
"legacyHint": "이 설문 전에 묻던 짧은 음주 질문의 답변입니다. 설문 결과가 있으면 코치는 더 이상 이 답변을 읽지 않습니다.",
|
||||
"legacyRemove": "이전 답변 삭제",
|
||||
"legacyRemoveConfirmTitle": "이전 답변을 삭제할까요?",
|
||||
"legacyRemoveConfirmBody": "이전 답변이 기록에서 삭제됩니다. 설문 결과는 그대로 남습니다.",
|
||||
"legacyRemovedToast": "이전 답변을 삭제했습니다",
|
||||
"legacyRemoveError": "이전 답변을 삭제하지 못했습니다."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "상태 일지로 옮기기",
|
||||
|
||||
+17
-2
@@ -8223,7 +8223,15 @@
|
||||
"busy": "Próba lub przeliczanie wciąż trwa. Spróbuj ponownie, gdy się zakończy.",
|
||||
"dryRunRequired": "Najpierw uruchom próbę. Do zastosowania potrzebna jest próba zakończona w ciągu ostatnich siedmiu dni.",
|
||||
"generic": "Żądanie nie zostało zrealizowane."
|
||||
}
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "Ponowne przeliczenie czeka na proces w tle.",
|
||||
"running": "Dni pulsu są przeliczane.",
|
||||
"done": "Dni pulsu przeliczone {at} dla kont: {accounts}.",
|
||||
"failed": "Przeliczanie zostało przerwane. Przyczynę podaje log aplikacji; uruchom je ponownie."
|
||||
},
|
||||
"rebuildAgain": "Przelicz ponownie",
|
||||
"rebuildStarted": "Przeliczanie rozpoczęte."
|
||||
}
|
||||
},
|
||||
"carrier": "Operator",
|
||||
@@ -11279,7 +11287,14 @@
|
||||
"removeConfirmBody": "Wpis zniknie z historii. Tutaj nie da się tego cofnąć.",
|
||||
"removedToast": "Wpis usunięty",
|
||||
"removeError": "Nie udało się usunąć wpisu.",
|
||||
"historyEntry": "{date}: {score} z 12"
|
||||
"historyEntry": "{date}: {score} z 12",
|
||||
"legacyLabel": "Wcześniejsza odpowiedź: {value}",
|
||||
"legacyHint": "Z krótkiego pytania o alkohol sprzed tego kwestionariusza. Gdy jest wynik kwestionariusza, Coach przestaje ją czytać.",
|
||||
"legacyRemove": "Usuń wcześniejszą odpowiedź",
|
||||
"legacyRemoveConfirmTitle": "Usunąć wcześniejszą odpowiedź?",
|
||||
"legacyRemoveConfirmBody": "Wcześniejsza odpowiedź zniknie z Twoich danych. Wyniki kwestionariusza zostają.",
|
||||
"legacyRemovedToast": "Wcześniejsza odpowiedź usunięta",
|
||||
"legacyRemoveError": "Nie udało się usunąć wcześniejszej odpowiedzi."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Przenieś do dziennika stanów",
|
||||
|
||||
@@ -66,10 +66,14 @@ const post = (body: unknown) =>
|
||||
const NOW = new Date("2026-10-16T12:00:00Z");
|
||||
const send = vi.fn();
|
||||
|
||||
function settings(dryRun: unknown, method = "hour_mean_v1") {
|
||||
function settings(
|
||||
dryRun: unknown,
|
||||
method = "hour_mean_v1",
|
||||
appliedAt: Date | null = null,
|
||||
) {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
pulseDayMeanMethod: method,
|
||||
pulseDayMeanAppliedAt: null,
|
||||
pulseDayMeanAppliedAt: appliedAt,
|
||||
pulseDayMeanDryRunJson: dryRun,
|
||||
} as never);
|
||||
}
|
||||
@@ -136,6 +140,7 @@ describe("GET", () => {
|
||||
method: "hour_mean_v1",
|
||||
appliedAt: null,
|
||||
lastDryRun: doneAt("2026-10-15T08:00:00.000Z"),
|
||||
lastRebuild: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,7 +204,9 @@ describe("POST dry_run", () => {
|
||||
const res = await post({ action: "dry_run" });
|
||||
expect(res.status).toBe(409);
|
||||
expect(vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0]).toEqual(
|
||||
expect.objectContaining({ update: { pulseDayMeanDryRunJson: previous } }),
|
||||
expect.objectContaining({
|
||||
update: { pulseDayMeanDryRunJson: { ...previous, rebuild: null } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -264,10 +271,71 @@ describe("POST apply", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST rebuild", () => {
|
||||
const failedRebuild = {
|
||||
status: "failed",
|
||||
startedAt: "2026-10-15T09:00:00.000Z",
|
||||
finishedAt: "2026-10-15T09:05:00.000Z",
|
||||
accounts: null,
|
||||
rows: null,
|
||||
};
|
||||
|
||||
it("is refused before any apply", async () => {
|
||||
settings(doneAt("2026-10-15T08:00:00.000Z"));
|
||||
const res = await post({ action: "rebuild" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta.errorCode).toBe(
|
||||
"maintenance.pulseDayMean.dryRunRequired",
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the recalculation again after a stopped one, without a new dry run", async () => {
|
||||
// The dry run is three weeks old: apply would refuse it, a rebuild does not.
|
||||
settings(
|
||||
{ ...doneAt("2026-09-20T08:00:00.000Z"), rebuild: failedRebuild },
|
||||
"weighted_hour_mean_v2",
|
||||
new Date("2026-09-20T09:00:00.000Z"),
|
||||
);
|
||||
const res = await post({ action: "rebuild" });
|
||||
expect(res.status).toBe(202);
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"pulse-day-mean",
|
||||
{ action: "rebuild" },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(
|
||||
vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0],
|
||||
).toMatchObject({
|
||||
update: {
|
||||
pulseDayMeanDryRunJson: { rebuild: { status: "queued" } },
|
||||
},
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.pulseDayMean.rebuild",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a rebuild left queued with no job behind it as failed", async () => {
|
||||
settings(
|
||||
{
|
||||
...doneAt("2026-10-15T08:00:00.000Z"),
|
||||
rebuild: { ...failedRebuild, status: "running", finishedAt: null },
|
||||
},
|
||||
"weighted_hour_mean_v2",
|
||||
new Date("2026-10-15T09:00:00.000Z"),
|
||||
);
|
||||
const body = await (await get()).json();
|
||||
expect(body.data.lastRebuild.status).toBe("failed");
|
||||
expect(body.data.lastDryRun.status).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST body", () => {
|
||||
it("is strict", async () => {
|
||||
expect((await post({ action: "apply", force: true })).status).toBe(422);
|
||||
expect((await post({ action: "rebuild" })).status).toBe(422);
|
||||
expect((await post({ action: "purge" })).status).toBe(422);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* GET answers the active method, when it was applied and the last dry run
|
||||
* (status and result: per-account ordinal labels only, never an id). POST
|
||||
* `{ action: "dry_run" | "apply" }` answers 202 `{ enqueued }`: a dry run
|
||||
* `{ action: "dry_run" | "apply" | "rebuild" }` answers 202 `{ enqueued }`: a dry run
|
||||
* computes both methods for every account and day and writes nothing but its
|
||||
* result; `apply` needs a successful dry run from the last seven days (409
|
||||
* `maintenance.pulseDayMean.dryRunRequired`), switches the method and
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
PULSE_DAY_MEAN_SEND_OPTIONS,
|
||||
readPulseDayMeanStatus,
|
||||
writeDryRun,
|
||||
writeRebuild,
|
||||
type PulseDayMeanPayload,
|
||||
} from "@/lib/jobs/pulse-day-mean";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
import {
|
||||
hasFreshDryRun,
|
||||
pulseDayMeanActionSchema,
|
||||
type PulseDayMeanStatus,
|
||||
} from "@/lib/validations/pulse-day-mean";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -54,6 +56,33 @@ function busy() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue the rebuild and record it as queued, so the card shows it from the
|
||||
* moment it is asked for and a rebuild that never starts reads as failed.
|
||||
* When another run holds the singleton, the previous rebuild state is put
|
||||
* back and the answer is false.
|
||||
*/
|
||||
async function sendRebuild(
|
||||
boss: NonNullable<ReturnType<typeof getGlobalBoss>>,
|
||||
previous: PulseDayMeanStatus["lastRebuild"],
|
||||
): Promise<boolean> {
|
||||
await writeRebuild({
|
||||
status: "queued",
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
accounts: null,
|
||||
rows: null,
|
||||
});
|
||||
const payload: PulseDayMeanPayload = { action: "rebuild" };
|
||||
const jobId = await boss.send(
|
||||
PULSE_DAY_MEAN_QUEUE,
|
||||
payload,
|
||||
PULSE_DAY_MEAN_SEND_OPTIONS,
|
||||
);
|
||||
if (jobId === null) await writeRebuild(previous);
|
||||
return jobId !== null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
const status = await readPulseDayMeanStatus();
|
||||
@@ -110,6 +139,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess({ enqueued: true }, 202);
|
||||
}
|
||||
|
||||
if (action === "rebuild") {
|
||||
// The recalculation again, after an apply whose rebuild stopped. The
|
||||
// method is already switched; the dry run that justified it stands.
|
||||
const status = await readPulseDayMeanStatus();
|
||||
if (status.appliedAt === null) {
|
||||
return apiError(
|
||||
"Nothing applied yet; run a dry run and apply first",
|
||||
409,
|
||||
{ errorCode: "maintenance.pulseDayMean.dryRunRequired" },
|
||||
);
|
||||
}
|
||||
const enqueued = await sendRebuild(boss, status.lastRebuild);
|
||||
if (!enqueued) return busy();
|
||||
await auditLog("admin.pulseDayMean.rebuild", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { method: status.method },
|
||||
});
|
||||
annotate({ meta: { pulse_day_mean_enqueued: true } });
|
||||
return apiSuccess({ enqueued: true }, 202);
|
||||
}
|
||||
|
||||
if (!hasFreshDryRun(await readPulseDayMeanStatus())) {
|
||||
return apiError(
|
||||
"Run a dry run first; apply needs one finished in the last seven days",
|
||||
@@ -135,13 +186,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
setActivePulseDayMeanMethod(APPLIED_METHOD);
|
||||
const payload: PulseDayMeanPayload = { action: "rebuild" };
|
||||
const jobId = await boss.send(
|
||||
PULSE_DAY_MEAN_QUEUE,
|
||||
payload,
|
||||
PULSE_DAY_MEAN_SEND_OPTIONS,
|
||||
const enqueued = await sendRebuild(
|
||||
boss,
|
||||
(await readPulseDayMeanStatus()).lastRebuild,
|
||||
);
|
||||
const enqueued = jobId !== null;
|
||||
|
||||
await auditLog("admin.pulseDayMean.apply", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -26,10 +26,14 @@ import {
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditCInstrument } from "@/lib/mental-health/audit-c";
|
||||
import { createAuditC } from "@/lib/profile/life-circumstances";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditCRequestSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
/** How far ahead of the server clock a `takenAt` may lie. */
|
||||
const AUDIT_C_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC));
|
||||
|
||||
async function postAuditC(request: NextRequest): Promise<Response> {
|
||||
@@ -54,11 +58,36 @@ async function postAuditC(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
// The cut-off follows the language of the wording that was answered, and
|
||||
// the only wording this account is ever shown is the one the read route
|
||||
// delivers for its language. The stored language is that one, decided
|
||||
// here; a client naming another wording answered something this server
|
||||
// did not offer (or the account language changed under an open form), and
|
||||
// is refused rather than scored against the wrong table.
|
||||
const offered = auditCInstrument(user.locale ?? "en").locale;
|
||||
if (
|
||||
input.locale !== undefined &&
|
||||
auditCInstrument(input.locale).locale !== offered
|
||||
) {
|
||||
return apiError("The wording answered is not the one offered", 422, {
|
||||
errorCode: "anamnesis.auditC.invalid",
|
||||
});
|
||||
}
|
||||
// An entry dated in the future would hold the "newest" place for as long
|
||||
// as the clock takes to reach it. A few minutes of device clock skew pass.
|
||||
const takenAt = input.takenAt ? new Date(input.takenAt) : undefined;
|
||||
if (takenAt && takenAt.getTime() > Date.now() + AUDIT_C_CLOCK_SKEW_MS) {
|
||||
return apiError("takenAt lies in the future", 422, {
|
||||
errorCode: "anamnesis.auditC.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { result, replayed } = await createAuditC(user.id, {
|
||||
items: input.items,
|
||||
takenAt: input.takenAt ? new Date(input.takenAt) : undefined,
|
||||
takenAt,
|
||||
tz: input.tz,
|
||||
locale: input.locale ?? user.locale ?? "en",
|
||||
locale: offered,
|
||||
source: input.source,
|
||||
externalId: input.externalId,
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "@/lib/optimistic-lock";
|
||||
import { resolveModuleMap, normalisePrefs } from "@/lib/modules/gate";
|
||||
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
|
||||
import { recomputeSupplementsIfNutrientsTurnedOn } from "@/lib/medications/ingredients/recompute-job";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -140,6 +141,12 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
await recomputeSupplementsIfNutrientsTurnedOn(
|
||||
user.id,
|
||||
existing?.modulePreferencesJson,
|
||||
merged,
|
||||
);
|
||||
|
||||
// A module toggle can add or remove a Health Score pillar, so the
|
||||
// cached composite was computed from a composition that no longer
|
||||
// holds. Without this the score stays stale for up to an hour after a
|
||||
|
||||
@@ -31,6 +31,12 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
enqueueUserMedicationComplianceBackfill: refoldMock,
|
||||
}));
|
||||
|
||||
// The supplement nutrient rows are keyed by the same local day.
|
||||
const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true })));
|
||||
vi.mock("@/lib/medications/ingredients/recompute-job", () => ({
|
||||
enqueueSupplementNutrientRecompute: nutrientMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
@@ -118,6 +124,7 @@ describe("PUT /api/auth/me/timezone", () => {
|
||||
const res = await PUT(mkReq({ timezone: "America/New_York" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(refoldMock).toHaveBeenCalledWith("user-1");
|
||||
expect(nutrientMock).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("does not re-fold when the zone is unchanged", async () => {
|
||||
@@ -130,6 +137,7 @@ describe("PUT /api/auth/me/timezone", () => {
|
||||
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(refoldMock).not.toHaveBeenCalled();
|
||||
expect(nutrientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims whitespace before validating", async () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
|
||||
import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job";
|
||||
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
@@ -69,6 +70,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// job; best-effort, the singleton key collapses rapid re-picks.
|
||||
if ((before?.timezone ?? null) !== tz) {
|
||||
const refold = await enqueueUserMedicationComplianceBackfill(user.id);
|
||||
// The supplement nutrient rows are keyed by the same local day.
|
||||
await enqueueSupplementNutrientRecompute(user.id);
|
||||
annotate({
|
||||
meta: {
|
||||
timezone_compliance_refold_enqueued: refold.enqueued,
|
||||
|
||||
@@ -40,9 +40,25 @@ vi.mock("@/lib/auth/oidc", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/managed-profiles/claim-cookie", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@/lib/managed-profiles/claim-cookie")
|
||||
>()),
|
||||
readClaimCookie: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/managed-profiles/handover", () => ({
|
||||
isHandoverUsable: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { readClaimCookie } from "@/lib/managed-profiles/claim-cookie";
|
||||
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { getOidcConfig, discoverOidcMetadata } from "@/lib/auth/oidc";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
discoverOidcMetadata,
|
||||
getOidcConfig,
|
||||
} from "@/lib/auth/oidc";
|
||||
|
||||
function makeRequest(path = "/api/auth/oidc/login"): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`);
|
||||
@@ -248,6 +264,34 @@ describe("GET /api/auth/oidc/login?intent=claim (v1.43, #959)", () => {
|
||||
expect(res.cookies.get("oidc_auth_state")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("asks the provider for a fresh sign-in on a claim, and only there", async () => {
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue({
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
});
|
||||
vi.mocked(buildAuthorizationUrl).mockReturnValue(
|
||||
"https://idp.example.com/authorize?x=1",
|
||||
);
|
||||
vi.mocked(readClaimCookie).mockReturnValue({
|
||||
handoverId: "handover-1",
|
||||
binding: "binding-1",
|
||||
} as never);
|
||||
vi.mocked(isHandoverUsable).mockResolvedValue(true);
|
||||
|
||||
await GET(makeRequest("/api/auth/oidc/login?intent=claim"));
|
||||
expect(vi.mocked(buildAuthorizationUrl).mock.calls[0]?.[0]).toMatchObject({
|
||||
forceLogin: true,
|
||||
});
|
||||
|
||||
vi.mocked(buildAuthorizationUrl).mockClear();
|
||||
await GET(makeRequest("/api/auth/oidc/login"));
|
||||
expect(vi.mocked(buildAuthorizationUrl).mock.calls[0]?.[0]).toMatchObject({
|
||||
forceLogin: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes the claim's other failures to the claim page too", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
|
||||
@@ -156,6 +156,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
nonce,
|
||||
codeChallenge,
|
||||
redirectUri,
|
||||
// A claim only; an ordinary sign-in keeps single sign-on.
|
||||
forceLogin: claimBinding !== null,
|
||||
});
|
||||
|
||||
const response = NextResponse.redirect(authorizationUrl);
|
||||
|
||||
@@ -50,6 +50,7 @@ const { profile, userUpdate, transaction } = vi.hoisted(() => {
|
||||
};
|
||||
const userUpdate = vi.fn(async () => ({}));
|
||||
const tx = {
|
||||
$queryRaw: vi.fn(async () => []),
|
||||
userHealthProfile: profile,
|
||||
user: { update: userUpdate },
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ const {
|
||||
const profileUpsert = vi.fn(async () => ({ updatedAt: new Date(0) }));
|
||||
const userUpdate = vi.fn(async () => ({}));
|
||||
const tx = {
|
||||
$queryRaw: vi.fn(async () => []),
|
||||
userHealthProfile: {
|
||||
findUnique: profileFindUnique,
|
||||
upsert: profileUpsert,
|
||||
@@ -246,6 +247,7 @@ describe("about-me module gate", () => {
|
||||
transaction.mockImplementationOnce(
|
||||
async (callback: (client: unknown) => unknown) => {
|
||||
const result = await callback({
|
||||
$queryRaw: vi.fn(async () => []),
|
||||
userHealthProfile: {
|
||||
findUnique: profileFindUnique,
|
||||
upsert: txProfileUpsert,
|
||||
|
||||
@@ -42,6 +42,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { lockAiSections } from "@/lib/profile/life-circumstances";
|
||||
import { takeBaseToken, invalidBaseTokenError } from "@/lib/optimistic-lock";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
@@ -246,6 +247,9 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
// A PUT here replaces the eight sections it lists and leaves the
|
||||
// life-circumstance switches (v1.43) exactly as stored.
|
||||
if (parsed.data.aiIncludedSections !== undefined) {
|
||||
// Taken before the read, so a switch changed concurrently is read
|
||||
// after it commits and carried over as it now stands.
|
||||
await lockAiSections(tx, user.id);
|
||||
const stored = await tx.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { aiIncludedSections: true },
|
||||
|
||||
@@ -37,7 +37,7 @@ vi.mock("@/lib/db", () => ({
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
healthProfileFactRevision: { findMany: vi.fn() },
|
||||
// v1.43 — the anamnesis leaf also carries the newest AUDIT-C entry.
|
||||
mentalHealthAssessment: { findFirst: vi.fn(async () => null) },
|
||||
mentalHealthAssessment: { findMany: vi.fn(async () => []) },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
// The whole-record gate reads the session's own stamps: a sign-in a
|
||||
// moment ago is the proof, on an account without a second factor.
|
||||
|
||||
@@ -81,8 +81,10 @@ import {
|
||||
import {
|
||||
classifyHourBucket,
|
||||
HOUR_BUCKET_SKIP_REASONS,
|
||||
combineHourWrites,
|
||||
parseHourBucketId,
|
||||
writeHourBuckets,
|
||||
type HourBucketEntry,
|
||||
type HourBucketWrite,
|
||||
} from "@/lib/measurements/intraday-hour-buckets";
|
||||
import { resolveStoredTimezone } from "@/lib/tz/resolver";
|
||||
@@ -394,7 +396,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const unstableShapes: UnstableExternalIdShape[] = [];
|
||||
// v1.43 — hourly sums for the intraday profile, and the zone they are cut in
|
||||
// (the account's, the one `/api/auth/me` publishes as `timezone`).
|
||||
const hourWrites: HourBucketWrite[] = [];
|
||||
const hourWrites: HourBucketEntry[] = [];
|
||||
let accountTz: string | undefined;
|
||||
const batchNow = new Date();
|
||||
|
||||
@@ -484,6 +486,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
dateKey: hourId.dateKey,
|
||||
hour: hourId.hour,
|
||||
value: hourMapped.value,
|
||||
startMs: startDate.getTime(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -984,28 +987,24 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.43 — the hours, after the measurements. The last entry for one slot
|
||||
// wins, as for the day rows; an earlier one in the same batch is a
|
||||
// superseded duplicate.
|
||||
// v1.43 — the hours, after the measurements. One write per slot: the last
|
||||
// entry for one start wins (an earlier one in the same batch is a
|
||||
// superseded duplicate), and the two starts of the hour a fall-back day
|
||||
// repeats are summed (`combineHourWrites`).
|
||||
let hourInsertedCount = 0;
|
||||
let hourUpdatedCount = 0;
|
||||
const hourLanded: HourBucketWrite[] = [];
|
||||
if (hourWrites.length > 0 && accountTz) {
|
||||
const lastBySlot = new Map<string, HourBucketWrite>();
|
||||
for (const w of hourWrites) {
|
||||
lastBySlot.set(`${w.type}::${w.dateKey}::${w.hour}`, w);
|
||||
const { writes, superseded } = combineHourWrites(hourWrites);
|
||||
for (const index of superseded) {
|
||||
results[index] = {
|
||||
index,
|
||||
status: "duplicate",
|
||||
reason: "superseded_in_batch",
|
||||
};
|
||||
duplicateCount++;
|
||||
}
|
||||
for (const w of hourWrites) {
|
||||
if (lastBySlot.get(`${w.type}::${w.dateKey}::${w.hour}`) !== w) {
|
||||
results[w.index] = {
|
||||
index: w.index,
|
||||
status: "duplicate",
|
||||
reason: "superseded_in_batch",
|
||||
};
|
||||
duplicateCount++;
|
||||
}
|
||||
}
|
||||
const toWrite = [...lastBySlot.values()];
|
||||
const toWrite = writes.map(({ write }) => write);
|
||||
try {
|
||||
const statuses = await writeHourBuckets(
|
||||
prisma,
|
||||
@@ -1013,21 +1012,25 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
accountTz,
|
||||
toWrite,
|
||||
);
|
||||
for (const w of toWrite) {
|
||||
const status = statuses.get(w.index) ?? "inserted";
|
||||
results[w.index] = { index: w.index, status };
|
||||
if (status === "inserted") hourInsertedCount++;
|
||||
else hourUpdatedCount++;
|
||||
hourLanded.push(w);
|
||||
for (const { write, contributors } of writes) {
|
||||
const status = statuses.get(write.index) ?? "inserted";
|
||||
for (const index of contributors) {
|
||||
results[index] = { index, status };
|
||||
if (status === "inserted") hourInsertedCount++;
|
||||
else hourUpdatedCount++;
|
||||
}
|
||||
hourLanded.push(write);
|
||||
}
|
||||
} catch (err) {
|
||||
for (const w of toWrite) {
|
||||
results[w.index] = {
|
||||
index: w.index,
|
||||
status: "failed",
|
||||
reason: "write_failed",
|
||||
};
|
||||
failedCount++;
|
||||
for (const { contributors } of writes) {
|
||||
for (const index of contributors) {
|
||||
results[index] = {
|
||||
index,
|
||||
status: "failed",
|
||||
reason: "write_failed",
|
||||
};
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
annotate({
|
||||
action: { name: "measurement.batch.hour-bucket.failed" },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job";
|
||||
import {
|
||||
apiHandler,
|
||||
requireGuardianAuth,
|
||||
@@ -239,6 +240,8 @@ export const PATCH = apiHandler(
|
||||
}
|
||||
if (patch.timezone !== undefined) {
|
||||
invalidateUserMedications(access.recordId, { evict: true });
|
||||
// The supplement nutrient rows are keyed by the local day.
|
||||
await enqueueSupplementNutrientRecompute(access.recordId);
|
||||
}
|
||||
settings = {
|
||||
...record,
|
||||
|
||||
@@ -37,7 +37,12 @@ const RESULT = {
|
||||
const status = (
|
||||
lastDryRun: PulseDayMeanStatus["lastDryRun"],
|
||||
method: PulseDayMeanStatus["method"] = "hour_mean_v1",
|
||||
): PulseDayMeanStatus => ({ method, appliedAt: null, lastDryRun });
|
||||
): PulseDayMeanStatus => ({
|
||||
method,
|
||||
appliedAt: null,
|
||||
lastDryRun,
|
||||
lastRebuild: null,
|
||||
});
|
||||
|
||||
const done = (finishedAt: string) => ({
|
||||
status: "done" as const,
|
||||
@@ -113,4 +118,33 @@ describe("PulseDayMeanCard", () => {
|
||||
expect(de).toContain("Dünne Stunden gewichtet");
|
||||
expect(de).toContain("Probelauf");
|
||||
});
|
||||
|
||||
it("shows a stopped recalculation and offers it again without a dry run", () => {
|
||||
const failed: PulseDayMeanStatus = {
|
||||
...status(done("2026-10-01T10:00:00Z"), "weighted_hour_mean_v2"),
|
||||
appliedAt: "2026-10-01T11:00:00Z",
|
||||
lastRebuild: {
|
||||
status: "failed",
|
||||
startedAt: "2026-10-01T11:00:00Z",
|
||||
finishedAt: "2026-10-01T11:05:00Z",
|
||||
accounts: null,
|
||||
rows: null,
|
||||
},
|
||||
};
|
||||
const html = render(failed);
|
||||
expect(html).toContain('data-state="failed"');
|
||||
expect(html).toContain('data-testid="admin-pulse-day-mean-rebuild"');
|
||||
|
||||
const finished = render({
|
||||
...failed,
|
||||
lastRebuild: {
|
||||
...failed.lastRebuild!,
|
||||
status: "done",
|
||||
accounts: 3,
|
||||
rows: 900,
|
||||
},
|
||||
});
|
||||
expect(finished).not.toContain("admin-pulse-day-mean-rebuild");
|
||||
expect(finished).toContain('data-slot="pulse-day-mean-rebuild"');
|
||||
});
|
||||
});
|
||||
@@ -57,8 +57,13 @@ export function PulseDayMeanCard() {
|
||||
queryFn: () =>
|
||||
apiGet<PulseDayMeanStatus>(ENDPOINT, { credentials: "include" }),
|
||||
refetchInterval: (query) => {
|
||||
const state = query.state.data?.lastDryRun?.status;
|
||||
return state === "queued" || state === "running" ? POLL_MS : false;
|
||||
const states = [
|
||||
query.state.data?.lastDryRun?.status,
|
||||
query.state.data?.lastRebuild?.status,
|
||||
];
|
||||
return states.some((state) => state === "queued" || state === "running")
|
||||
? POLL_MS
|
||||
: false;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -102,7 +107,11 @@ export function PulseDayMeanCard() {
|
||||
: t("admin.section.pulseDayMean.appliedNoRebuild"),
|
||||
);
|
||||
} else {
|
||||
toast.success(t("admin.section.pulseDayMean.dryRunStarted"));
|
||||
toast.success(
|
||||
action === "rebuild"
|
||||
? t("admin.section.pulseDayMean.rebuildStarted")
|
||||
: t("admin.section.pulseDayMean.dryRunStarted"),
|
||||
);
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminPulseDayMean(),
|
||||
@@ -125,9 +134,16 @@ export function PulseDayMeanCard() {
|
||||
const s = status.data;
|
||||
const dryRun = s?.lastDryRun ?? null;
|
||||
const result = dryRun?.status === "done" ? dryRun.result : null;
|
||||
const active = dryRun?.status === "queued" || dryRun?.status === "running";
|
||||
const rebuild = s?.lastRebuild ?? null;
|
||||
const rebuilding =
|
||||
rebuild?.status === "queued" || rebuild?.status === "running";
|
||||
const active =
|
||||
dryRun?.status === "queued" || dryRun?.status === "running" || rebuilding;
|
||||
const weighted = s?.method === "weighted_hour_mean_v2";
|
||||
const canApply = !!s && !active && hasFreshDryRun(s);
|
||||
// A stopped recalculation is run again from here; the method is already
|
||||
// switched, so it needs no new dry run.
|
||||
const canRebuild = !!s?.appliedAt && rebuild?.status === "failed";
|
||||
|
||||
return (
|
||||
<SettingsCard data-testid="admin-pulse-day-mean">
|
||||
@@ -252,6 +268,25 @@ export function PulseDayMeanCard() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{rebuild ? (
|
||||
<p
|
||||
className={
|
||||
rebuild.status === "failed"
|
||||
? "text-destructive text-sm"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
data-slot="pulse-day-mean-rebuild"
|
||||
data-state={rebuild.status}
|
||||
>
|
||||
{rebuild.status === "done"
|
||||
? t("admin.section.pulseDayMean.rebuild.done", {
|
||||
at: fmt.dateTime(rebuild.finishedAt ?? rebuild.startedAt),
|
||||
accounts: fmt.number(rebuild.accounts ?? 0),
|
||||
})
|
||||
: t(`admin.section.pulseDayMean.rebuild.${rebuild.status}`)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -264,6 +299,18 @@ export function PulseDayMeanCard() {
|
||||
<FlaskConical className="size-3.5" aria-hidden />
|
||||
{t("admin.section.pulseDayMean.dryRun")}
|
||||
</Button>
|
||||
{canRebuild ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={run.isPending}
|
||||
onClick={() => run.mutate("rebuild")}
|
||||
data-testid="admin-pulse-day-mean-rebuild"
|
||||
>
|
||||
{t("admin.section.pulseDayMean.rebuildAgain")}
|
||||
</Button>
|
||||
) : null}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
facts: null as unknown,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: state.facts }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/outcome/outcome-toast", () => ({
|
||||
toastWrittenOutcome: vi.fn(),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { auditCInstrument } from "@/lib/mental-health/audit-c";
|
||||
import { AuditCPanel } from "../audit-c-panel";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AuditCPanel
|
||||
data={{
|
||||
latest: null,
|
||||
history: [],
|
||||
offerDue: true,
|
||||
instrument: auditCInstrument("en"),
|
||||
}}
|
||||
onChanged={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.facts = null;
|
||||
});
|
||||
|
||||
describe("the old alcohol pattern in the alcohol section", () => {
|
||||
it("lists the stored pattern with a way to remove it", () => {
|
||||
state.facts = {
|
||||
current: {
|
||||
SMOKING_STATUS: null,
|
||||
SHIFT_SCHEDULE: null,
|
||||
ALCOHOL_PATTERN: { id: "fact-1", value: "WEEKLY" },
|
||||
},
|
||||
history: [],
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="audit-c-legacy-pattern"');
|
||||
expect(html).toContain("Earlier answer: Weekly");
|
||||
expect(html).toContain("audit-c-legacy-remove");
|
||||
});
|
||||
|
||||
it("shows nothing when there is no stored pattern", () => {
|
||||
state.facts = {
|
||||
current: {
|
||||
SMOKING_STATUS: null,
|
||||
SHIFT_SCHEDULE: null,
|
||||
ALCOHOL_PATTERN: null,
|
||||
},
|
||||
history: [],
|
||||
};
|
||||
expect(render()).not.toContain("audit-c-legacy-pattern");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
* and its source are the server's too; nothing is scored here.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
@@ -18,9 +18,10 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { HealthProfileFactsPayload } from "@/lib/profile/health-facts";
|
||||
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -76,6 +77,34 @@ export function AuditCPanel({
|
||||
|
||||
const complete = answers.every((answer) => answer !== null);
|
||||
|
||||
// The one-word alcohol pattern asked before AUDIT-C. It still lives in the
|
||||
// record, so it is shown here, where alcohol now lives, with a way out.
|
||||
// The Coach stops reading it once an AUDIT-C entry exists.
|
||||
const queryClient = useQueryClient();
|
||||
const facts = useQuery({
|
||||
queryKey: queryKeys.healthProfileFacts(),
|
||||
queryFn: () => apiGet<HealthProfileFactsPayload>("/api/anamnesis/facts"),
|
||||
});
|
||||
const legacy = facts.data?.current.ALCOHOL_PATTERN ?? null;
|
||||
const removeLegacy = useMutation({
|
||||
mutationKey: queryKeys.healthProfileFacts(),
|
||||
mutationFn: (id: string) => apiDelete(`/api/anamnesis/facts/${id}`),
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
t("records.life.auditC.legacyRemovedToast"),
|
||||
);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.healthProfileFacts(),
|
||||
});
|
||||
onChanged();
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorToast(t("records.life.auditC.legacyRemoveError"), {
|
||||
cause: error,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="audit-c-panel">
|
||||
<p className="text-sm">{t("records.life.auditC.intro")}</p>
|
||||
@@ -154,6 +183,41 @@ export function AuditCPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{legacy?.value ? (
|
||||
<div
|
||||
className="flex items-start justify-between gap-2"
|
||||
data-slot="audit-c-legacy-pattern"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">
|
||||
{t("records.life.auditC.legacyLabel", {
|
||||
value: t(
|
||||
`records.profileFacts.values.ALCOHOL_PATTERN.${legacy.value}`,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("records.life.auditC.legacyHint")}
|
||||
</p>
|
||||
</div>
|
||||
<ConfirmButton
|
||||
label={t("records.life.auditC.legacyRemove")}
|
||||
ariaLabel={t("records.life.auditC.legacyRemove")}
|
||||
title={t("records.life.auditC.legacyRemoveConfirmTitle")}
|
||||
body={t("records.life.auditC.legacyRemoveConfirmBody")}
|
||||
confirmLabel={t("records.life.auditC.legacyRemove")}
|
||||
onConfirm={() => removeLegacy.mutate(legacy.id)}
|
||||
pending={removeLegacy.isPending}
|
||||
disabled={removeLegacy.isPending}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
slot="audit-c-legacy-remove"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.offerDue ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("records.life.auditC.offerDue")}
|
||||
|
||||
@@ -61,6 +61,9 @@ describe("resolveAiUsageLevel — every choice x default x ceiling", () => {
|
||||
describe("what each level does", () => {
|
||||
it("regenerates status cards after a sync: a day, the operator's gap, the hour", () => {
|
||||
expect(statusRegenGapHours("frugal", "sync", 6)).toBe(24);
|
||||
// Never more often than standard: an operator gap above a day wins.
|
||||
expect(statusRegenGapHours("frugal", "sync", 48)).toBe(48);
|
||||
expect(statusRegenGapHours("standard", "sync", 48)).toBe(48);
|
||||
expect(statusRegenGapHours("standard", "sync", 6)).toBe(6);
|
||||
expect(statusRegenGapHours("standard", "sync", 12)).toBe(12);
|
||||
expect(statusRegenGapHours("full", "sync", 12)).toBe(1);
|
||||
|
||||
@@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
allergyFindMany: vi.fn(),
|
||||
familyFindMany: vi.fn(),
|
||||
factFindMany: vi.fn(),
|
||||
auditFindFirst: vi.fn(),
|
||||
auditFindMany: vi.fn(),
|
||||
decrypt: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({
|
||||
allergy: { findMany: mocks.allergyFindMany },
|
||||
familyHistoryEntry: { findMany: mocks.familyFindMany },
|
||||
healthProfileFactRevision: { findMany: mocks.factFindMany },
|
||||
mentalHealthAssessment: { findFirst: mocks.auditFindFirst },
|
||||
mentalHealthAssessment: { findMany: mocks.auditFindMany },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -46,13 +46,15 @@ beforeEach(() => {
|
||||
mocks.allergyFindMany.mockResolvedValue([]);
|
||||
mocks.familyFindMany.mockResolvedValue([]);
|
||||
mocks.factFindMany.mockResolvedValue([]);
|
||||
mocks.auditFindFirst.mockResolvedValue(null);
|
||||
mocks.auditFindMany.mockResolvedValue([]);
|
||||
mocks.decrypt.mockImplementation((bytes: Uint8Array) => {
|
||||
if (bytes[0] === 1) return secret;
|
||||
if (bytes[0] === 2) return "Asthma";
|
||||
if (bytes[0] === 3) return "FORMER";
|
||||
if (bytes[0] === 4) return "OCCASIONAL";
|
||||
if (bytes[0] === 5) return "ROTATING";
|
||||
if (bytes[0] === 6) return JSON.stringify({ schema: 1, items: [2, 2, 1] });
|
||||
if (bytes[0] === 7) throw new Error("unknown key id");
|
||||
return "unknown";
|
||||
});
|
||||
});
|
||||
@@ -204,6 +206,11 @@ describe("profile section access in AI prompt paths", () => {
|
||||
if (bytes[0] === 21) {
|
||||
return JSON.stringify({ schema: 1, goals: ["sleep"] });
|
||||
}
|
||||
if (bytes[0] === 4) return "OCCASIONAL";
|
||||
if (bytes[0] === 6) {
|
||||
return JSON.stringify({ schema: 1, items: [2, 2, 1] });
|
||||
}
|
||||
if (bytes[0] === 7) throw new Error("unknown key id");
|
||||
return "unknown";
|
||||
});
|
||||
});
|
||||
@@ -235,11 +242,16 @@ describe("profile section access in AI prompt paths", () => {
|
||||
});
|
||||
|
||||
it("gives AUDIT-C as a sum with its screening verdict under the alcohol switch only", async () => {
|
||||
mocks.auditFindFirst.mockResolvedValue({
|
||||
totalScore: 5,
|
||||
takenAt: new Date("2026-10-01T09:00:00.000Z"),
|
||||
locale: "de",
|
||||
});
|
||||
mocks.auditFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "audit-1",
|
||||
totalScore: 5,
|
||||
takenAt: new Date("2026-10-01T09:00:00.000Z"),
|
||||
locale: "de",
|
||||
tz: null,
|
||||
responsesEncrypted: new Uint8Array([6]),
|
||||
},
|
||||
]);
|
||||
mocks.userFindUnique.mockResolvedValue({ gender: "MALE", locale: "de" });
|
||||
|
||||
mockIncluded(["ALCOHOL_PATTERN"]);
|
||||
@@ -248,10 +260,59 @@ describe("profile section access in AI prompt paths", () => {
|
||||
"AUDIT-C (Screening, keine Diagnose): 5 von 12 am 2026-10-01, Screening-Grenzwert 5 erreicht",
|
||||
);
|
||||
|
||||
mocks.auditFindFirst.mockClear();
|
||||
mocks.auditFindMany.mockClear();
|
||||
mockIncluded(["GOALS"]);
|
||||
await getSelfContextTextForUser("user-1", "de");
|
||||
expect(mocks.auditFindFirst).not.toHaveBeenCalled();
|
||||
expect(mocks.auditFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops the old alcohol pattern once an AUDIT-C entry exists", async () => {
|
||||
mockIncluded(["ALCOHOL_PATTERN"]);
|
||||
mocks.factFindMany.mockResolvedValue([
|
||||
{ kind: "ALCOHOL_PATTERN", valueEncrypted: new Uint8Array([4]) },
|
||||
]);
|
||||
const alone = await getSelfContextTextForUser("user-1", "en");
|
||||
expect(alone).toContain("Alcohol pattern: Occasional");
|
||||
|
||||
mocks.auditFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "audit-1",
|
||||
totalScore: 5,
|
||||
takenAt: new Date("2026-10-01T09:00:00.000Z"),
|
||||
locale: "en",
|
||||
tz: null,
|
||||
responsesEncrypted: new Uint8Array([6]),
|
||||
},
|
||||
]);
|
||||
const text = await getSelfContextTextForUser("user-1", "en");
|
||||
expect(text).toContain("AUDIT-C");
|
||||
expect(text).not.toContain("Alcohol pattern");
|
||||
});
|
||||
|
||||
it("reads the newest entry whose answers open, as the read route does", async () => {
|
||||
mocks.userFindUnique.mockResolvedValue({ gender: null, locale: "en" });
|
||||
mocks.auditFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "audit-sealed",
|
||||
totalScore: 11,
|
||||
takenAt: new Date("2026-10-05T09:00:00.000Z"),
|
||||
locale: "en",
|
||||
tz: null,
|
||||
responsesEncrypted: new Uint8Array([7]),
|
||||
},
|
||||
{
|
||||
id: "audit-open",
|
||||
totalScore: 5,
|
||||
takenAt: new Date("2026-10-01T09:00:00.000Z"),
|
||||
locale: "en",
|
||||
tz: null,
|
||||
responsesEncrypted: new Uint8Array([6]),
|
||||
},
|
||||
]);
|
||||
mockIncluded(["ALCOHOL_PATTERN"]);
|
||||
const text = await getSelfContextTextForUser("user-1", "en");
|
||||
expect(text).toContain("5 of 12");
|
||||
expect(text).not.toContain("11 of 12");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "@/lib/profile/health-facts";
|
||||
import { LIFE_KIND_AI_SECTION } from "@/lib/profile/life-derived";
|
||||
import { auditCCutoff } from "@/lib/mental-health/audit-c";
|
||||
import { loadNewestReadableAuditC } from "@/lib/profile/audit-c-entries";
|
||||
import {
|
||||
LIFE_FACT_KINDS,
|
||||
isLifeFactKind,
|
||||
@@ -636,20 +637,16 @@ export function composeLifeCircumstancesText(
|
||||
|
||||
/**
|
||||
* The newest AUDIT-C entry for a prompt: only when the alcohol section is
|
||||
* released, and without decrypting anything (the sum is stored in the clear,
|
||||
* like the screeners'). The profile sex for the cut-off is read only when
|
||||
* there is an entry to judge.
|
||||
* released. "Newest" is the newest entry whose answers open, the same entry
|
||||
* the read route shows as the result (`audit-c-entries.ts`). The profile sex
|
||||
* for the cut-off is read only when there is an entry to judge.
|
||||
*/
|
||||
async function loadAuditCForPrompt(
|
||||
userId: string,
|
||||
included: ReadonlySet<string>,
|
||||
): Promise<AuditCForPrompt | null> {
|
||||
if (!included.has("ALCOHOL_PATTERN")) return null;
|
||||
const audit = await prisma.mentalHealthAssessment.findFirst({
|
||||
where: { userId, instrument: "AUDIT_C", deletedAt: null },
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
select: { totalScore: true, takenAt: true, locale: true, tz: true },
|
||||
});
|
||||
const audit = await loadNewestReadableAuditC(userId);
|
||||
if (!audit) return null;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -839,6 +836,10 @@ export async function getSelfContextTextForUser(
|
||||
const factsText = composeStructuredHealthFactsText(
|
||||
factRows
|
||||
.filter((row) => !isLifeFactKind(row.kind))
|
||||
// The old one-word alcohol pattern gives way to an AUDIT-C entry:
|
||||
// the questionnaire is the measured answer to the same question, and
|
||||
// the two side by side would let a years-old guess argue with it.
|
||||
.filter((row) => !(auditC && row.kind === "ALCOHOL_PATTERN"))
|
||||
.flatMap((row) => {
|
||||
const decrypted = decryptHealthProfileFactValue(
|
||||
row.kind as HealthProfileFactKind,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* values with weekday labels and aggregates the older window into
|
||||
* weekly buckets so the prompt budget stays tight.
|
||||
*/
|
||||
import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances";
|
||||
import { loadFreeWeekdaySpansForPrompt } from "@/lib/profile/life-circumstances";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import type { CoachDataCluster } from "@/lib/validations/coach-prefs";
|
||||
@@ -807,7 +807,7 @@ async function buildCoachSnapshotImpl(
|
||||
sourcePriorityJson: prefsRow?.sourcePriorityJson ?? null,
|
||||
userTz,
|
||||
ageYears: derivedProfile.ageYears,
|
||||
freeWeekdaySpans: await loadFreeWeekdaySpans(userId),
|
||||
freeWeekdaySpans: await loadFreeWeekdaySpansForPrompt(userId),
|
||||
snapshot,
|
||||
metrics,
|
||||
registerBlock,
|
||||
|
||||
@@ -44,9 +44,12 @@ export interface AiUsageLevelEffect {
|
||||
* Minimum hours between two status-card regenerations of one scope after a
|
||||
* sync (a watch, an integration). `"instance"` reads the operator's sync
|
||||
* gap (`AppSettings.aiSyncRegenGapHours`, else `AI_SYNC_REGEN_GAP_HOURS`,
|
||||
* else six).
|
||||
* else six). `{ instanceAtLeast }` reads the same gap but never goes
|
||||
* below the floor, so a level meant to regenerate less often than
|
||||
* `standard` cannot end up regenerating more often on an instance whose
|
||||
* operator set a gap above the floor.
|
||||
*/
|
||||
syncRegenGapHours: number | "instance";
|
||||
syncRegenGapHours: number | "instance" | { instanceAtLeast: number };
|
||||
/** The same gap after a hand entry. */
|
||||
manualRegenGapHours: number;
|
||||
/**
|
||||
@@ -89,7 +92,7 @@ export const AI_USAGE_LEVEL_EFFECTS: Readonly<
|
||||
Record<AiUsageLevel, Readonly<AiUsageLevelEffect>>
|
||||
> = Object.freeze({
|
||||
frugal: {
|
||||
syncRegenGapHours: 24,
|
||||
syncRegenGapHours: { instanceAtLeast: 24 },
|
||||
manualRegenGapHours: 6,
|
||||
statusSnapshotChars: 10_000,
|
||||
backgroundReasoning: false,
|
||||
@@ -235,9 +238,10 @@ export function statusRegenGapHours(
|
||||
): number {
|
||||
const effect = AI_USAGE_LEVEL_EFFECTS[level];
|
||||
if (source === "manual") return effect.manualRegenGapHours;
|
||||
return effect.syncRegenGapHours === "instance"
|
||||
? instanceSyncGapHours
|
||||
: effect.syncRegenGapHours;
|
||||
const gap = effect.syncRegenGapHours;
|
||||
if (gap === "instance") return instanceSyncGapHours;
|
||||
if (typeof gap === "number") return gap;
|
||||
return Math.max(gap.instanceAtLeast, instanceSyncGapHours);
|
||||
}
|
||||
|
||||
// ── Estimate ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -253,6 +253,23 @@ describe("buildAuthorizationUrl", () => {
|
||||
expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(url.searchParams.get("scope")).toBe(config.scopes);
|
||||
expect(url.searchParams.has("prompt")).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for a fresh sign-in only when told to", () => {
|
||||
configureEnv();
|
||||
const url = new URL(
|
||||
buildAuthorizationUrl({
|
||||
metadata: METADATA_DOC,
|
||||
config: getOidcConfig()!,
|
||||
state: "state-1",
|
||||
nonce: "nonce-1",
|
||||
codeChallenge: "challenge-1",
|
||||
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
|
||||
forceLogin: true,
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("prompt")).toBe("login");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -237,6 +237,13 @@ export function buildAuthorizationUrl(params: {
|
||||
nonce: string;
|
||||
codeChallenge: string;
|
||||
redirectUri: string;
|
||||
/**
|
||||
* Ask the provider to authenticate afresh (`prompt=login`, OpenID Connect
|
||||
* Core 3.1.2.1) instead of reusing the browser's provider session. A claim
|
||||
* hands a profile to whoever signs in, so it must be the person at the
|
||||
* keyboard now, not a session someone else left open in this browser.
|
||||
*/
|
||||
forceLogin?: boolean;
|
||||
}): string {
|
||||
const url = new URL(params.metadata.authorization_endpoint);
|
||||
url.search = new URLSearchParams({
|
||||
@@ -248,6 +255,7 @@ export function buildAuthorizationUrl(params: {
|
||||
nonce: params.nonce,
|
||||
code_challenge: params.codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
...(params.forceLogin ? { prompt: "login" } : {}),
|
||||
}).toString();
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const safeFetchMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
|
||||
safeFetch: safeFetchMock,
|
||||
}));
|
||||
|
||||
import { DawarichApiError, probeDawarich } from "../client";
|
||||
|
||||
const CONN = { baseUrl: "https://dawarich.example.com", apiKey: "key" };
|
||||
|
||||
beforeEach(() => {
|
||||
safeFetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe("reading a Dawarich answer", () => {
|
||||
it("parses an honest answer", async () => {
|
||||
safeFetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ user: { id: 1 } }), { status: 200 }),
|
||||
);
|
||||
await expect(probeDawarich(CONN)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("stops reading an answer that runs past the cap", async () => {
|
||||
// Streamed without a length, so only the running count can catch it.
|
||||
const chunk = new Uint8Array(1024 * 1024).fill(32);
|
||||
let sent = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
sent += 1;
|
||||
if (sent > 64) controller.close();
|
||||
else controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
safeFetchMock.mockResolvedValue(new Response(body, { status: 200 }));
|
||||
const failure = await probeDawarich(CONN).catch((e: unknown) => e);
|
||||
expect(failure).toBeInstanceOf(DawarichApiError);
|
||||
expect((failure as Error).message).toMatch(/oversized/);
|
||||
expect(sent).toBeLessThan(64);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,9 @@
|
||||
* reads four endpoints, all with `Authorization: Bearer <api key>`:
|
||||
*
|
||||
* GET /api/v1/timeline?start_at&end_at&distance_unit=km
|
||||
* one entry per local day (the instance's own zone) with a `summary`:
|
||||
* one entry per day in the Dawarich account's own zone (its timezone
|
||||
* setting, the instance's `TIME_ZONE` by default; the request cannot
|
||||
* name one), summing only what lies inside the window, with a `summary`:
|
||||
* `total_distance`, `places_visited`, `mode_distances.{driving,train}`
|
||||
* in km to one decimal. At most 31 days per request.
|
||||
* GET /api/v1/visits?start_at&end_at
|
||||
@@ -26,6 +28,7 @@
|
||||
* `DAWARICH_PRIVATE_ORIGINS`. The policy is re-read on every call, so taking
|
||||
* an origin off the list stops the next request.
|
||||
*/
|
||||
import { readBodyCapped } from "@/lib/http/read-capped";
|
||||
import { SafeFetchError, safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
import {
|
||||
@@ -35,6 +38,14 @@ import {
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
/**
|
||||
* The most any one answer may hold. A month of timeline with its entries and
|
||||
* a year of residency stay far below it; the cap is what keeps an instance
|
||||
* that streams without end from holding the worker's memory until the
|
||||
* timeout.
|
||||
*/
|
||||
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/** Dawarich refuses a timeline window longer than 31 days. */
|
||||
export const TIMELINE_MAX_DAYS = 31;
|
||||
|
||||
@@ -118,8 +129,12 @@ async function dawarichGet(
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new DawarichApiError(`Dawarich responded ${res.status}`, res.status);
|
||||
}
|
||||
const read = await readBodyCapped(res, MAX_BODY_BYTES);
|
||||
if (!read.ok) {
|
||||
throw new DawarichApiError("Dawarich returned an oversized body", null);
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
return JSON.parse(read.text) as unknown;
|
||||
} catch {
|
||||
throw new DawarichApiError("Dawarich returned a non-JSON body", null);
|
||||
}
|
||||
|
||||
+58
-11
@@ -46,8 +46,57 @@ const HOME_COUNTRY_WINDOW_DAYS = 365;
|
||||
/** How far before the range the visits request reaches. */
|
||||
const VISIT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Days per timeline request, inside Dawarich's 31-day limit. */
|
||||
const TIMELINE_CHUNK_DAYS = TIMELINE_MAX_DAYS - 1;
|
||||
/**
|
||||
* Days kept per timeline request. Each request reaches one whole day past
|
||||
* both ends of its chunk (see `timelineChunks`), so it spans the chunk plus
|
||||
* two days, and a fall-back day is 25 hours long: chunk + 2 must stay under
|
||||
* Dawarich's 31-day limit by more than an hour.
|
||||
*/
|
||||
const TIMELINE_CHUNK_DAYS = TIMELINE_MAX_DAYS - 3;
|
||||
|
||||
/**
|
||||
* The timeline requests for `days`, each with the dates it is allowed to
|
||||
* fill.
|
||||
*
|
||||
* Dawarich groups a timeline by the day in ITS user's zone (the timezone
|
||||
* setting of the Dawarich account, `TIME_ZONE` by default), not by the zone
|
||||
* the request names, and it only sums what lies inside the requested window.
|
||||
* A window cut at the account's local midnight therefore answers the edge day
|
||||
* in the instance's zone with a fragment of it whenever the two zones differ.
|
||||
* Two rules keep a fragment out of the stored values:
|
||||
*
|
||||
* - each request reaches a whole day before and after its chunk, so every
|
||||
* date of the chunk is covered end to end in any zone within a day of the
|
||||
* account's;
|
||||
* - only the chunk's own dates are kept from the answer, so the padded edge
|
||||
* days (the fragments) are thrown away and no chunk can overwrite a date
|
||||
* another chunk owns.
|
||||
*
|
||||
* The API offers no zone parameter and no per-track detail to regroup by, so
|
||||
* the stored value for a date is the instance's day of that date. When the
|
||||
* two zones agree, as on a typical self-host, that is exactly the account's
|
||||
* local day.
|
||||
*/
|
||||
function timelineChunks(
|
||||
days: readonly string[],
|
||||
tz: string,
|
||||
): Array<{ startAt: Date; endAt: Date; dates: Set<string> }> {
|
||||
const out: Array<{ startAt: Date; endAt: Date; dates: Set<string> }> = [];
|
||||
for (let i = 0; i < days.length; i += TIMELINE_CHUNK_DAYS) {
|
||||
const chunk = days.slice(i, i + TIMELINE_CHUNK_DAYS);
|
||||
const { dayStart } = localDayWindow(shiftDateKey(chunk[0], -1), tz);
|
||||
const { dayEnd } = localDayWindow(
|
||||
shiftDateKey(chunk[chunk.length - 1], 1),
|
||||
tz,
|
||||
);
|
||||
out.push({
|
||||
startAt: dayStart,
|
||||
endAt: new Date(dayEnd.getTime() - 1000),
|
||||
dates: new Set(chunk),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface DawarichSyncResult {
|
||||
/** Days written this run (new or refreshed). */
|
||||
@@ -137,16 +186,14 @@ export async function syncDawarichRange(
|
||||
let visits: Awaited<ReturnType<typeof fetchVisits>> = [];
|
||||
let countries: DailyCountries | null = null;
|
||||
try {
|
||||
for (let i = 0; i < days.length; i += TIMELINE_CHUNK_DAYS) {
|
||||
const chunk = days.slice(i, i + TIMELINE_CHUNK_DAYS);
|
||||
const { dayStart } = localDayWindow(chunk[0], tz);
|
||||
const { dayEnd } = localDayWindow(chunk[chunk.length - 1], tz);
|
||||
for (const chunk of timelineChunks(days, tz)) {
|
||||
const answered = await fetchTimeline(
|
||||
connection,
|
||||
chunk.startAt,
|
||||
chunk.endAt,
|
||||
);
|
||||
timeline = timeline.concat(
|
||||
await fetchTimeline(
|
||||
connection,
|
||||
dayStart,
|
||||
new Date(dayEnd.getTime() - 1000),
|
||||
),
|
||||
answered.filter((day) => chunk.dates.has(day.date)),
|
||||
);
|
||||
}
|
||||
const rangeStart = localDayWindow(fromDate, tz).dayStart;
|
||||
|
||||
@@ -1094,7 +1094,9 @@ export async function buildFullBackupPayload(
|
||||
externalId: m.externalId,
|
||||
updatedAt: m.updatedAt.toISOString(),
|
||||
}
|
||||
: {}),
|
||||
: // No id: a portable restore mints a fresh one. The relation links
|
||||
// still name this drug, so the file says which row they mean.
|
||||
{ ref: m.id }),
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
|
||||
@@ -83,6 +83,8 @@ function readForExport(
|
||||
export interface LabResultBackupEntry {
|
||||
/** Present in canonical DR payloads so rows keep a stable identity. */
|
||||
id?: string;
|
||||
/** The source row's id in a portable payload, for the relation links. */
|
||||
ref?: string;
|
||||
panel: string | null;
|
||||
analyte: string;
|
||||
value: number | null;
|
||||
@@ -455,6 +457,9 @@ export async function buildRecordsBackupSection(
|
||||
note: null,
|
||||
}
|
||||
: {
|
||||
// No id: a portable restore mints a fresh one. The relation links
|
||||
// still name this row, so the file says which row they mean.
|
||||
ref: r.id,
|
||||
note: readForExport(
|
||||
r.noteEncrypted,
|
||||
"lab note",
|
||||
|
||||
@@ -244,6 +244,19 @@ function placeable<T>(
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* The id each medication and lab result had in the file, to the id the
|
||||
* restore gave it. A portable file carries neither row's id (a restore there
|
||||
* mints fresh ones), only a `ref` naming the source row, so a link that names
|
||||
* the source id is re-pointed through this map. An id the map does not hold is
|
||||
* used as it is: a disaster-recovery file keeps its ids, and a hand-edited one
|
||||
* falls through to the placement check below.
|
||||
*/
|
||||
export interface RelationLinkSourceIds {
|
||||
medications: ReadonlyMap<string, string>;
|
||||
labResults: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create the five link tables.
|
||||
*
|
||||
@@ -261,7 +274,13 @@ export async function restoreRelationLinksData(
|
||||
ownerId: string,
|
||||
payload: RelationLinksBackupSection,
|
||||
skips: RestoreSkipLog,
|
||||
sourceIds: RelationLinkSourceIds = {
|
||||
medications: new Map(),
|
||||
labResults: new Map(),
|
||||
},
|
||||
): Promise<RelationLinksRestoreCleared> {
|
||||
const medication = (id: string) => sourceIds.medications.get(id) ?? id;
|
||||
const labResult = (id: string) => sourceIds.labResults.get(id) ?? id;
|
||||
const owned = { where: { userId: ownerId } };
|
||||
const [c1, c2, c3, c4, c5] = await Promise.all([
|
||||
tx.labResultDocumentLink.deleteMany(owned),
|
||||
@@ -278,7 +297,10 @@ export async function restoreRelationLinksData(
|
||||
});
|
||||
|
||||
const labDocs = placeable(
|
||||
payload.labResultDocumentLinks,
|
||||
payload.labResultDocumentLinks.map((r) => ({
|
||||
...r,
|
||||
labResultId: labResult(r.labResultId),
|
||||
})),
|
||||
[
|
||||
[(r) => r.labResultId, written.labResults],
|
||||
[(r) => r.documentId, written.documents],
|
||||
@@ -297,7 +319,10 @@ export async function restoreRelationLinksData(
|
||||
}
|
||||
|
||||
const encounterMeds = placeable(
|
||||
payload.encounterMedicationLinks,
|
||||
payload.encounterMedicationLinks.map((r) => ({
|
||||
...r,
|
||||
medicationId: medication(r.medicationId),
|
||||
})),
|
||||
[
|
||||
[(r) => r.encounterId, written.encounters],
|
||||
[(r) => r.medicationId, written.medications],
|
||||
@@ -335,7 +360,10 @@ export async function restoreRelationLinksData(
|
||||
}
|
||||
|
||||
const medDocs = placeable(
|
||||
payload.medicationDocumentLinks,
|
||||
payload.medicationDocumentLinks.map((r) => ({
|
||||
...r,
|
||||
medicationId: medication(r.medicationId),
|
||||
})),
|
||||
[
|
||||
[(r) => r.medicationId, written.medications],
|
||||
[(r) => r.documentId, written.documents],
|
||||
@@ -354,7 +382,10 @@ export async function restoreRelationLinksData(
|
||||
}
|
||||
|
||||
const medConditions = placeable(
|
||||
payload.medicationConditionLinks,
|
||||
payload.medicationConditionLinks.map((r) => ({
|
||||
...r,
|
||||
medicationId: medication(r.medicationId),
|
||||
})),
|
||||
[
|
||||
[(r) => r.medicationId, written.medications],
|
||||
[(r) => r.episodeId, written.episodes],
|
||||
|
||||
@@ -1158,6 +1158,12 @@ export async function restoreBackup(
|
||||
|
||||
const medByName = new Map<string, string>();
|
||||
const restoredMedicationIds = new Set<string>();
|
||||
// The id each medication had in the source account, to the id it got
|
||||
// here. A disaster-recovery file keeps the id, so this is the
|
||||
// identity there; a portable file names the source row only through
|
||||
// `ref`, and the relation links (written last) are re-pointed through
|
||||
// this map instead of being dropped as "end is gone".
|
||||
const medicationIdBySourceId = new Map<string, string>();
|
||||
// The efficacy targets cannot ride inside the medication create
|
||||
// beside the schedules and the packs: `biomarkerId` is a foreign key
|
||||
// into a catalogue this transaction has not restored yet. They are
|
||||
@@ -1454,6 +1460,10 @@ export async function restoreBackup(
|
||||
},
|
||||
});
|
||||
restoredMedicationIds.add(created.id);
|
||||
const medicationSourceId = m.id ?? m.ref;
|
||||
if (medicationSourceId) {
|
||||
medicationIdBySourceId.set(medicationSourceId, created.id);
|
||||
}
|
||||
if (!medByName.has(m.name)) medByName.set(m.name, created.id);
|
||||
// v1.39.4 — the clinical category lives in its own table keyed on the
|
||||
// medication id, so it is written after the row exists and inside the
|
||||
@@ -2172,6 +2182,8 @@ export async function restoreBackup(
|
||||
// `committedRecordId` names the lab result it was committed to, and
|
||||
// that reference is resolved against the rows this loop writes.
|
||||
const restoredLabResultIds = new Set<string>();
|
||||
// Same arrangement as `medicationIdBySourceId`, for the lab results.
|
||||
const labResultIdBySourceId = new Map<string, string>();
|
||||
// What each drug was supposed to move, written now that both ends
|
||||
// exist. The drugs came back several hundred lines up and the
|
||||
// analytes only just now, and one of those two had to move for a
|
||||
@@ -2280,6 +2292,9 @@ export async function restoreBackup(
|
||||
},
|
||||
});
|
||||
restoredLabResultIds.add(createdLab.id);
|
||||
const labSourceId = lab.id ?? lab.ref;
|
||||
if (labSourceId)
|
||||
labResultIdBySourceId.set(labSourceId, createdLab.id);
|
||||
}
|
||||
|
||||
reportSection("illness");
|
||||
@@ -2683,6 +2698,10 @@ export async function restoreBackup(
|
||||
ownerId,
|
||||
payload,
|
||||
skips,
|
||||
{
|
||||
medications: medicationIdBySourceId,
|
||||
labResults: labResultIdBySourceId,
|
||||
},
|
||||
);
|
||||
|
||||
// The Coach's threads, turns and document links. AFTER the
|
||||
|
||||
@@ -63,7 +63,8 @@ export type StatusIngestSource = IngestSource;
|
||||
* day. Since v1.43 (#1217) the gap is chosen by source and by the record's AI
|
||||
* usage level (`statusRegenGapHours` in `@/lib/ai/usage-level`): a hand entry
|
||||
* keeps the hour (six on `frugal`), a sync waits the operator's sync gap on
|
||||
* `standard` (default six hours), a day on `frugal`, the hour on `full`.
|
||||
* `standard` (default six hours), a day on `frugal` (or the operator's gap
|
||||
* when it is longer), the hour on `full`.
|
||||
*/
|
||||
async function ingestRegenGapMs(
|
||||
userId: string,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The pulse rollup rebuild is on the record: a stopped one reads as failed
|
||||
* beside the dry run, so the admin card can offer it again.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => {
|
||||
let stored: unknown = {
|
||||
status: "done",
|
||||
startedAt: "2026-10-15T08:00:00.000Z",
|
||||
finishedAt: "2026-10-15T08:10:00.000Z",
|
||||
result: null,
|
||||
};
|
||||
return {
|
||||
get stored() {
|
||||
return stored;
|
||||
},
|
||||
reset(value: unknown) {
|
||||
stored = value;
|
||||
},
|
||||
appSettings: {
|
||||
findUnique: vi.fn(async () => ({ pulseDayMeanDryRunJson: stored })),
|
||||
upsert: vi.fn(
|
||||
async (args: { update: { pulseDayMeanDryRunJson: unknown } }) => {
|
||||
stored = args.update.pulseDayMeanDryRunJson;
|
||||
return {};
|
||||
},
|
||||
),
|
||||
},
|
||||
$queryRaw: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { appSettings: db.appSettings, $queryRaw: db.$queryRaw },
|
||||
}));
|
||||
vi.mock("@/lib/jobs/job-lock", () => ({
|
||||
withJobLock: async (_key: string, run: () => Promise<unknown>) => ({
|
||||
ran: true,
|
||||
result: await run(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/logging/signal", () => ({ logCaught: vi.fn() }));
|
||||
|
||||
import { handlePulseDayMean } from "../pulse-day-mean";
|
||||
|
||||
const job = [{ data: { action: "rebuild" } }] as never;
|
||||
|
||||
beforeEach(() => {
|
||||
db.$queryRaw.mockReset();
|
||||
});
|
||||
|
||||
describe("the pulse rebuild state", () => {
|
||||
it("records a stopped rebuild as failed, beside the dry run", async () => {
|
||||
db.$queryRaw.mockRejectedValue(new Error("statement timeout"));
|
||||
const outcome = await handlePulseDayMean(job);
|
||||
expect(outcome.ok).toBe(false);
|
||||
expect(db.stored).toMatchObject({
|
||||
status: "done",
|
||||
rebuild: { status: "failed", accounts: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("records a finished rebuild with what it did", async () => {
|
||||
// No account holds pulse: done, nothing written.
|
||||
db.$queryRaw.mockResolvedValue([]);
|
||||
const outcome = await handlePulseDayMean(job);
|
||||
expect(outcome.ok).toBe(true);
|
||||
expect(db.stored).toMatchObject({
|
||||
rebuild: { status: "done", accounts: 0, rows: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,10 +41,12 @@ import {
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import {
|
||||
PULSE_DAY_MEAN_CHANGE_BUCKETS,
|
||||
pulseDayMeanDryRunSchema,
|
||||
pulseDayMeanStoredRunsSchema,
|
||||
type PulseDayMeanDryRun,
|
||||
type PulseDayMeanDryRunResult,
|
||||
type PulseDayMeanRebuild,
|
||||
type PulseDayMeanStatus,
|
||||
type PulseDayMeanStoredRuns,
|
||||
} from "@/lib/validations/pulse-day-mean";
|
||||
|
||||
export const PULSE_DAY_MEAN_QUEUE = "pulse-day-mean";
|
||||
@@ -92,15 +94,26 @@ export async function isPulseDayMeanRunActive(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
function parseDryRun(value: unknown): PulseDayMeanDryRun | null {
|
||||
const parsed = pulseDayMeanDryRunSchema.safeParse(value);
|
||||
function parseStoredRuns(value: unknown): PulseDayMeanStoredRuns | null {
|
||||
const parsed = pulseDayMeanStoredRunsSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
/** A run still marked queued or running with no live job behind it failed. */
|
||||
function settle<T extends { status: PulseDayMeanDryRun["status"] }>(
|
||||
run: T | null,
|
||||
active: boolean,
|
||||
): T | null {
|
||||
if (run && (run.status === "queued" || run.status === "running") && !active) {
|
||||
return { ...run, status: "failed" };
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored method, when it was applied, and the last dry run. A dry run
|
||||
* still marked queued or running with no live job behind it (the worker went
|
||||
* away mid-run) reads as failed.
|
||||
* The stored method, when it was applied, the last dry run and the last
|
||||
* rebuild. A run still marked queued or running with no live job behind it
|
||||
* (the worker went away mid-run) reads as failed.
|
||||
*/
|
||||
export async function readPulseDayMeanStatus(): Promise<PulseDayMeanStatus> {
|
||||
const row = await prisma.appSettings.findUnique({
|
||||
@@ -111,32 +124,59 @@ export async function readPulseDayMeanStatus(): Promise<PulseDayMeanStatus> {
|
||||
pulseDayMeanDryRunJson: true,
|
||||
},
|
||||
});
|
||||
let lastDryRun = parseDryRun(row?.pulseDayMeanDryRunJson ?? null);
|
||||
if (
|
||||
lastDryRun &&
|
||||
(lastDryRun.status === "queued" || lastDryRun.status === "running") &&
|
||||
!(await isPulseDayMeanRunActive())
|
||||
) {
|
||||
lastDryRun = { ...lastDryRun, status: "failed" };
|
||||
}
|
||||
const stored = parseStoredRuns(row?.pulseDayMeanDryRunJson ?? null);
|
||||
const pending =
|
||||
stored !== null &&
|
||||
[stored.status, stored.rebuild?.status].some(
|
||||
(status) => status === "queued" || status === "running",
|
||||
);
|
||||
const active = pending ? await isPulseDayMeanRunActive() : false;
|
||||
const { rebuild, ...dryRun } = stored ?? { rebuild: null };
|
||||
return {
|
||||
method: isPulseDayMeanMethod(row?.pulseDayMeanMethod)
|
||||
? row.pulseDayMeanMethod
|
||||
: DEFAULT_PULSE_DAY_MEAN_METHOD,
|
||||
appliedAt: row?.pulseDayMeanAppliedAt?.toISOString() ?? null,
|
||||
lastDryRun,
|
||||
lastDryRun: stored ? settle(dryRun as PulseDayMeanDryRun, active) : null,
|
||||
lastRebuild: settle(rebuild ?? null, active),
|
||||
};
|
||||
}
|
||||
|
||||
/** The only write a dry run makes. */
|
||||
export async function writeDryRun(run: PulseDayMeanDryRun): Promise<void> {
|
||||
async function readStoredRuns(): Promise<PulseDayMeanStoredRuns | null> {
|
||||
const row = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { pulseDayMeanDryRunJson: true },
|
||||
});
|
||||
return parseStoredRuns(row?.pulseDayMeanDryRunJson ?? null);
|
||||
}
|
||||
|
||||
async function writeStoredRuns(runs: PulseDayMeanStoredRuns): Promise<void> {
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", pulseDayMeanDryRunJson: run },
|
||||
update: { pulseDayMeanDryRunJson: run },
|
||||
create: { id: "singleton", pulseDayMeanDryRunJson: runs },
|
||||
update: { pulseDayMeanDryRunJson: runs },
|
||||
});
|
||||
}
|
||||
|
||||
/** The only write a dry run makes. The last rebuild stays as it was. */
|
||||
export async function writeDryRun(run: PulseDayMeanDryRun): Promise<void> {
|
||||
const stored = await readStoredRuns();
|
||||
await writeStoredRuns({ ...run, rebuild: stored?.rebuild ?? null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the rebuild's state beside the dry run it followed. A rebuild only
|
||||
* runs after an apply, and an apply needs a dry run, so there is always one
|
||||
* to sit beside; without it there is nothing to record against.
|
||||
*/
|
||||
export async function writeRebuild(
|
||||
rebuild: PulseDayMeanRebuild | null,
|
||||
): Promise<void> {
|
||||
const stored = await readStoredRuns();
|
||||
if (!stored) return;
|
||||
await writeStoredRuns({ ...stored, rebuild });
|
||||
}
|
||||
|
||||
// ─── Accounts ─────────────────────────────────────────────
|
||||
|
||||
interface PulseAccount {
|
||||
@@ -354,6 +394,38 @@ export async function rebuildPulseRollups(): Promise<{
|
||||
|
||||
// ─── Job ──────────────────────────────────────────────────
|
||||
|
||||
async function runRebuild(): Promise<{ accounts: number; rows: number }> {
|
||||
const startedAt = new Date().toISOString();
|
||||
await writeRebuild({
|
||||
status: "running",
|
||||
startedAt,
|
||||
finishedAt: null,
|
||||
accounts: null,
|
||||
rows: null,
|
||||
});
|
||||
try {
|
||||
const done = await rebuildPulseRollups();
|
||||
await writeRebuild({
|
||||
status: "done",
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
...done,
|
||||
});
|
||||
return done;
|
||||
} catch (err) {
|
||||
await writeRebuild({
|
||||
status: "failed",
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
accounts: null,
|
||||
rows: null,
|
||||
}).catch((writeErr) =>
|
||||
logCaught("pulse_day_mean.rebuild.status_write_failed", writeErr),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function runDryRun(): Promise<PulseDayMeanDryRunResult> {
|
||||
const startedAt = new Date().toISOString();
|
||||
await writeDryRun({
|
||||
@@ -394,7 +466,7 @@ export async function handlePulseDayMean(
|
||||
try {
|
||||
const guarded = await withJobLock(PULSE_DAY_MEAN_QUEUE, async () =>
|
||||
action === "rebuild"
|
||||
? { kind: "rebuild" as const, ...(await rebuildPulseRollups()) }
|
||||
? { kind: "rebuild" as const, ...(await runRebuild()) }
|
||||
: { kind: "dry_run" as const, result: await runDryRun() },
|
||||
);
|
||||
if (!guarded.ran) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const appSettings = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(async () => ({ defaultUserTimezone: "America/New_York" })),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: { appSettings } }));
|
||||
|
||||
import {
|
||||
bucketRowsByDay,
|
||||
iterateDayBuckets,
|
||||
@@ -303,4 +308,29 @@ describe("runConsolidation — streamed walk", () => {
|
||||
expect(result.stoppedEarly).toBe(true);
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cuts days in the zone the server publishes for the account", async () => {
|
||||
// A stored zone the server sets aside reads as the instance default, as
|
||||
// it does for `/api/auth/me` and the hour-bucket writer; never as the
|
||||
// drain's own fallback.
|
||||
const findMany = buildPagingFindMany(hourlyRows(1));
|
||||
const base = params(findMany, async () => ({
|
||||
kind: "written" as const,
|
||||
sourceRowsRemoved: 0,
|
||||
}));
|
||||
const zones: string[] = [];
|
||||
await runConsolidation({
|
||||
...base,
|
||||
prismaClient: {
|
||||
user: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "user-2", timezone: null }]),
|
||||
},
|
||||
measurement: { findMany },
|
||||
} as unknown as PrismaClient,
|
||||
onUserStart: ({ tz }) => zones.push(tz),
|
||||
});
|
||||
expect(zones).toEqual(["America/New_York"]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
classifyHourBucket,
|
||||
combineHourWrites,
|
||||
cumulativeFromIncrements,
|
||||
HOUR_NOT_POSTED,
|
||||
isStartOfLocalHour,
|
||||
@@ -158,3 +159,38 @@ describe("cumulativeFromIncrements", () => {
|
||||
expect(postedHours(increments)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combineHourWrites", () => {
|
||||
const entry = (index: number, startIso: string, value: number) => ({
|
||||
index,
|
||||
type: "ACTIVITY_STEPS" as const,
|
||||
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
||||
dateKey: "2026-10-25",
|
||||
hour: 2,
|
||||
value,
|
||||
startMs: Date.parse(startIso),
|
||||
});
|
||||
|
||||
it("sums the two starts of the hour a fall-back day repeats", () => {
|
||||
// Berlin, 2026-10-25: 02:00 CEST is 00:00Z, 02:00 CET is 01:00Z.
|
||||
const { writes, superseded } = combineHourWrites([
|
||||
entry(0, "2026-10-25T00:00:00Z", 300),
|
||||
entry(1, "2026-10-25T01:00:00Z", 120),
|
||||
]);
|
||||
expect(superseded).toEqual([]);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0].write.value).toBe(420);
|
||||
expect(writes[0].contributors.sort()).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("lets the last entry of one start win, and marks the earlier one", () => {
|
||||
const { writes, superseded } = combineHourWrites([
|
||||
entry(0, "2026-10-25T00:00:00Z", 300),
|
||||
entry(1, "2026-10-25T00:00:00Z", 310),
|
||||
entry(2, "2026-10-25T01:00:00Z", 120),
|
||||
]);
|
||||
expect(superseded).toEqual([0]);
|
||||
expect(writes[0].write.value).toBe(430);
|
||||
expect(writes[0].write.index).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type PerSampleRow,
|
||||
} from "./consolidation-tz";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { resolveStoredTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
// Re-export the shared grace constant so the established
|
||||
// `consolidation-base` import surface stays unchanged for callers.
|
||||
@@ -454,7 +455,12 @@ export async function runConsolidation<TType extends MeasurementType>(
|
||||
const users = await loadConsolidationUsers(prismaClient, options.userId);
|
||||
|
||||
walk: for (const user of users) {
|
||||
const tz = resolveUserTimezone(user.timezone);
|
||||
// The zone the server publishes for the account (`/api/auth/me`), which
|
||||
// is the zone the phone cuts its `stats:` days and hour buckets in and
|
||||
// the one the hour-bucket writer stores them under. A stored value the
|
||||
// server sets aside falls back to the instance default here as it does
|
||||
// there, never to a second fallback of the drain's own.
|
||||
const tz = await resolveStoredTimezone(user.timezone);
|
||||
// Aligned to the account's local day: a day is folded whole or not at
|
||||
// all, never in two runs (see `foldBoundary`).
|
||||
const cutoffAt =
|
||||
|
||||
@@ -177,6 +177,59 @@ export interface HourBucketWrite {
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** One hour as the batch reads it, with the instant it starts at. */
|
||||
export interface HourBucketEntry extends HourBucketWrite {
|
||||
/** `startDate` of the entry, in epoch milliseconds. */
|
||||
startMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A batch's hours reduced to one write per slot.
|
||||
*
|
||||
* A slot is one local hour of one day. On the autumn day the clock falls
|
||||
* back, one local hour happens twice, and the phone hands over both under
|
||||
* the same key (`…T02`), each with its own `startDate`: they are two hours of
|
||||
* activity and are summed into the slot rather than the second replacing the
|
||||
* first. Entries that repeat the same start are the same hour posted twice,
|
||||
* and the last one counts, as for every other slot.
|
||||
*
|
||||
* `contributors` are the entries whose values the write carries (they take
|
||||
* its status); `superseded` are the earlier repeats of one start.
|
||||
*
|
||||
* The slot holds one number, so the sum is only as complete as the batch
|
||||
* that carries it: both occurrences have to arrive together. The phone posts
|
||||
* a day's hours in one batch, and a later batch that carries only one of
|
||||
* them replaces the slot with that one, as a re-post of any slot does.
|
||||
*/
|
||||
export function combineHourWrites(entries: readonly HourBucketEntry[]): {
|
||||
writes: Array<{ write: HourBucketWrite; contributors: number[] }>;
|
||||
superseded: number[];
|
||||
} {
|
||||
const slots = new Map<string, Map<number, HourBucketEntry>>();
|
||||
const superseded: number[] = [];
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.type}::${entry.dateKey}::${entry.hour}`;
|
||||
const byStart = slots.get(key) ?? new Map<number, HourBucketEntry>();
|
||||
const earlier = byStart.get(entry.startMs);
|
||||
if (earlier) superseded.push(earlier.index);
|
||||
byStart.set(entry.startMs, entry);
|
||||
slots.set(key, byStart);
|
||||
}
|
||||
const writes = [...slots.values()].map((byStart) => {
|
||||
const occurrences = [...byStart.values()];
|
||||
const last = occurrences.reduce((a, b) => (b.index > a.index ? b : a));
|
||||
const { startMs: _start, ...base } = last;
|
||||
return {
|
||||
write: {
|
||||
...base,
|
||||
value: occurrences.reduce((sum, entry) => sum + entry.value, 0),
|
||||
},
|
||||
contributors: occurrences.map((entry) => entry.index),
|
||||
};
|
||||
});
|
||||
return { writes, superseded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a batch's hours, one transaction per (type, day). Returns the
|
||||
* per-entry status: `inserted` for the first value of a slot, `updated` when
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const send = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => "job-1"));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => ({ send }),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("../recompute", () => ({
|
||||
recomputeSupplementNutrientsForUser: vi.fn(),
|
||||
}));
|
||||
|
||||
import { recomputeSupplementsIfNutrientsTurnedOn } from "../recompute-job";
|
||||
|
||||
beforeEach(() => send.mockClear());
|
||||
|
||||
describe("the supplement recompute after a module switch", () => {
|
||||
it("queues the full recompute when nutrients comes back on", async () => {
|
||||
await recomputeSupplementsIfNutrientsTurnedOn(
|
||||
"user-1",
|
||||
{ nutrients: false },
|
||||
{ nutrients: true },
|
||||
);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send.mock.calls[0]?.[1]).toMatchObject({ userId: "user-1" });
|
||||
});
|
||||
|
||||
it("queues nothing when the switch did not move to on", async () => {
|
||||
await recomputeSupplementsIfNutrientsTurnedOn(
|
||||
"user-1",
|
||||
{ nutrients: true },
|
||||
{ nutrients: true, mood: false },
|
||||
);
|
||||
await recomputeSupplementsIfNutrientsTurnedOn(
|
||||
"user-1",
|
||||
{ nutrients: true },
|
||||
{ nutrients: false },
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { normalisePrefs } from "@/lib/modules/gate";
|
||||
import { isOptInModule } from "@/lib/modules/registry";
|
||||
|
||||
import { recomputeSupplementNutrientsForUser } from "./recompute";
|
||||
|
||||
@@ -67,6 +69,28 @@ export async function enqueueSupplementNutrientRecompute(
|
||||
return { queued: false };
|
||||
}
|
||||
|
||||
/** The account's own `nutrients` switch in a stored preference blob. */
|
||||
function nutrientsSwitchOn(prefs: unknown): boolean {
|
||||
const value = normalisePrefs(prefs).nutrients;
|
||||
return isOptInModule("nutrients") ? value === true : value !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue the full recompute when a preference write turned the account's
|
||||
* `nutrients` switch back on. While the module is off nothing is written
|
||||
* (`recompute.ts`), so a dose removed, a composition changed or a zone moved
|
||||
* in the meantime left the stored rows as they were; the first pass after
|
||||
* the switch puts them right instead of the next unrelated change.
|
||||
*/
|
||||
export async function recomputeSupplementsIfNutrientsTurnedOn(
|
||||
userId: string,
|
||||
before: unknown,
|
||||
after: unknown,
|
||||
): Promise<void> {
|
||||
if (nutrientsSwitchOn(before) || !nutrientsSwitchOn(after)) return;
|
||||
await enqueueSupplementNutrientRecompute(userId);
|
||||
}
|
||||
|
||||
/** The pg-boss handler body for one job. */
|
||||
export async function runSupplementNutrientRecompute(
|
||||
payload: SupplementNutrientRecomputePayload,
|
||||
|
||||
@@ -18,11 +18,10 @@ import {
|
||||
} from "../client";
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const SAMPLE: NightscoutSgvEntry[] = [
|
||||
@@ -295,4 +294,27 @@ describe("fetchSgvEntries — SSRF gating", () => {
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]!.sgv).toBe(112);
|
||||
});
|
||||
|
||||
it("stops reading an answer that runs past the cap", async () => {
|
||||
// Streamed without a length, so only the running count can catch it.
|
||||
const chunk = new Uint8Array(1024 * 1024).fill(32);
|
||||
let sent = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
sent += 1;
|
||||
if (sent > 64) controller.close();
|
||||
else controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
safeFetchMock.mockResolvedValue(new Response(body, { status: 200 }));
|
||||
await expect(
|
||||
fetchSgvEntries({
|
||||
baseUrl: "https://ns.example.com",
|
||||
token: "",
|
||||
count: 10,
|
||||
allowPrivateHost: false,
|
||||
}),
|
||||
).rejects.toThrow(/oversized/);
|
||||
expect(sent).toBeLessThan(64);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { readBodyCapped } from "@/lib/http/read-capped";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import {
|
||||
configuredNightscoutPrivateOrigins,
|
||||
@@ -33,6 +34,7 @@ import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
/** Default per-request timeout for a Nightscout fetch. */
|
||||
const NIGHTSCOUT_TIMEOUT_MS = 15_000;
|
||||
const NIGHTSCOUT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Canonical glucose storage unit. Nightscout SGV is always mg/dL on the wire. */
|
||||
export const NIGHTSCOUT_GLUCOSE_UNIT = "mg/dL" as const;
|
||||
@@ -259,9 +261,15 @@ export async function fetchSgvEntries(
|
||||
);
|
||||
}
|
||||
|
||||
// Bounded while reading: `count` entries are a few hundred bytes each, so
|
||||
// the cap is far above any honest answer and stops one that never ends.
|
||||
const read = await readBodyCapped(res, NIGHTSCOUT_MAX_BODY_BYTES);
|
||||
if (!read.ok) {
|
||||
throw new NightscoutApiError("Nightscout returned an oversized body", null);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
body = JSON.parse(read.text) as unknown;
|
||||
} catch {
|
||||
throw new NightscoutApiError("Nightscout returned a non-JSON body", null);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
export const aiUsageLevel = z.enum(AI_USAGE_LEVELS).meta({
|
||||
id: "AiUsageLevel",
|
||||
description:
|
||||
"How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at most every 24 h after a sync and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The interactive Coach is the same at every level.",
|
||||
"How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at most every 24 h after a sync (or the operator's sync gap when that is longer, so never more often than `standard`) and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The interactive Coach is the same at every level.",
|
||||
});
|
||||
|
||||
export const aiUsageState = z
|
||||
|
||||
@@ -119,7 +119,11 @@ const lifeCircumstances = z
|
||||
screeningHints: z.array(screeningHint),
|
||||
}),
|
||||
auditC: z.object({
|
||||
latest: auditCResult.nullable(),
|
||||
latest: auditCResult
|
||||
.nullable()
|
||||
.describe(
|
||||
"The newest entry whose answers this host can open; an entry it cannot open is listed in `history` only.",
|
||||
),
|
||||
history: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -129,7 +133,11 @@ const lifeCircumstances = z
|
||||
}),
|
||||
)
|
||||
.describe("Newest first, at most 20."),
|
||||
offerDue: z.boolean(),
|
||||
offerDue: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"No readable entry, or the newest one whose answers open (the one in `latest`) is 365 days old or older.",
|
||||
),
|
||||
instrument: z.object({
|
||||
items: z
|
||||
.array(
|
||||
@@ -268,7 +276,7 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Anamnesis"],
|
||||
summary: "Record an AUDIT-C (v1.43)",
|
||||
description:
|
||||
"Scores the three items on the server. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid`; 429 `anamnesis.auditC.rateLimited` after 30 in an hour.",
|
||||
"Scores the three items on the server. The entry is stored in the language of the wording the read route offers this account (`auditC.instrument.locale`), and the cut-off follows that wording; `locale`, when sent, must name that same wording. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid` (also for another wording than the one offered, and for a `takenAt` more than five minutes ahead of the server clock); 429 `anamnesis.auditC.rateLimited` after 30 in an hour.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* v1.43 (#614) — which AUDIT-C entry is "the newest" for every reader.
|
||||
*
|
||||
* The answers are sealed; the sum is stored in the clear. An entry whose
|
||||
* answers this host can no longer open (a lost or retired key) still has a
|
||||
* readable sum, so a reader that skipped decryption used to take it as the
|
||||
* newest result while the read route, which needs the answers, showed an
|
||||
* older one beside it and offered the questionnaire again on a date neither
|
||||
* of them showed. One rule now holds everywhere a result is read (the read
|
||||
* route's `latest` and `offerDue`, the Coach block, the doctor report and
|
||||
* FHIR): the newest entry whose answers open. The history keeps listing every
|
||||
* entry by its sum, so an unreadable one can still be found and removed.
|
||||
*/
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** How many entries back a reader looks for one it can open. */
|
||||
export const AUDIT_C_HISTORY_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* The sealed answers of one AUDIT-C entry. `schema` versions the blob the same
|
||||
* way the screener responses are versioned.
|
||||
*/
|
||||
export interface AuditCResponses {
|
||||
schema: 1;
|
||||
items: [number, number, number];
|
||||
}
|
||||
|
||||
/** Open the sealed AUDIT-C answers; null when this host cannot read them. */
|
||||
export function openAuditCResponses(
|
||||
payload: Uint8Array,
|
||||
): AuditCResponses | null {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
decryptFromBytes(payload),
|
||||
) as Partial<AuditCResponses>;
|
||||
const items = parsed.items;
|
||||
if (
|
||||
Array.isArray(items) &&
|
||||
items.length === 3 &&
|
||||
items.every((item) => Number.isInteger(item) && item >= 0 && item <= 4)
|
||||
) {
|
||||
return { schema: 1, items: items as [number, number, number] };
|
||||
}
|
||||
getEvent()?.addWarning("audit-c responses shape drifted");
|
||||
} catch (error) {
|
||||
getEvent()?.addWarning(
|
||||
`audit-c responses decrypt failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The first entry of `rows` (newest first) whose answers open. */
|
||||
export function newestReadableAuditC<
|
||||
T extends { responsesEncrypted: Uint8Array },
|
||||
>(rows: readonly T[]): { row: T; items: [number, number, number] } | null {
|
||||
for (const row of rows) {
|
||||
const opened = openAuditCResponses(row.responsesEncrypted);
|
||||
if (opened) return { row, items: opened.items };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The newest readable entry of a record, for the readers that need one. */
|
||||
export async function loadNewestReadableAuditC(userId: string): Promise<{
|
||||
id: string;
|
||||
takenAt: Date;
|
||||
totalScore: number;
|
||||
locale: string;
|
||||
tz: string | null;
|
||||
} | null> {
|
||||
const rows = await prisma.mentalHealthAssessment.findMany({
|
||||
where: { userId, instrument: "AUDIT_C", deletedAt: null },
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
take: AUDIT_C_HISTORY_LIMIT,
|
||||
select: {
|
||||
id: true,
|
||||
takenAt: true,
|
||||
totalScore: true,
|
||||
locale: true,
|
||||
tz: true,
|
||||
responsesEncrypted: true,
|
||||
},
|
||||
});
|
||||
const newest = newestReadableAuditC(rows);
|
||||
if (!newest) return null;
|
||||
const { responsesEncrypted: _sealed, ...entry } = newest.row;
|
||||
return entry;
|
||||
}
|
||||
@@ -13,8 +13,15 @@
|
||||
* Server-only.
|
||||
*/
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { deriveAgeYears } from "@/lib/ai/coach/about-me";
|
||||
import {
|
||||
AUDIT_C_HISTORY_LIMIT,
|
||||
loadNewestReadableAuditC,
|
||||
newestReadableAuditC,
|
||||
openAuditCResponses,
|
||||
type AuditCResponses,
|
||||
} from "@/lib/profile/audit-c-entries";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -40,6 +47,7 @@ import {
|
||||
computePackYears,
|
||||
DEFAULT_FREE_WEEKDAYS,
|
||||
isReviewDue,
|
||||
LIFE_KIND_AI_SECTION,
|
||||
screeningHints,
|
||||
type FreeWeekdaysSpan,
|
||||
type PackYears,
|
||||
@@ -63,22 +71,11 @@ import {
|
||||
} from "@/lib/validations/life-circumstances";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
/** History entries the read route lists, newest first. */
|
||||
const AUDIT_C_HISTORY_LIMIT = 20;
|
||||
/** AUDIT-C is offered again once the newest entry is this old. */
|
||||
const AUDIT_C_OFFER_AFTER_DAYS = 365;
|
||||
/** A step count this recent means activity is measured, not only reported. */
|
||||
const MEASURED_STEPS_DAYS = 30;
|
||||
|
||||
/**
|
||||
* The sealed answers of one AUDIT-C entry. `schema` versions the blob the same
|
||||
* way the screener responses are versioned.
|
||||
*/
|
||||
interface AuditCResponses {
|
||||
schema: 1;
|
||||
items: [number, number, number];
|
||||
}
|
||||
|
||||
export interface AuditCResult {
|
||||
id: string;
|
||||
takenAt: string;
|
||||
@@ -160,31 +157,6 @@ function latest(...dates: Array<Date | null | undefined>): Date | null {
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Open the sealed AUDIT-C answers; null when this host cannot read them. */
|
||||
function openAuditCResponses(payload: Uint8Array): AuditCResponses | null {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
decryptFromBytes(payload),
|
||||
) as Partial<AuditCResponses>;
|
||||
const items = parsed.items;
|
||||
if (
|
||||
Array.isArray(items) &&
|
||||
items.length === 3 &&
|
||||
items.every((item) => Number.isInteger(item) && item >= 0 && item <= 4)
|
||||
) {
|
||||
return { schema: 1, items: items as [number, number, number] };
|
||||
}
|
||||
getEvent()?.addWarning("audit-c responses shape drifted");
|
||||
} catch (error) {
|
||||
getEvent()?.addWarning(
|
||||
`audit-c responses decrypt failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One stored entry scored for a profile sex; the source line follows `labelLocale`. */
|
||||
export function toAuditCResult(
|
||||
row: { id: string; takenAt: Date; totalScore: number; locale: string },
|
||||
@@ -243,6 +215,42 @@ export async function loadFreeWeekdaySpans(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The free-weekday answers for a prompt: only when the section they belong
|
||||
* to (`WORK`) is released to the AI. Otherwise the answers are never read or
|
||||
* decrypted for a prompt, and the night-by-night day type falls back to the
|
||||
* weekend default, exactly as for a record that never answered. The data
|
||||
* surfaces (the sleep page, the dashboard) read `loadFreeWeekdaySpans`
|
||||
* directly: they show the person their own answer, no model sees it.
|
||||
*/
|
||||
export async function loadFreeWeekdaySpansForPrompt(
|
||||
userId: string,
|
||||
): Promise<FreeWeekdaysSpan[]> {
|
||||
// Fail-soft and fail-closed, like the read below: a failed switch read
|
||||
// gives the weekend default and never the answers.
|
||||
try {
|
||||
const profile = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { aiIncludedSections: true },
|
||||
});
|
||||
if (
|
||||
!storedSections(profile?.aiIncludedSections).has(
|
||||
LIFE_KIND_AI_SECTION.FREE_WEEKDAYS,
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
getEvent()?.addWarning(
|
||||
`free weekdays switch read failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return loadFreeWeekdaySpans(userId);
|
||||
}
|
||||
|
||||
/** Assemble the whole read model for one record. */
|
||||
export async function buildLifeCircumstances(
|
||||
userId: string,
|
||||
@@ -327,17 +335,16 @@ export async function buildLifeCircumstances(
|
||||
const factOf = <T>(kind: string): T | null =>
|
||||
(facts.find((fact) => fact.kind === kind)?.value as T | undefined) ?? null;
|
||||
|
||||
// AUDIT-C: the newest entry whose answers this host can open is the one
|
||||
// shown in full; the history lists totals, which need no decryption.
|
||||
let latestAuditC: AuditCResult | null = null;
|
||||
for (const row of auditRows) {
|
||||
const opened = openAuditCResponses(row.responsesEncrypted);
|
||||
if (opened) {
|
||||
latestAuditC = toAuditCResult(row, opened.items, sex, locale);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// AUDIT-C: the newest entry whose answers this host can open is the
|
||||
// result, and the offer is timed from it, as every other reader times it
|
||||
// (`audit-c-entries.ts`). The history lists totals, which need no
|
||||
// decryption, so an unreadable entry can still be removed.
|
||||
const readable = newestReadableAuditC(auditRows);
|
||||
const latestAuditC: AuditCResult | null = readable
|
||||
? toAuditCResult(readable.row, readable.items, sex, locale)
|
||||
: null;
|
||||
const newestAuditC = auditRows[0]?.takenAt ?? null;
|
||||
const newestReadableAt = readable?.row.takenAt ?? null;
|
||||
|
||||
const sections = LIFE_SECTIONS.map((section) => {
|
||||
const sectionFacts = facts.filter((fact) =>
|
||||
@@ -398,8 +405,8 @@ export async function buildLifeCircumstances(
|
||||
totalScore: row.totalScore,
|
||||
})),
|
||||
offerDue:
|
||||
newestAuditC === null ||
|
||||
now.getTime() - newestAuditC.getTime() >=
|
||||
newestReadableAt === null ||
|
||||
now.getTime() - newestReadableAt.getTime() >=
|
||||
AUDIT_C_OFFER_AFTER_DAYS * MS_PER_DAY,
|
||||
instrument: auditCInstrument(locale),
|
||||
},
|
||||
@@ -438,6 +445,25 @@ function lifeSwitches(
|
||||
) as Record<LifeAiSection, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise every read-modify-write of a profile's AI switches. Two routes
|
||||
* write the one array: this module's switch route and the about-me PUT, which
|
||||
* replaces its eight sections and carries the life switches over as stored.
|
||||
* Without a lock, a PUT that read the array before a concurrent switch-off
|
||||
* committed wrote the old value back and turned the section on again. A
|
||||
* transaction-scoped advisory lock covers the first write too, when there is
|
||||
* no row yet to lock.
|
||||
*/
|
||||
export async function lockAiSections(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await tx.$queryRaw`
|
||||
SELECT 1 AS locked
|
||||
FROM pg_advisory_xact_lock(hashtextextended(${`ai-sections:${userId}`}, 0))
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch life-circumstance sections in or out of the AI inclusion list. Only
|
||||
* the named sections change; the other stored values, the five about-me
|
||||
@@ -452,6 +478,7 @@ export async function updateLifeAiSections(
|
||||
base?: Date,
|
||||
): Promise<LifeAiSectionsOutcome> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await lockAiSections(tx, userId);
|
||||
const row = await tx.userHealthProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { aiIncludedSections: true, updatedAt: true },
|
||||
@@ -646,11 +673,7 @@ export async function loadLifeClinicalSummary(
|
||||
},
|
||||
select: { kind: true, valueEncrypted: true },
|
||||
}),
|
||||
prisma.mentalHealthAssessment.findFirst({
|
||||
where: { userId, instrument: "AUDIT_C", deletedAt: null },
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
select: { id: true, takenAt: true, totalScore: true, locale: true },
|
||||
}),
|
||||
loadNewestReadableAuditC(userId),
|
||||
]);
|
||||
const value = <T>(kind: string): T | null => {
|
||||
const row = rows.find((candidate) => candidate.kind === kind);
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { recomputeSupplementsIfNutrientsTurnedOn } from "@/lib/medications/ingredients/recompute-job";
|
||||
|
||||
export interface RecordModuleWrite {
|
||||
/** The record whose modules these are — never the actor, under a switch. */
|
||||
@@ -59,10 +60,19 @@ export async function writeRecordModulePreferences(
|
||||
input: RecordModuleWrite,
|
||||
): Promise<{ cycleTrackingEnabled: boolean | null }> {
|
||||
if (input.modulePreferences !== undefined) {
|
||||
const before = await prisma.user.findUnique({
|
||||
where: { id: input.recordId },
|
||||
select: { modulePreferencesJson: true },
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: input.recordId },
|
||||
data: { modulePreferencesJson: toJson(input.modulePreferences) },
|
||||
});
|
||||
await recomputeSupplementsIfNutrientsTurnedOn(
|
||||
input.recordId,
|
||||
before?.modulePreferencesJson,
|
||||
input.modulePreferences,
|
||||
);
|
||||
}
|
||||
if (input.cycleTrackingEnabled !== undefined) {
|
||||
await getOrCreateCycleProfile(input.recordId);
|
||||
|
||||
@@ -392,6 +392,10 @@ const customMedicationCategorySchema = z
|
||||
const medicationSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
// The source row's id in a portable file, which carries no `id`: the
|
||||
// relation links name the medication by it, and the restore re-points
|
||||
// them at the row it writes (`relation-links-backup.ts`).
|
||||
ref: z.string().min(1).optional(),
|
||||
name: z.string().min(1),
|
||||
dose: z.string(),
|
||||
treatmentClass: z.enum(MedicationCategory).optional(),
|
||||
@@ -1126,6 +1130,8 @@ const appSettingsBackupSchema = z
|
||||
const labResultBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
// The source row's id in a portable file, as on a medication.
|
||||
ref: z.string().min(1).optional(),
|
||||
panel: z.string().nullable().optional(),
|
||||
analyte: z.string().min(1),
|
||||
value: z.number().nullable().optional(),
|
||||
|
||||
@@ -9,7 +9,9 @@ import { z } from "zod/v4";
|
||||
import type { PulseDayMeanMethod } from "@/lib/measurements/day-statistic";
|
||||
|
||||
export const pulseDayMeanActionSchema = z.strictObject({
|
||||
action: z.enum(["dry_run", "apply"]),
|
||||
// `rebuild` runs the recalculation again after an apply whose rebuild
|
||||
// stopped, without a new dry run: the method is already switched.
|
||||
action: z.enum(["dry_run", "apply", "rebuild"]),
|
||||
});
|
||||
export type PulseDayMeanActionInput = z.infer<typeof pulseDayMeanActionSchema>;
|
||||
|
||||
@@ -46,20 +48,47 @@ const dryRunResultSchema = z.object({
|
||||
});
|
||||
export type PulseDayMeanDryRunResult = z.infer<typeof dryRunResultSchema>;
|
||||
|
||||
/** `app_settings.pulse_day_mean_dry_run_json`: the last dry run. */
|
||||
export const pulseDayMeanDryRunSchema = z.object({
|
||||
status: z.enum(["queued", "running", "done", "failed"]),
|
||||
const runStatusSchema = z.enum(["queued", "running", "done", "failed"]);
|
||||
|
||||
/** The last dry run. */
|
||||
const pulseDayMeanDryRunSchema = z.object({
|
||||
status: runStatusSchema,
|
||||
startedAt: z.string(),
|
||||
finishedAt: z.string().nullable(),
|
||||
result: dryRunResultSchema.nullable(),
|
||||
});
|
||||
export type PulseDayMeanDryRun = z.infer<typeof pulseDayMeanDryRunSchema>;
|
||||
|
||||
/** The last recalculation of the pulse rollups after an apply. */
|
||||
const pulseDayMeanRebuildSchema = z.object({
|
||||
status: runStatusSchema,
|
||||
startedAt: z.string(),
|
||||
finishedAt: z.string().nullable(),
|
||||
/** Accounts and rollup rows done; null until the run has finished. */
|
||||
accounts: z.number().int().nonnegative().nullable(),
|
||||
rows: z.number().int().nonnegative().nullable(),
|
||||
});
|
||||
export type PulseDayMeanRebuild = z.infer<typeof pulseDayMeanRebuildSchema>;
|
||||
|
||||
/**
|
||||
* `app_settings.pulse_day_mean_dry_run_json`: the last dry run, and beside it
|
||||
* the last rebuild. One column holds both, because the rebuild only ever
|
||||
* follows an apply that needed a dry run; a blob written before the rebuild
|
||||
* was recorded has no `rebuild` key and reads as none.
|
||||
*/
|
||||
export const pulseDayMeanStoredRunsSchema = pulseDayMeanDryRunSchema.extend({
|
||||
rebuild: pulseDayMeanRebuildSchema.nullable().optional(),
|
||||
});
|
||||
export type PulseDayMeanStoredRuns = z.infer<
|
||||
typeof pulseDayMeanStoredRunsSchema
|
||||
>;
|
||||
|
||||
/** `GET /api/admin/maintenance/pulse-day-mean`. */
|
||||
export interface PulseDayMeanStatus {
|
||||
method: PulseDayMeanMethod;
|
||||
appliedAt: string | null;
|
||||
lastDryRun: PulseDayMeanDryRun | null;
|
||||
lastRebuild: PulseDayMeanRebuild | null;
|
||||
}
|
||||
|
||||
/** How old a finished dry run may be for the apply step to accept it. */
|
||||
|
||||
@@ -134,6 +134,10 @@ function residency(year: number) {
|
||||
};
|
||||
}
|
||||
|
||||
/** A timeline that answers from the window, set by the test that needs it. */
|
||||
let timelineAnswer: ((url: URL) => { status: number; body: unknown }) | null =
|
||||
null;
|
||||
|
||||
function answer(req: IncomingMessage): { status: number; body: unknown } {
|
||||
const url = new URL(req.url ?? "/", "http://stub");
|
||||
requests.push(url.pathname);
|
||||
@@ -144,6 +148,7 @@ function answer(req: IncomingMessage): { status: number; body: unknown } {
|
||||
case "/api/v1/users/me":
|
||||
return { status: 200, body: { email: "someone@example.test" } };
|
||||
case "/api/v1/timeline":
|
||||
if (timelineAnswer) return timelineAnswer(url);
|
||||
return { status: 200, body: { days: TIMELINE_DAYS } };
|
||||
case "/api/v1/visits":
|
||||
return { status: 200, body: VISITS };
|
||||
@@ -188,6 +193,7 @@ beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
cookieJar.clear();
|
||||
requests.length = 0;
|
||||
timelineAnswer = null;
|
||||
process.env.DAWARICH_PRIVATE_ORIGINS = origin;
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
@@ -516,3 +522,73 @@ describe("Disconnect", () => {
|
||||
expect(await storedDays()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Timeline days across a request boundary", () => {
|
||||
/**
|
||||
* The instance as Dawarich builds a timeline (`Timeline::DayAssembler`):
|
||||
* days in the Dawarich account's own zone, here New York while the
|
||||
* HealthLog account is in Berlin, and only what lies inside the requested
|
||||
* window is summed, so a window cut at Berlin midnight answers the New
|
||||
* York day it cuts with a fragment. Every New York day holds a 1 km trip
|
||||
* at 02:00 and a 10 km trip at 20:00 (02:00 the next morning in Berlin),
|
||||
* so a complete day is 11 km and a fragment 1 or 10. A window longer than
|
||||
* 31 days is refused as the instance refuses it.
|
||||
*/
|
||||
const INSTANCE_TZ = "America/New_York";
|
||||
const nyDate = (ms: number) =>
|
||||
new Date(ms).toLocaleDateString("en-CA", { timeZone: INSTANCE_TZ });
|
||||
const nyInstant = (date: string, hour: number) => {
|
||||
// New York is UTC-4 for the whole summer range this test reads.
|
||||
return Date.parse(`${date}T${String(hour).padStart(2, "0")}:00:00-04:00`);
|
||||
};
|
||||
function instanceTimeline(url: URL) {
|
||||
const startMs = Date.parse(url.searchParams.get("start_at") ?? "");
|
||||
const endMs = Date.parse(url.searchParams.get("end_at") ?? "");
|
||||
if (endMs - startMs > 31 * 24 * 3_600_000) {
|
||||
return { status: 400, body: { error: "range too large" } };
|
||||
}
|
||||
const totals = new Map<string, number>();
|
||||
const from = new Date(startMs - 2 * 86_400_000);
|
||||
for (let d = from.getTime(); d <= endMs + 86_400_000; d += 86_400_000) {
|
||||
const date = nyDate(d);
|
||||
for (const [hour, km] of [
|
||||
[2, 1],
|
||||
[20, 10],
|
||||
] as const) {
|
||||
const at = nyInstant(date, hour);
|
||||
if (at < startMs || at > endMs) continue;
|
||||
totals.set(date, (totals.get(date) ?? 0) + km);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
days: [...totals].map(([date, km]) => ({
|
||||
date,
|
||||
summary: {
|
||||
total_distance: km,
|
||||
places_visited: 0,
|
||||
mode_distances: {},
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("never lets a boundary fragment overwrite a complete day", async () => {
|
||||
timelineAnswer = instanceTimeline;
|
||||
expect((await connect()).status).toBe(200);
|
||||
await getPrismaClient().dawarichDay.deleteMany({});
|
||||
|
||||
const { syncDawarichRange } = await import("@/lib/dawarich/sync");
|
||||
// Sixty days: three timeline requests, two boundaries between them.
|
||||
await syncDawarichRange(USER_ID, "2026-06-01", "2026-07-30");
|
||||
|
||||
const days = await storedDays();
|
||||
expect(days).toHaveLength(60);
|
||||
const wrong = days
|
||||
.filter((d) => d.totalDistanceM !== 11_000)
|
||||
.map((d) => `${d.date}=${d.totalDistanceM}`);
|
||||
expect(wrong).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -297,6 +297,34 @@ describe("life-circumstance facts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("free weekdays in a prompt", () => {
|
||||
it("are read for a prompt only while the work section is released", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
await data(
|
||||
await postFact({ kind: "FREE_WEEKDAYS", value: VALUES.FREE_WEEKDAYS }),
|
||||
201,
|
||||
);
|
||||
const { loadFreeWeekdaySpans, loadFreeWeekdaySpansForPrompt } =
|
||||
await import("@/lib/profile/life-circumstances");
|
||||
|
||||
await getPrismaClient().userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, aiIncludedSections: ["WORK"] },
|
||||
update: { aiIncludedSections: ["WORK"] },
|
||||
});
|
||||
expect(await loadFreeWeekdaySpansForPrompt(user.id)).toHaveLength(1);
|
||||
|
||||
await getPrismaClient().userHealthProfile.update({
|
||||
where: { userId: user.id },
|
||||
data: { aiIncludedSections: ["ABOUT_ME"] },
|
||||
});
|
||||
expect(await loadFreeWeekdaySpansForPrompt(user.id)).toEqual([]);
|
||||
// The data surfaces still show the person their own answer.
|
||||
expect(await loadFreeWeekdaySpans(user.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AUDIT-C", () => {
|
||||
async function postAuditC(body: unknown): Promise<Response> {
|
||||
const { POST } = await import("@/app/api/anamnesis/audit-c/route");
|
||||
@@ -396,6 +424,79 @@ describe("AUDIT-C", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("scores against the wording offered and refuses another one or a future date", async () => {
|
||||
// An English account is shown the English wording; a German cut-off for
|
||||
// it would be the stricter S3 table applied to questions never asked.
|
||||
const man = await makeUser({ locale: "en", gender: "MALE" });
|
||||
await signIn(man.id);
|
||||
const wrong = await postAuditC({
|
||||
items: [2, 1, 1],
|
||||
locale: "de",
|
||||
source: "IOS",
|
||||
});
|
||||
expect(wrong.status).toBe(422);
|
||||
const { result } = await data<{
|
||||
result: { locale: string; cutoff: { source: string } | null };
|
||||
}>(await postAuditC({ items: [2, 1, 1], source: "IOS" }), 201);
|
||||
expect(result.locale).toBe("en");
|
||||
expect(result.cutoff?.source).toBe("BRADLEY_2007");
|
||||
|
||||
// French has no validated wording, so the English one is offered and
|
||||
// stored, whatever the account language says.
|
||||
const french = await makeUser({ locale: "fr", gender: "MALE" });
|
||||
await signIn(french.id);
|
||||
const fr = await data<{ result: { locale: string } }>(
|
||||
await postAuditC({ items: [0, 0, 1], locale: "fr", source: "WEB" }),
|
||||
201,
|
||||
);
|
||||
expect(fr.result.locale).toBe("en");
|
||||
|
||||
const future = await postAuditC({
|
||||
items: [0, 0, 0],
|
||||
source: "WEB",
|
||||
takenAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
expect(future.status).toBe(422);
|
||||
const skew = await postAuditC({
|
||||
items: [0, 0, 0],
|
||||
source: "WEB",
|
||||
takenAt: new Date(Date.now() + 60 * 1000).toISOString(),
|
||||
});
|
||||
expect(skew.status).toBe(201);
|
||||
});
|
||||
|
||||
it("reads latest and the offer from the newest entry whose answers open", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
const older = await data<{ result: { id: string } }>(
|
||||
await postAuditC({
|
||||
items: [1, 0, 0],
|
||||
source: "WEB",
|
||||
takenAt: "2025-01-10T09:00:00.000Z",
|
||||
}),
|
||||
201,
|
||||
);
|
||||
// A newer entry this host cannot open (its key is gone).
|
||||
const sealed = await getPrismaClient().mentalHealthAssessment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
instrument: "AUDIT_C",
|
||||
locale: "en",
|
||||
responsesEncrypted: new Uint8Array([9, 9, 9, 9]),
|
||||
totalScore: 11,
|
||||
severityBand: "unbanded",
|
||||
takenAt: new Date(),
|
||||
source: "WEB",
|
||||
},
|
||||
});
|
||||
const life = await readLife();
|
||||
expect(life.auditC.latest?.id).toBe(older.result.id);
|
||||
// Timed from the result shown, not from the entry nobody can read.
|
||||
expect(life.auditC.offerDue).toBe(true);
|
||||
// Still listed, so it can be removed.
|
||||
expect(life.auditC.history.map((h) => h.id)).toContain(sealed.id);
|
||||
});
|
||||
|
||||
it("soft-deletes an entry once, and only an AUDIT-C entry of the record", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
@@ -477,6 +578,53 @@ describe("AI switches and confirmations", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("never turns a section on again that a concurrent switch turned off", async () => {
|
||||
const user = await makeUser({
|
||||
modulePreferencesJson: { mentalHealth: true, coach: true },
|
||||
});
|
||||
await signIn(user.id);
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.userHealthProfile.create({
|
||||
data: { userId: user.id, aiIncludedSections: ["ABOUT_ME", "HABITS"] },
|
||||
});
|
||||
const { lockAiSections } = await import("@/lib/profile/life-circumstances");
|
||||
const aboutMe = await import("@/app/api/coach/about-me/route");
|
||||
|
||||
// The switch-off holds its transaction open while the PUT starts.
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => (release = resolve));
|
||||
let locked!: () => void;
|
||||
const isLocked = new Promise<void>((resolve) => (locked = resolve));
|
||||
const switchOff = prisma.$transaction(async (tx) => {
|
||||
await lockAiSections(tx, user.id);
|
||||
await tx.userHealthProfile.update({
|
||||
where: { userId: user.id },
|
||||
data: { aiIncludedSections: ["ABOUT_ME"] },
|
||||
});
|
||||
locked();
|
||||
await held;
|
||||
});
|
||||
await isLocked;
|
||||
const put = (aboutMe.PUT as unknown as Handler)(
|
||||
request("PUT", "/api/coach/about-me", {
|
||||
aiIncludedSections: ["ABOUT_ME", "CONDITIONS"],
|
||||
}),
|
||||
);
|
||||
// Long enough for an unlocked PUT to have read the old array.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
release();
|
||||
await switchOff;
|
||||
await data(await put);
|
||||
|
||||
const stored = await prisma.userHealthProfile.findUniqueOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
expect([...stored.aiIncludedSections].sort()).toEqual([
|
||||
"ABOUT_ME",
|
||||
"CONDITIONS",
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers a stale token with about_me_conflict", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
|
||||
@@ -38,7 +38,7 @@ import { readLiveBuckets } from "@/lib/measurements/daily-series-read";
|
||||
import { readCanonicalRollupBuckets } from "@/lib/rollups/measurement-read";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
import { pulseDayMeanDryRunSchema } from "@/lib/validations/pulse-day-mean";
|
||||
import { pulseDayMeanStoredRunsSchema } from "@/lib/validations/pulse-day-mean";
|
||||
import type { Job } from "pg-boss";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
@@ -276,7 +276,9 @@ describe("the dry run", () => {
|
||||
const stored = await prisma.appSettings.findUniqueOrThrow({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
const run = pulseDayMeanDryRunSchema.parse(stored.pulseDayMeanDryRunJson);
|
||||
const run = pulseDayMeanStoredRunsSchema.parse(
|
||||
stored.pulseDayMeanDryRunJson,
|
||||
);
|
||||
expect(run.status).toBe("done");
|
||||
expect(run.finishedAt).not.toBeNull();
|
||||
expect(run.result).toEqual({
|
||||
@@ -324,6 +326,13 @@ describe("the rebuild after apply", () => {
|
||||
});
|
||||
const outcome = await handlePulseDayMean(job("rebuild"));
|
||||
expect(outcome.ok).toBe(true);
|
||||
// The rebuild is on the record beside the dry run, with what it did.
|
||||
const { readPulseDayMeanStatus } =
|
||||
await import("@/lib/jobs/pulse-day-mean");
|
||||
const status = await readPulseDayMeanStatus();
|
||||
expect(status.lastRebuild).toMatchObject({ status: "done" });
|
||||
expect(status.lastRebuild?.accounts).toBeGreaterThan(0);
|
||||
expect(status.lastDryRun?.status).toBe("done");
|
||||
|
||||
expect(await dayMean(A, D1)).toBeCloseTo(V2, 6);
|
||||
expect(await dayMean(A, D2)).toBeCloseTo(70, 6);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* The five relation link tables survive a PORTABLE export and restore.
|
||||
*
|
||||
* A portable file carries no row id for a medication or a lab result: the
|
||||
* restore mints fresh ones, because the file may land on another instance or
|
||||
* another account where the original ids mean nothing. The link tables name
|
||||
* both of their ends by id, so a link that rode the file under the OLD id of
|
||||
* either end used to point at nothing after the restore, and the restore's
|
||||
* own placement check dropped it as "end is gone". Four of the five tables
|
||||
* have a medication or a lab result at one end; only the visit to dose link
|
||||
* came back.
|
||||
*
|
||||
* Driven through the real restore route. A portable file with documents is
|
||||
* refused (no document bytes, see `backup-portable-file-restore.test.ts`), so
|
||||
* the file restored here is the portable payload with the document entries
|
||||
* taken from the disaster-recovery build of the same account: everything else
|
||||
* (medications, lab results, links) is the portable bytes exactly as the
|
||||
* export writes them.
|
||||
*
|
||||
* Mutation check: drop the `exportRef` from the portable medication or lab
|
||||
* result entry and the matching link count goes to zero.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { linkTargets } from "@/lib/links";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { POST } from "./restore-job-driver";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
async function seed() {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
username: "portable-links-owner",
|
||||
email: "portable-links-owner@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const userId = owner.id;
|
||||
const session = await prisma.session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
const encounter = await prisma.encounter.create({
|
||||
data: {
|
||||
userId,
|
||||
occurredAt: new Date("2026-07-01T09:00:00.000Z"),
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
},
|
||||
});
|
||||
const document = await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: "LAB_RESULT",
|
||||
title: "Thyroid panel",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 12,
|
||||
contentEncrypted: new Uint8Array([1, 2, 3]),
|
||||
contentCodec: "binary2",
|
||||
documentDate: new Date("2026-06-30T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const biomarker = await prisma.biomarker.create({
|
||||
data: { userId, name: "TSH", unit: "mU/L" },
|
||||
});
|
||||
const labResult = await prisma.labResult.create({
|
||||
data: {
|
||||
userId,
|
||||
biomarkerId: biomarker.id,
|
||||
analyte: "TSH",
|
||||
value: 2.1,
|
||||
unit: "mU/L",
|
||||
takenAt: new Date("2026-06-30T09:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const episode = await prisma.illnessEpisode.create({
|
||||
data: {
|
||||
userId,
|
||||
label: "Thyroid",
|
||||
type: "CHRONIC",
|
||||
onsetAt: new Date("2026-01-20T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const medication = await prisma.medication.create({
|
||||
data: { userId, name: "Levothyroxine", dose: "50 µg" },
|
||||
});
|
||||
// A second drug with no links, so a restore that bound every link to the
|
||||
// first medication it found would be told apart from one that maps by key.
|
||||
await prisma.medication.create({
|
||||
data: { userId, name: "Vitamin D", dose: "1000 IU" },
|
||||
});
|
||||
const vaccination = await prisma.vaccinationRecord.create({
|
||||
data: {
|
||||
userId,
|
||||
occurredAt: new Date("2026-05-14T00:00:00.000Z"),
|
||||
antigenSlug: "tdap",
|
||||
vaccineName: "Tdap",
|
||||
},
|
||||
});
|
||||
|
||||
const link = (
|
||||
sourceKind: Parameters<typeof linkTargets>[1]["sourceKind"],
|
||||
sourceId: string,
|
||||
targetKind: Parameters<typeof linkTargets>[1]["targetKind"],
|
||||
targetId: string,
|
||||
) =>
|
||||
linkTargets(prisma, {
|
||||
userId,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
targetKind,
|
||||
targetIds: [targetId],
|
||||
});
|
||||
await link("labResult", labResult.id, "document", document.id);
|
||||
await link("encounter", encounter.id, "medication", medication.id);
|
||||
await link("encounter", encounter.id, "vaccination", vaccination.id);
|
||||
await link("medication", medication.id, "document", document.id);
|
||||
await link("medication", medication.id, "conditionEpisode", episode.id);
|
||||
|
||||
return { userId, medication, labResult };
|
||||
}
|
||||
|
||||
describe("a portable restore of the five relation link tables", () => {
|
||||
it("brings every link back, bound to the records the restore wrote", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { userId, medication, labResult } = await seed();
|
||||
|
||||
const portable = parseBackupPayload(
|
||||
JSON.stringify((await buildFullBackupPayload(prisma, userId)).payload),
|
||||
);
|
||||
const dr = parseBackupPayload(
|
||||
JSON.stringify(
|
||||
(
|
||||
await buildFullBackupPayload(prisma, userId, {
|
||||
purpose: "disaster-recovery",
|
||||
})
|
||||
).payload,
|
||||
),
|
||||
);
|
||||
// The premise: the portable file names no medication or lab result id.
|
||||
expect(portable.medications.every((m) => m.id === undefined)).toBe(true);
|
||||
expect(portable.labResults.every((l) => l.id === undefined)).toBe(true);
|
||||
|
||||
const file = parseBackupPayload(
|
||||
JSON.stringify({ ...portable, documents: dr.documents }),
|
||||
);
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "PORTABLE_LINKS_RESTORE",
|
||||
data: encrypt(JSON.stringify(file)),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as unknown as Parameters<typeof POST>[0],
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
expect(response.status, await response.clone().text()).toBe(200);
|
||||
|
||||
// The ends were re-minted, so the old ids are gone.
|
||||
const restoredMed = await prisma.medication.findFirstOrThrow({
|
||||
where: { userId, name: "Levothyroxine" },
|
||||
});
|
||||
const restoredLab = await prisma.labResult.findFirstOrThrow({
|
||||
where: { userId, analyte: "TSH" },
|
||||
});
|
||||
expect(restoredMed.id).not.toBe(medication.id);
|
||||
expect(restoredLab.id).not.toBe(labResult.id);
|
||||
|
||||
const where = { userId };
|
||||
const [labDocs, encMeds, encDoses, medDocs, medConds] = await Promise.all([
|
||||
prisma.labResultDocumentLink.findMany({ where }),
|
||||
prisma.encounterMedicationLink.findMany({ where }),
|
||||
prisma.encounterVaccinationLink.findMany({ where }),
|
||||
prisma.medicationDocumentLink.findMany({ where }),
|
||||
prisma.medicationConditionLink.findMany({ where }),
|
||||
]);
|
||||
expect(labDocs.map((r) => r.labResultId)).toEqual([restoredLab.id]);
|
||||
expect(encMeds.map((r) => r.medicationId)).toEqual([restoredMed.id]);
|
||||
expect(encDoses).toHaveLength(1);
|
||||
expect(medDocs.map((r) => r.medicationId)).toEqual([restoredMed.id]);
|
||||
expect(medConds.map((r) => r.medicationId)).toEqual([restoredMed.id]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user