fix(forms): keep one Idempotency-Key for the remaining create forms

A screening, an illness episode, a life event, a symptom, a custom
metric or an address-book entry created on a weak signal could be
stored twice: the POST committed, the answer missed the timeout, and
the second tap went out under a new key. Their routes already honour
the key; the forms now post through useIdempotentPost like the other
create forms, so the retry replays the first answer.

The screening's save moves into a small hook beside MentalWellbeing,
which sat at the function-size cap and now drops below it; the custom
metric form's create-or-update call moves into one as well.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:39:19 +01:00
1 parent 4485dfb3a9
commit e68041a3af
8 files changed
+102 -38

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 339,
"total": 110086,
"count": 338,
"total": 109885,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -168,7 +168,6 @@
"components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516,
"components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222,
"components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230,
"components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 201,
"components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256,
"components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270,
"components/mood/mood-form.tsx :: MoodForm": 514,
@@ -10,7 +10,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { ApiError, apiPatch } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
@@ -34,6 +35,18 @@ function parseInteger(raw: string): number | null {
return Number.isInteger(n) && n >= 0 ? n : null;
}
/**
* Update the metric, or define a new one under one Idempotency-Key until the
* create lands, so a retry after a timeout is not a second metric.
*/
function useSaveCustomMetric(existing: CustomMetricDto | undefined) {
const post = useIdempotentPost();
return (body: unknown) =>
existing
? apiPatch<CustomMetricDto>(`/api/custom-metrics/${existing.id}`, body)
: post<CustomMetricDto>("/api/custom-metrics", body);
}
interface CustomMetricFormProps {
/** When set the form edits this metric; otherwise it defines a new one. */
existing?: CustomMetricDto;
@@ -80,6 +93,7 @@ export function CustomMetricForm({
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = useSaveCustomMetric(existing);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -110,12 +124,7 @@ export function CustomMetricForm({
setSubmitting(true);
try {
const saved = existing
? await apiPatch<CustomMetricDto>(
`/api/custom-metrics/${existing.id}`,
body,
)
: await apiPost<CustomMetricDto>("/api/custom-metrics", body);
const saved = await save(body);
toast.success(
existing
? t("customMetrics.form.updatedToast")
@@ -1,9 +1,11 @@
/**
* An episode save settles once the server stored it.
* An episode save settles once the server stored it, and stores it once.
*
* Every episode hook's `onSuccess` used to return the refresh of the illness
* reads and the dashboard. A mutation waits for what `onSuccess` returns, so
* the episode sheet kept its spinner until each of those reads had refetched.
* And a create retried after a timeout went out under a new key, so the
* server stored the episode twice.
*/
import { describe, expect, it, vi } from "vitest";
@@ -14,6 +16,11 @@ const client = vi.hoisted(() => ({
getQueryCache: () => ({ findAll: () => [] }),
}));
vi.mock("react", async (importOriginal) => ({
...(await importOriginal<typeof import("react")>()),
useRef: <T>(initial: T) => ({ current: initial }),
}));
vi.mock("@tanstack/react-query", () => ({
// The hook's options are what is under test; return them as they are.
useQuery: (options: unknown) => options,
@@ -29,6 +36,7 @@ vi.mock("@/lib/api/api-fetch", () => ({
apiDelete: vi.fn(),
}));
import { apiPost } from "@/lib/api/api-fetch";
import {
useCreateEpisode,
useDeleteEpisode,
@@ -54,3 +62,26 @@ describe("episode saves", () => {
expect(client.invalidateQueries).toHaveBeenCalled();
});
});
describe("creating an episode", () => {
it("retries under the same Idempotency-Key and starts the next one fresh", async () => {
const create = useCreateEpisode() as unknown as {
mutationFn: (input: unknown) => Promise<unknown>;
};
const post = vi.mocked(apiPost);
post.mockRejectedValueOnce(new TypeError("timed out"));
post.mockResolvedValue({ id: "e1" });
const keyOf = (call: number) =>
new Headers((post.mock.calls[call]![2] as RequestInit).headers).get(
"Idempotency-Key",
);
await expect(create.mutationFn({ name: "Flu" })).rejects.toThrow();
await create.mutationFn({ name: "Flu" });
await create.mutationFn({ name: "Cold" });
expect(keyOf(0)).toBeTruthy();
expect(keyOf(1)).toBe(keyOf(0));
expect(keyOf(2)).not.toBe(keyOf(0));
});
});
+5 -1
View File
@@ -17,6 +17,7 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import {
illnessDependentKeys,
invalidateKeys,
@@ -164,9 +165,12 @@ function useInvalidateIllness() {
/** Create an episode. */
export function useCreateEpisode() {
const invalidate = useInvalidateIllness();
// One key until a save lands, so a retry after a timeout is not a second
// episode.
const post = useIdempotentPost();
return useMutation({
mutationFn: (input: IllnessEpisodeCreateInput) =>
apiPost<IllnessEpisodeDTO>("/api/illness/episodes", input),
post<IllnessEpisodeDTO>("/api/illness/episodes", input),
onSuccess: invalidate,
});
}
@@ -31,7 +31,8 @@ import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { apiGetList, apiPost } from "@/lib/api/api-fetch";
import { apiGetList } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import {
invalidateKeys,
queryKeys,
@@ -57,9 +58,35 @@ import type {
} from "./types";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
/**
* The check-in's save, under one Idempotency-Key until it lands, so a second
* tap after a timeout replays the stored screening instead of adding one.
*/
function useSubmitAssessment(onSaved: (data: CreateResponse) => void) {
const queryClient = useQueryClient();
const post = useIdempotentPost();
return useMutation({
mutationFn: (body: unknown) =>
post<CreateResponse>("/api/mental-health/assessments", body),
onSuccess: (data) => {
onSaved(data);
// A screening is listed on its day, and it can satisfy a due checkup
// reminder, so the checkups list and the Today rail must repaint.
// Honest constraint: the server satisfy runs async via pg-boss, so
// this invalidation narrows the stale window but cannot close it — a
// refetch racing the job may still see the pre-satisfy row.
void invalidateKeys(queryClient, [
queryKeys.mentalHealthAssessments(),
queryKeys.measurementReminders(),
queryKeys.dayRoot(),
]);
void refetchInactiveDailyReads(queryClient);
},
});
}
export function MentalWellbeing() {
const { t, locale } = useTranslations();
const queryClient = useQueryClient();
const [phase, setPhase] = useState<Phase>("choose");
const [instrument, setInstrument] = useState<InstrumentId>("PHQ9");
@@ -94,24 +121,9 @@ export function MentalWellbeing() {
});
const history = historyPage?.data;
const mutation = useMutation({
mutationFn: (body: unknown) =>
apiPost<CreateResponse>("/api/mental-health/assessments", body),
onSuccess: (data) => {
setResult(data);
setPhase("result");
// A screening is listed on its day, and it can satisfy a due checkup
// reminder, so the checkups list and the Today rail must repaint.
// Honest constraint: the server satisfy runs async via pg-boss, so
// this invalidation narrows the stale window but cannot close it — a
// refetch racing the job may still see the pre-satisfy row.
void invalidateKeys(queryClient, [
queryKeys.mentalHealthAssessments(),
queryKeys.measurementReminders(),
queryKeys.dayRoot(),
]);
void refetchInactiveDailyReads(queryClient);
},
const mutation = useSubmitAssessment((data) => {
setResult(data);
setPhase("result");
});
// Most-recent assessment per instrument for the landing's "last result" line.
+5 -2
View File
@@ -16,7 +16,7 @@ import {
type QueryClient,
} from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type {
@@ -147,9 +147,12 @@ function useInvalidateSymptoms() {
export function useCreateSymptomDefinition() {
const invalidate = useInvalidateSymptoms();
// One key until a save lands, so a retry after a timeout is not a second
// symptom.
const post = useIdempotentPost();
return useMutation({
mutationFn: (input: { label: string; icon?: string | null }) =>
apiPost<SymptomDefinitionDTO>("/api/symptoms/definitions", input),
post<SymptomDefinitionDTO>("/api/symptoms/definitions", input),
onSuccess: invalidate,
});
}
+6 -2
View File
@@ -10,7 +10,8 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import type {
LifeEventCreateInput,
LifeEventDTO,
@@ -75,9 +76,12 @@ export function useLifeEventMutations() {
const invalidate = () =>
invalidateKeys(qc, [queryKeys.timelineRoot(), queryKeys.dayRoot()]);
// One key until a save lands, so a retry after a timeout is not a second
// event.
const post = useIdempotentPost();
const create = useMutation({
mutationFn: (body: LifeEventCreateInput) =>
apiPost<LifeEventDTO>("/api/life-events", body),
post<LifeEventDTO>("/api/life-events", body),
onSuccess: invalidate,
});
const update = useMutation({
+4 -2
View File
@@ -11,6 +11,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGetList, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import {
encounterDependentKeys,
invalidateKeys,
@@ -70,10 +71,11 @@ export function usePractitionerMutations() {
refetchInactiveDailyReads(qc),
]);
// One key for as long as the sheet that mounts this is open.
const post = useIdempotentPost();
const create = useMutation({
mutationKey: queryKeys.practitionerCreate(),
mutationFn: (body: PractitionerWriteBody) =>
apiPost<Practitioner>(BASE, body),
mutationFn: (body: PractitionerWriteBody) => post<Practitioner>(BASE, body),
onSuccess: invalidate,
});