From f360d221e145551111f036f7117ece3e2382cab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:55:31 +0200 Subject: [PATCH] fix(admin): record the pulse rebuild and let a stopped one run again The rebuild after apply now writes its state beside the dry run (queued, running, done with counts, failed); a run left queued or running without a job reads as failed. The card shows it and offers a new rebuild after a failure, which the route accepts once the method is applied, without a new dry run. --- messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/ko.json | 10 +- messages/pl.json | 10 +- .../pulse-day-mean/__tests__/route.test.ts | 76 +++++++++++- .../admin/maintenance/pulse-day-mean/route.ts | 62 ++++++++-- .../__tests__/pulse-day-mean-card.test.tsx | 36 +++++- src/components/admin/pulse-day-mean-card.tsx | 51 +++++++- .../pulse-day-mean-rebuild-state.test.ts | 73 ++++++++++++ src/lib/jobs/pulse-day-mean.ts | 112 ++++++++++++++---- .../__tests__/recompute-on-switch.test.ts | 2 +- src/lib/validations/pulse-day-mean.ts | 37 +++++- .../integration/pulse-day-mean-method.test.ts | 13 +- 16 files changed, 483 insertions(+), 49 deletions(-) create mode 100644 src/lib/jobs/__tests__/pulse-day-mean-rebuild-state.test.ts diff --git a/messages/de.json b/messages/de.json index d514020f9..8b7dae3ab 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", diff --git a/messages/en.json b/messages/en.json index 84b69140a..2a30cc523 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", diff --git a/messages/es.json b/messages/es.json index 7f428795b..49d60e6f5 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", diff --git a/messages/fr.json b/messages/fr.json index 8f7f8f69f..d2453af72 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", diff --git a/messages/it.json b/messages/it.json index 4e3f7b5b5..fefbb8d30 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", diff --git a/messages/ko.json b/messages/ko.json index 95d537ce8..ec0a66d5b 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": "통신사", diff --git a/messages/pl.json b/messages/pl.json index 5fe13ba60..7aecb1066 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", 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/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..91bfbc0dc 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; }, }); @@ -101,6 +106,8 @@ export function PulseDayMeanCard() { ? t("admin.section.pulseDayMean.applied") : t("admin.section.pulseDayMean.appliedNoRebuild"), ); + } else if (action === "rebuild") { + toast.success(t("admin.section.pulseDayMean.rebuildStarted")); } else { toast.success(t("admin.section.pulseDayMean.dryRunStarted")); } @@ -125,9 +132,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 +266,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}