fix(forms): send one Idempotency-Key per opened create form

A mood, symptom, lab value, custom metric value, unscheduled dose,
allergy, vaccination, visit or measurement saved on a weak signal could
be stored twice: the POST committed, the answer missed the 15 s
timeout, the form said it could not save, and the second tap was a new
call with a new key, so the server ran it again.

useIdempotentPost keeps one key until a save lands and renews it for the
next entry, so the retry replays the first answer. The create forms and
the three create hooks post through it; the comment on the per-call key
in api-fetch now says what that key does and does not cover.

LabForm and MoodForm each gain the one line that mounts the key, so
their function-size baselines go up by one. The intake toast guard now
recognises a dose posted through the hook.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:02:40 +01:00
1 parent 121b23f4a2
commit f761500247
15 files changed
+172 -35

No files matched your search

@@ -11,8 +11,8 @@ import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -56,6 +56,7 @@ export function CustomMetricEntryForm({
const { t } = useTranslations();
const formId = useId();
const queryClient = useQueryClient();
const post = useIdempotentPost();
const [value, setValue] = useState("");
const [measuredAt, setMeasuredAt] = useState(() => nowLocal());
@@ -86,7 +87,7 @@ export function CustomMetricEntryForm({
setSubmitting(true);
try {
await apiPost<CustomMetricEntryDto>(
await post<CustomMetricEntryDto>(
`/api/custom-metrics/${customMetricId}/entries`,
body,
);
@@ -399,7 +399,7 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not
// AFTER the intake POST landed, and after the sheet was told it is done:
// the save does not wait for the refetches before it closes.
expect(componentSrc).toMatch(
/async function handleSubmit\([\s\S]*?apiPost[\s\S]*?onSuccess\?\.\(\);\s*(?:\/\/[^\n]*\n\s*)*void invalidateMedicationReads\(queryClient\);/,
/async function handleSubmit\([\s\S]*?await post<[\s\S]*?onSuccess\?\.\(\);\s*(?:\/\/[^\n]*\n\s*)*void invalidateMedicationReads\(queryClient\);/,
);
// The bundle carries every inline compliance chart, so no second sweep.
expect(medicationDependentKeys).toContainEqual(
@@ -27,7 +27,8 @@ import {
} from "@/lib/medications/default-medication";
import { useAuth } from "@/hooks/use-auth";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiGet } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { localizedApiError } from "@/lib/api/localized-error";
import {
intakeToastOptions,
@@ -119,6 +120,7 @@ export function MedicationIntakeQuickAdd({
}: MedicationIntakeQuickAddProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const post = useIdempotentPost();
// v1.16.9 — the auto-pick reasons in the PROFILE timezone (the same
// source the cards use); Berlin stays the last-resort fallback so
// logged-out mounts behave unchanged.
@@ -226,7 +228,7 @@ export function MedicationIntakeQuickAdd({
// v1.30.1 — the route returns the created event
// (`apiSuccess(event, 201)`), same shape the card's take/skip path
// already reads; the id drives the new Undo action below.
const created = await apiPost<{ id?: string } | undefined>(
const created = await post<{ id?: string } | undefined>(
`/api/medications/${medicationId}/intake`,
{
takenAt: timestamp,
+4 -2
View File
@@ -21,7 +21,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiGet } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { localizedApiError } from "@/lib/api/localized-error";
import {
isUnreadableRange,
@@ -144,6 +145,7 @@ export function LabForm({
const labNumber = useLabNumber();
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const post = useIdempotentPost();
const formId = useId();
const { data: catalog, isLoading: catalogLoading } = useQuery({
@@ -265,7 +267,7 @@ export function LabForm({
const printed = isQualitative
? null
: parseReferenceRange(sourceRange, selected?.unit);
const created = await apiPost<LabResultDto>("/api/labs", {
const created = await post<LabResultDto>("/api/labs", {
biomarkerId,
...(isQualitative
? { valueText: qualitativeValue }
@@ -33,8 +33,8 @@ import {
measurementDependentKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit";
import {
getLastUsedMeasurementType,
@@ -327,6 +327,7 @@ export function MeasurementForm({
const { t } = useTranslations();
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const post = useIdempotentPost();
const unitDisplay = useUnitDisplay();
const { user } = useAuth();
// Glucose is its own display axis, separate from metric/imperial: it is
@@ -448,7 +449,7 @@ export function MeasurementForm({
});
}
await apiPost("/api/measurements", batch);
await post("/api/measurements", batch);
} else if (isKetoneMode) {
// One fingerstick, posted as one transaction (`ketone-fingerstick.ts`).
const batch = ketoneFingerstickBatch({
@@ -463,7 +464,7 @@ export function MeasurementForm({
setError(t("measurements.saveError"));
return;
}
await apiPost("/api/measurements", batch);
await post("/api/measurements", batch);
} else {
// Single measurement. Canonical storage stays SI: an imperial user
// types in lb / in / °F and we invert to the canonical unit at the
@@ -508,7 +509,7 @@ export function MeasurementForm({
: inverted;
}
canonicalValue = entryValueToCanonical(type, canonicalValue);
await apiPost("/api/measurements", {
await post("/api/measurements", {
type,
value: canonicalValue,
measuredAt: timestamp,
+3 -2
View File
@@ -42,8 +42,8 @@ import {
moodDependentKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { MoodTagPicker } from "./mood-tag-picker";
import {
@@ -125,6 +125,7 @@ export function MoodForm({
}: MoodFormProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const post = useIdempotentPost();
const [mood, setMood] = useState("");
const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({
@@ -236,7 +237,7 @@ export function MoodForm({
const trimmedNote = note.trim();
const saved = await apiPost<DroppedLinkKeysWire>("/api/mood-entries", {
const saved = await post<DroppedLinkKeysWire>("/api/mood-entries", {
mood,
tags: tags.length > 0 ? tags : undefined,
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
+4 -2
View File
@@ -17,7 +17,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { apiPost, apiPut } from "@/lib/api/api-fetch";
import { apiPut } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { localizedApiError } from "@/lib/api/localized-error";
import {
allergyCategoryEnum,
@@ -67,6 +68,7 @@ export function AllergyForm({
footerSlot,
}: AllergyFormProps) {
const { t } = useTranslations();
const post = useIdempotentPost();
const formId = useId();
const [substance, setSubstance] = useState(existing?.substance ?? "");
@@ -106,7 +108,7 @@ export function AllergyForm({
try {
const saved = existing
? await apiPut<AllergyDTO>(`/api/allergies/${existing.id}`, body)
: await apiPost<AllergyDTO>("/api/allergies", body);
: await post<AllergyDTO>("/api/allergies", body);
toast.success(
existing
? t("records.allergies.form.updatedToast")
+4 -1
View File
@@ -17,6 +17,7 @@ import {
} from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type {
SymptomDefinitionDTO,
@@ -184,11 +185,13 @@ export function useDeleteSymptomDefinition() {
});
}
/** Under one key for as long as the entry form that mounts it is open. */
export function useLogSymptomEvent() {
const invalidate = useInvalidateSymptoms();
const post = useIdempotentPost();
return useMutation({
mutationFn: (input: SymptomEventCreateInput) =>
apiPost<SymptomEventDTO>("/api/symptoms/events", input),
post<SymptomEventDTO>("/api/symptoms/events", input),
onSuccess: invalidate,
});
}
@@ -21,6 +21,7 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
import {
invalidateKeys,
@@ -133,10 +134,11 @@ export function useVaccinationMutations() {
invalidateReminderReads(qc),
]);
// One key for as long as the sheet that mounts this is open.
const post = useIdempotentPost();
const create = useMutation({
mutationKey: queryKeys.vaccinationCreate(),
mutationFn: (body: VaccinationWriteBody) =>
apiPost<Vaccination>(BASE, body),
mutationFn: (body: VaccinationWriteBody) => post<Vaccination>(BASE, body),
onSuccess: invalidate,
});