diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cdbce07fa..3153d95c6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 421fcb35c..b89725bc1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index b80e0a938..b20d52ba7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index bb81a534a..3dde73720 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 96777e4a8..f53f6dc32 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 4acd04e2f..9ec0dc003 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 1bfd80398..d35f2619b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "상태 일지로 옮기기", diff --git a/messages/pl.json b/messages/pl.json index 8f35637a7..3284806be 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts b/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts index 3f05cb0a1..26c89266e 100644 --- a/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts +++ b/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts @@ -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(); }); diff --git a/src/app/api/admin/maintenance/pulse-day-mean/route.ts b/src/app/api/admin/maintenance/pulse-day-mean/route.ts index c94e34f50..12e0ebf5d 100644 --- a/src/app/api/admin/maintenance/pulse-day-mean/route.ts +++ b/src/app/api/admin/maintenance/pulse-day-mean/route.ts @@ -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>, + previous: PulseDayMeanStatus["lastRebuild"], +): Promise { + 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, diff --git a/src/app/api/anamnesis/audit-c/route.ts b/src/app/api/anamnesis/audit-c/route.ts index fa979b285..cae7171d6 100644 --- a/src/app/api/anamnesis/audit-c/route.ts +++ b/src/app/api/anamnesis/audit-c/route.ts @@ -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 { @@ -54,11 +58,36 @@ async function postAuditC(request: NextRequest): Promise { } 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, }); diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index 5c10e8d93..abeceb8de 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -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 diff --git a/src/app/api/auth/me/timezone/__tests__/route.test.ts b/src/app/api/auth/me/timezone/__tests__/route.test.ts index 8fa2a9216..5cd3cfff8 100644 --- a/src/app/api/auth/me/timezone/__tests__/route.test.ts +++ b/src/app/api/auth/me/timezone/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/auth/me/timezone/route.ts b/src/app/api/auth/me/timezone/route.ts index 0bc0c0503..ad4ff0485 100644 --- a/src/app/api/auth/me/timezone/route.ts +++ b/src/app/api/auth/me/timezone/route.ts @@ -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, diff --git a/src/app/api/auth/oidc/login/__tests__/route.test.ts b/src/app/api/auth/oidc/login/__tests__/route.test.ts index 1d9fc1584..cf283fb6a 100644 --- a/src/app/api/auth/oidc/login/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/login/__tests__/route.test.ts @@ -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, diff --git a/src/app/api/auth/oidc/login/route.ts b/src/app/api/auth/oidc/login/route.ts index dd0dc1cc8..f783e171c 100644 --- a/src/app/api/auth/oidc/login/route.ts +++ b/src/app/api/auth/oidc/login/route.ts @@ -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); diff --git a/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts b/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts index 77954ad35..022796ebb 100644 --- a/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts +++ b/src/app/api/coach/about-me/__tests__/route-concurrency.test.ts @@ -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 }, }; diff --git a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts index 402286c7d..95dcfe264 100644 --- a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts +++ b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts @@ -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, diff --git a/src/app/api/coach/about-me/route.ts b/src/app/api/coach/about-me/route.ts index 5cc84ed6c..bacf26cad 100644 --- a/src/app/api/coach/about-me/route.ts +++ b/src/app/api/coach/about-me/route.ts @@ -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 }, diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index a141cccbf..3b61abb58 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -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. diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index ea7c78dd1..876d7a75a 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -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 { 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 { dateKey: hourId.dateKey, hour: hourId.hour, value: hourMapped.value, + startMs: startDate.getTime(), }); continue; } @@ -984,28 +987,24 @@ async function postBatch(request: NextRequest): Promise { } } - // 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(); - 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 { 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" }, diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index c899bd3ba..86b6b465c 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -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, diff --git a/src/components/admin/__tests__/pulse-day-mean-card.test.tsx b/src/components/admin/__tests__/pulse-day-mean-card.test.tsx index 926de7ea6..317901ea6 100644 --- a/src/components/admin/__tests__/pulse-day-mean-card.test.tsx +++ b/src/components/admin/__tests__/pulse-day-mean-card.test.tsx @@ -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"'); + }); }); diff --git a/src/components/admin/pulse-day-mean-card.tsx b/src/components/admin/pulse-day-mean-card.tsx index 285fec109..9a78004f4 100644 --- a/src/components/admin/pulse-day-mean-card.tsx +++ b/src/components/admin/pulse-day-mean-card.tsx @@ -57,8 +57,13 @@ export function PulseDayMeanCard() { queryFn: () => apiGet(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 ( @@ -252,6 +268,25 @@ export function PulseDayMeanCard() {

) : null} + {rebuild ? ( +

+ {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}`)} +

+ ) : null} + + {canRebuild ? ( + + ) : null}