fix(medications): identify an imported dose by when it was taken

The intake import built its replay key from the row's optional `zaehler`
field. That reads as a per-row counter — the documented example showed one —
but nothing required it to be unique and nothing checked. A reported export
carried the dose quantity there instead: 1 tablet, on all 28 rows. Every entry
queued under the same key, one dose reached the ledger, and the other 27 came
back reported as duplicates of a dose they had nothing to do with.

The key is the instant now, and `zaehler` is out of the contract rather than
accepted-and-ignored. Nothing is lost by dropping it: the live-row unique on
`(user_id, medication_id, scheduled_for, source)` already makes an imported
dose unique per instant, so a re-import of a file first imported under the old
key still dedups.

The telling is the other half. Two different facts were counted as one number:
entries of a submission that collide with each other, and doses the ledger
already holds. They are separate reasons now, grouped with a count, and the
dialog renders them through the same classifier and the same outcome line as
the CSV and JSON import cards — so an import that wrote nothing can no longer
answer in success tone. That rule shipped for the settings import panel and
this surface was not part of it; the guard now lists every import surface by
path, so a fourth one has to be added deliberately. `skippedInvalid` is gone:
it was hardcoded to zero on every path and reported a capability the importer
never had.

Alongside it, the other half of the environment report — a scheduled job that
fails every run has to say so. `recordError()` increments one unnamed counter,
so the admin status page could show a number and not which queue produced it,
and the module's own screen showed a zero day count with nothing to
distinguish "nothing stored yet" from "every run failed". pg-boss has held the
answer the whole time: a job that exhausts its retries keeps its queue name,
its error and its timestamp. The admin status now names the failing queues
with counts and the last error, the environment overview reports its own
account's failed runs, and neither needed a change at any of the thirty
`recordError()` call sites — a visibility fix that has to be remembered per
call site is the same failure one level up.

Refs #650, #646
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 14:37:57 +02:00
1 parent a8d798442f
commit f0c5b5ae94
27 files changed
+1626 -154

No files matched your search

+15
View File
@@ -45,6 +45,14 @@ export interface WorkerStatus {
errors: number;
}
/** One queue with exhausted-retry failures, as `/api/admin/status` reports it. */
export interface FailingQueue {
queue: string;
failures: number;
lastFailedAt: string;
lastError: string;
}
export interface SystemStatus {
version: string;
nodeVersion: string;
@@ -53,6 +61,13 @@ export interface SystemStatus {
startTime: string;
database: string;
worker: WorkerStatus;
/**
* Queues whose jobs exhausted their retries recently. `null` means the
* deployment has no pg-boss schema to ask (web-only); an empty `queues`
* array means it was asked and nothing is failing. The two are rendered
* differently on purpose — absence is not health.
*/
failingJobs: { windowHours: number; queues: FailingQueue[] } | null;
counts: {
users: number;
measurements: number;
+84 -1
View File
@@ -23,7 +23,12 @@ import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
import {
StatusItem,
usePublicVersion,
useSystemStatus,
type FailingQueue,
} from "./_shared";
// v1.4.16 phase B3: Recharts is ~108 KiB Brotli — defer-load the host
// metrics chart so the system-status page only ships it when an admin
@@ -43,6 +48,83 @@ const HostMetricsChart = dynamic(
},
);
/**
* The queues whose jobs gave up, named.
*
* Before this, a background job that threw on every single run showed up as one
* digit in an unnamed `errors` counter, and the module it fed rendered an empty
* screen as if empty were the answer. A broken queue has to say its own name,
* so this card is rendered whenever there is anything to report and stays
* silent otherwise — an operator should be able to trust that a quiet card
* means quiet queues, which is why the "cannot ask" case renders its own line
* rather than looking identical to health.
*/
function FailingJobsCard({
failingJobs,
}: {
failingJobs: { windowHours: number; queues: FailingQueue[] } | null;
}) {
const { t, tCount } = useTranslations();
if (failingJobs === null) {
return (
<SettingsCard>
<SettingsCardHeader
icon={AlertTriangle}
title={t("admin.failingJobs.title")}
description={t("admin.failingJobs.description")}
/>
<p className="text-muted-foreground mt-4 pl-7 text-sm">
{t("admin.failingJobs.unavailable")}
</p>
</SettingsCard>
);
}
return (
<SettingsCard>
<SettingsCardHeader
icon={AlertTriangle}
title={t("admin.failingJobs.title")}
description={t("admin.failingJobs.description")}
/>
{failingJobs.queues.length === 0 ? (
<p
data-testid="failing-jobs-none"
className="text-muted-foreground mt-4 pl-7 text-sm"
>
{t("admin.failingJobs.none", { hours: failingJobs.windowHours })}
</p>
) : (
<ul data-testid="failing-jobs-list" className="mt-4 space-y-3 pl-7">
{failingJobs.queues.map((queue) => (
<li key={queue.queue} className="text-sm">
<p className="text-foreground font-medium">
<span className="font-mono">{queue.queue}</span>{" "}
<span className="text-destructive font-normal">
{tCount("admin.failingJobs.count", queue.failures, {
count: queue.failures,
})}
</span>
</p>
<p className="text-muted-foreground text-xs">
{t("admin.failingJobs.lastFailed", {
when: formatDateTime(queue.lastFailedAt),
})}
</p>
{queue.lastError.length > 0 && (
<p className="text-muted-foreground mt-0.5 font-mono text-xs break-all">
{queue.lastError}
</p>
)}
</li>
))}
</ul>
)}
</SettingsCard>
);
}
export function SystemStatusSection() {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -216,6 +298,7 @@ export function SystemStatusSection() {
</div>
)}
</SettingsCard>
{status && <FailingJobsCard failingJobs={status.failingJobs} />}
</div>
);
}