diff --git a/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx b/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx index 1a10c49..3e9ac6a 100644 --- a/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx @@ -11,6 +11,7 @@ import { FeedType } from "@/lib/feedings/types"; import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels"; import { ApiError } from "@/lib/auth/api-client"; import { queueFeeding } from "@/lib/offline/db"; +import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -50,8 +51,12 @@ export default function NewFeedingPage() { const [notes, setNotes] = useState(""); const [error, setError] = useState(null); + // One key per opened form: a double tap or an offline retry of the + // same entry is recognised by the server instead of saved twice. + const [idempotencyKey] = useState(newIdempotencyKey); const mutation = useMutation({ - mutationFn: createFeeding, + mutationFn: (input: Parameters[0]) => + createFeeding(input, { idempotencyKey }), onSuccess: (feeding) => { void queryClient.invalidateQueries({ queryKey: ["colonies", feeding.colonyId, "feedings"], @@ -67,7 +72,7 @@ export default function NewFeedingPage() { setError("Fütterung konnte nicht gespeichert werden."); return; } - void queueFeeding(variables).then(() => { + void queueFeeding(variables, idempotencyKey).then(() => { toast.info("Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert."); router.push(`/colonies/${variables.colonyId}`); }); diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx index 76aa604..32a2a22 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx @@ -11,6 +11,7 @@ import { createHarvest } from "@/lib/harvests/api"; import { fetchWithdrawalStatus } from "@/lib/treatments/api"; import { ApiError } from "@/lib/auth/api-client"; import { queueHarvest } from "@/lib/offline/db"; +import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -60,8 +61,12 @@ export default function NewHarvestPage() { (withdrawalStatuses ?? []).map((status) => [status.colonyId, status]), ); + // One key per opened form: a double tap or an offline retry of the + // same entry is recognised by the server instead of saved twice. + const [idempotencyKey] = useState(newIdempotencyKey); const mutation = useMutation({ - mutationFn: createHarvest, + mutationFn: (input: Parameters[0]) => + createHarvest(input, { idempotencyKey }), onSuccess: (harvest) => { void queryClient.invalidateQueries({ queryKey: ["harvests"] }); toast.success("Ernte gespeichert"); @@ -72,7 +77,7 @@ export default function NewHarvestPage() { setError("Ernte konnte nicht gespeichert werden."); return; } - void queueHarvest(variables).then(() => { + void queueHarvest(variables, idempotencyKey).then(() => { toast.info("Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert."); router.push("/harvests"); }); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx index 5b422c7..5240d34 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx @@ -9,6 +9,7 @@ import { fetchColonies } from "@/lib/colonies/api"; import { createInspection } from "@/lib/inspections/api"; import { ApiError } from "@/lib/auth/api-client"; import { queueInspection } from "@/lib/offline/db"; +import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -69,8 +70,12 @@ export default function NewInspectionPage() { const [notes, setNotes] = useState(""); const [error, setError] = useState(null); + // One key per opened form: a double tap or an offline retry of the + // same entry is recognised by the server instead of saved twice. + const [idempotencyKey] = useState(newIdempotencyKey); const mutation = useMutation({ - mutationFn: createInspection, + mutationFn: (input: Parameters[0]) => + createInspection(input, { idempotencyKey }), onSuccess: (inspection) => { void queryClient.invalidateQueries({ queryKey: ["colonies", inspection.colonyId, "inspections"], @@ -83,7 +88,7 @@ export default function NewInspectionPage() { setError("Durchsicht konnte nicht gespeichert werden."); return; } - void queueInspection(variables).then(() => { + void queueInspection(variables, idempotencyKey).then(() => { toast.info("Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert."); router.push(`/colonies/${variables.colonyId}`); }); diff --git a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx index 18c64b1..9540486 100644 --- a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx @@ -11,6 +11,7 @@ import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labe import { SwarmControlAction, SwarmMood } from "@/lib/swarm-control/types"; import { ApiError } from "@/lib/auth/api-client"; import { queueSwarmControlCheck } from "@/lib/offline/db"; +import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -50,8 +51,12 @@ export default function NewSwarmControlCheckPage() { const [notes, setNotes] = useState(""); const [error, setError] = useState(null); + // One key per opened form: a double tap or an offline retry of the + // same entry is recognised by the server instead of saved twice. + const [idempotencyKey] = useState(newIdempotencyKey); const mutation = useMutation({ - mutationFn: createSwarmControlCheck, + mutationFn: (input: Parameters[0]) => + createSwarmControlCheck(input, { idempotencyKey }), onSuccess: (check) => { void queryClient.invalidateQueries({ queryKey: ["colonies", check.colonyId, "swarm-control-checks"], @@ -64,7 +69,7 @@ export default function NewSwarmControlCheckPage() { setError("Schwarmkontrolle konnte nicht gespeichert werden."); return; } - void queueSwarmControlCheck(variables).then(() => { + void queueSwarmControlCheck(variables, idempotencyKey).then(() => { toast.info("Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert."); router.push(`/colonies/${variables.colonyId}`); }); diff --git a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx index 0c2cb56..9169825 100644 --- a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx @@ -9,6 +9,7 @@ import { fetchColonies } from "@/lib/colonies/api"; import { createTreatment } from "@/lib/treatments/api"; import { ApiError } from "@/lib/auth/api-client"; import { queueTreatment } from "@/lib/offline/db"; +import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -52,8 +53,12 @@ export default function NewTreatmentPage() { const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState(""); const [error, setError] = useState(null); + // One key per opened form: a double tap or an offline retry of the + // same entry is recognised by the server instead of saved twice. + const [idempotencyKey] = useState(newIdempotencyKey); const mutation = useMutation({ - mutationFn: createTreatment, + mutationFn: (input: Parameters[0]) => + createTreatment(input, { idempotencyKey }), onSuccess: (treatment) => { void queryClient.invalidateQueries({ queryKey: ["colonies", treatment.colonyId, "treatments"], @@ -66,7 +71,7 @@ export default function NewTreatmentPage() { setError("Behandlung konnte nicht gespeichert werden."); return; } - void queueTreatment(variables).then(() => { + void queueTreatment(variables, idempotencyKey).then(() => { toast.info("Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert."); router.push(`/colonies/${variables.colonyId}`); }); diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx index d124442..1fe5f23 100644 --- a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx @@ -11,6 +11,7 @@ import { VarroaMethod } from "@/lib/varroa/types"; import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels"; import { ApiError } from "@/lib/auth/api-client"; import { queueVarroaMeasurement } from "@/lib/offline/db"; +import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -49,8 +50,12 @@ export default function NewVarroaMeasurementPage() { const [notes, setNotes] = useState(""); const [error, setError] = useState(null); + // One key per opened form: a double tap or an offline retry of the + // same entry is recognised by the server instead of saved twice. + const [idempotencyKey] = useState(newIdempotencyKey); const mutation = useMutation({ - mutationFn: createVarroaMeasurement, + mutationFn: (input: Parameters[0]) => + createVarroaMeasurement(input, { idempotencyKey }), onSuccess: (measurement) => { void queryClient.invalidateQueries({ queryKey: ["colonies", measurement.colonyId, "varroa-measurements"], @@ -63,7 +68,7 @@ export default function NewVarroaMeasurementPage() { setError("Varroamessung konnte nicht gespeichert werden."); return; } - void queueVarroaMeasurement(variables).then(() => { + void queueVarroaMeasurement(variables, idempotencyKey).then(() => { toast.info("Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert."); router.push(`/colonies/${variables.colonyId}`); }); diff --git a/ApiCore-Frontend/src/lib/feedings/api.ts b/ApiCore-Frontend/src/lib/feedings/api.ts index 70d4333..1e7b217 100644 --- a/ApiCore-Frontend/src/lib/feedings/api.ts +++ b/ApiCore-Frontend/src/lib/feedings/api.ts @@ -1,4 +1,5 @@ import { apiFetch } from "@/lib/auth/api-client"; +import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency"; import { CreateFeedingInput, Feeding, @@ -14,9 +15,13 @@ export function fetchColonyFeedingsSummary(colonyId: string): Promise(`/colonies/${colonyId}/feedings/summary`); } -export function createFeeding(input: CreateFeedingInput): Promise { +export function createFeeding( + input: CreateFeedingInput, + options: CreateOptions = {}, +): Promise { return apiFetch("/feedings", { method: "POST", + headers: idempotencyHeaders(options), body: JSON.stringify(input), }); } diff --git a/ApiCore-Frontend/src/lib/harvests/api.ts b/ApiCore-Frontend/src/lib/harvests/api.ts index 6428fbe..723e59b 100644 --- a/ApiCore-Frontend/src/lib/harvests/api.ts +++ b/ApiCore-Frontend/src/lib/harvests/api.ts @@ -1,4 +1,5 @@ import { apiFetch } from "@/lib/auth/api-client"; +import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency"; import { CreateHarvestInput, Harvest, UpdateHarvestInput } from "./types"; export function fetchHarvests(): Promise { @@ -9,9 +10,13 @@ export function fetchHarvest(id: string): Promise { return apiFetch(`/harvests/${id}`); } -export function createHarvest(input: CreateHarvestInput): Promise { +export function createHarvest( + input: CreateHarvestInput, + options: CreateOptions = {}, +): Promise { return apiFetch("/harvests", { method: "POST", + headers: idempotencyHeaders(options), body: JSON.stringify(input), }); } diff --git a/ApiCore-Frontend/src/lib/inspections/api.ts b/ApiCore-Frontend/src/lib/inspections/api.ts index 898ddd4..3807b71 100644 --- a/ApiCore-Frontend/src/lib/inspections/api.ts +++ b/ApiCore-Frontend/src/lib/inspections/api.ts @@ -1,4 +1,5 @@ import { apiFetch } from "@/lib/auth/api-client"; +import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency"; import { CreateInspectionInput, CreateTaskFromInspectionInput, @@ -11,9 +12,13 @@ export function fetchColonyInspections(colonyId: string): Promise return apiFetch(`/colonies/${colonyId}/inspections`); } -export function createInspection(input: CreateInspectionInput): Promise { +export function createInspection( + input: CreateInspectionInput, + options: CreateOptions = {}, +): Promise { return apiFetch("/inspections", { method: "POST", + headers: idempotencyHeaders(options), body: JSON.stringify(input), }); } diff --git a/ApiCore-Frontend/src/lib/offline/db.ts b/ApiCore-Frontend/src/lib/offline/db.ts index 32c4481..1fbb8a3 100644 --- a/ApiCore-Frontend/src/lib/offline/db.ts +++ b/ApiCore-Frontend/src/lib/offline/db.ts @@ -18,6 +18,8 @@ interface PendingEntry { id: number; input: T; queuedAt: string; + // Missing on entries queued before keys existed - those sync as before. + idempotencyKey?: string; } interface OfflineDB extends DBSchema { @@ -70,9 +72,13 @@ function getDb(): Promise> { return dbPromise; } -async function queue(store: PendingStoreName, input: T): Promise { +async function queue( + store: PendingStoreName, + input: T, + idempotencyKey?: string, +): Promise { const db = await getDb(); - await db.add(store, { input, queuedAt: new Date().toISOString() } as never); + await db.add(store, { input, idempotencyKey, queuedAt: new Date().toISOString() } as never); } async function getPending(store: PendingStoreName): Promise[]> { @@ -91,8 +97,8 @@ async function countPending(store: PendingStoreName): Promise { } export type PendingInspection = PendingEntry; -export function queueInspection(input: CreateInspectionInput): Promise { - return queue("pending-inspections", input); +export function queueInspection(input: CreateInspectionInput, idempotencyKey?: string): Promise { + return queue("pending-inspections", input, idempotencyKey); } export function getPendingInspections(): Promise { return getPending("pending-inspections"); @@ -105,8 +111,8 @@ export function countPendingInspections(): Promise { } export type PendingTreatment = PendingEntry; -export function queueTreatment(input: CreateTreatmentInput): Promise { - return queue("pending-treatments", input); +export function queueTreatment(input: CreateTreatmentInput, idempotencyKey?: string): Promise { + return queue("pending-treatments", input, idempotencyKey); } export function getPendingTreatments(): Promise { return getPending("pending-treatments"); @@ -119,8 +125,8 @@ export function countPendingTreatments(): Promise { } export type PendingFeeding = PendingEntry; -export function queueFeeding(input: CreateFeedingInput): Promise { - return queue("pending-feedings", input); +export function queueFeeding(input: CreateFeedingInput, idempotencyKey?: string): Promise { + return queue("pending-feedings", input, idempotencyKey); } export function getPendingFeedings(): Promise { return getPending("pending-feedings"); @@ -133,8 +139,8 @@ export function countPendingFeedings(): Promise { } export type PendingVarroaMeasurement = PendingEntry; -export function queueVarroaMeasurement(input: CreateVarroaMeasurementInput): Promise { - return queue("pending-varroa-measurements", input); +export function queueVarroaMeasurement(input: CreateVarroaMeasurementInput, idempotencyKey?: string): Promise { + return queue("pending-varroa-measurements", input, idempotencyKey); } export function getPendingVarroaMeasurements(): Promise { return getPending("pending-varroa-measurements"); @@ -147,8 +153,8 @@ export function countPendingVarroaMeasurements(): Promise { } export type PendingSwarmControlCheck = PendingEntry; -export function queueSwarmControlCheck(input: CreateSwarmControlCheckInput): Promise { - return queue("pending-swarm-control-checks", input); +export function queueSwarmControlCheck(input: CreateSwarmControlCheckInput, idempotencyKey?: string): Promise { + return queue("pending-swarm-control-checks", input, idempotencyKey); } export function getPendingSwarmControlChecks(): Promise { return getPending("pending-swarm-control-checks"); @@ -161,8 +167,8 @@ export function countPendingSwarmControlChecks(): Promise { } export type PendingHarvest = PendingEntry; -export function queueHarvest(input: CreateHarvestInput): Promise { - return queue("pending-harvests", input); +export function queueHarvest(input: CreateHarvestInput, idempotencyKey?: string): Promise { + return queue("pending-harvests", input, idempotencyKey); } export function getPendingHarvests(): Promise { return getPending("pending-harvests"); diff --git a/ApiCore-Frontend/src/lib/offline/idempotency.ts b/ApiCore-Frontend/src/lib/offline/idempotency.ts new file mode 100644 index 0000000..f45df8f --- /dev/null +++ b/ApiCore-Frontend/src/lib/offline/idempotency.ts @@ -0,0 +1,23 @@ +/** + * One key per form submission, sent as `Idempotency-Key` and stored with an + * offline-queued entry. If the server saved a record but the answer never + * reached the phone (dead zone at the apiary), the queued retry carries the + * same key and the server returns the first record instead of a duplicate. + */ +export interface CreateOptions { + idempotencyKey?: string; +} + +export function idempotencyHeaders(options: CreateOptions = {}): HeadersInit { + return options.idempotencyKey ? { "Idempotency-Key": options.idempotencyKey } : {}; +} + +export function newIdempotencyKey(): string { + // randomUUID only exists in secure contexts (HTTPS/localhost); the app can + // also be opened over plain HTTP in a local network. + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/ApiCore-Frontend/src/lib/offline/sync.spec.ts b/ApiCore-Frontend/src/lib/offline/sync.spec.ts index 089d332..eb43bb2 100644 --- a/ApiCore-Frontend/src/lib/offline/sync.spec.ts +++ b/ApiCore-Frontend/src/lib/offline/sync.spec.ts @@ -54,6 +54,26 @@ describe("syncPendingInspections", () => { expect(mockedRemovePending).toHaveBeenCalledWith(2); }); + it("resends a queued entry with the idempotency key it was queued with", async () => { + // The server may already have saved the first attempt; the same key lets + // it answer with that record instead of storing a duplicate. + mockedGetPending.mockResolvedValue([ + { ...pendingEntry(1), idempotencyKey: "key-from-the-form-0001" }, + pendingEntry(2), + ]); + mockedCreateInspection.mockResolvedValue({ id: "x" } as never); + + await syncPendingInspections(); + + expect(mockedCreateInspection).toHaveBeenNthCalledWith(1, pendingEntry(1).input, { + idempotencyKey: "key-from-the-form-0001", + }); + // Entries queued before keys existed still sync, just without one. + expect(mockedCreateInspection).toHaveBeenNthCalledWith(2, pendingEntry(2).input, { + idempotencyKey: undefined, + }); + }); + it("keeps a rejected entry queued and continues with the rest on a server-side ApiError", async () => { mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]); mockedCreateInspection diff --git a/ApiCore-Frontend/src/lib/offline/sync.ts b/ApiCore-Frontend/src/lib/offline/sync.ts index e384c10..0dbb6d7 100644 --- a/ApiCore-Frontend/src/lib/offline/sync.ts +++ b/ApiCore-Frontend/src/lib/offline/sync.ts @@ -1,4 +1,5 @@ import { ApiError } from "@/lib/auth/api-client"; +import { CreateOptions } from "./idempotency"; import { createInspection } from "@/lib/inspections/api"; import { createTreatment } from "@/lib/treatments/api"; import { createFeeding } from "@/lib/feedings/api"; @@ -46,9 +47,9 @@ export function syncPendingInspections(): Promise { // failed and left in the queue too, rather than silently dropping data the // user entered - the sync badge stays visible so it's not forgotten. async function drain( - getPending: () => Promise<{ id: number; input: T }[]>, + getPending: () => Promise<{ id: number; input: T; idempotencyKey?: string }[]>, removePending: (id: number) => Promise, - create: (input: T) => Promise, + create: (input: T, options: CreateOptions) => Promise, ): Promise { const pending = await getPending(); let synced = 0; @@ -56,7 +57,7 @@ async function drain( for (const entry of pending) { try { - await create(entry.input); + await create(entry.input, { idempotencyKey: entry.idempotencyKey }); await removePending(entry.id); synced += 1; } catch (error) { diff --git a/ApiCore-Frontend/src/lib/swarm-control/api.ts b/ApiCore-Frontend/src/lib/swarm-control/api.ts index 5c44fa1..b5a5916 100644 --- a/ApiCore-Frontend/src/lib/swarm-control/api.ts +++ b/ApiCore-Frontend/src/lib/swarm-control/api.ts @@ -1,4 +1,5 @@ import { apiFetch } from "@/lib/auth/api-client"; +import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency"; import { CreateSwarmControlCheckInput, SwarmControlCheck, @@ -11,9 +12,11 @@ export function fetchColonySwarmControlChecks(colonyId: string): Promise { return apiFetch("/swarm-control-checks", { method: "POST", + headers: idempotencyHeaders(options), body: JSON.stringify(input), }); } diff --git a/ApiCore-Frontend/src/lib/treatments/api.ts b/ApiCore-Frontend/src/lib/treatments/api.ts index 8a55a68..2cc45bb 100644 --- a/ApiCore-Frontend/src/lib/treatments/api.ts +++ b/ApiCore-Frontend/src/lib/treatments/api.ts @@ -1,4 +1,5 @@ import { apiFetch } from "@/lib/auth/api-client"; +import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency"; import { CreateTreatmentInput, Treatment, @@ -21,9 +22,13 @@ export function fetchWithdrawalStatus( ); } -export function createTreatment(input: CreateTreatmentInput): Promise { +export function createTreatment( + input: CreateTreatmentInput, + options: CreateOptions = {}, +): Promise { return apiFetch("/treatments", { method: "POST", + headers: idempotencyHeaders(options), body: JSON.stringify(input), }); } diff --git a/ApiCore-Frontend/src/lib/varroa/api.ts b/ApiCore-Frontend/src/lib/varroa/api.ts index 5a891f1..89c31c1 100644 --- a/ApiCore-Frontend/src/lib/varroa/api.ts +++ b/ApiCore-Frontend/src/lib/varroa/api.ts @@ -1,4 +1,5 @@ import { apiFetch } from "@/lib/auth/api-client"; +import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency"; import { CreateVarroaMeasurementInput, UpdateVarroaMeasurementInput, @@ -11,9 +12,11 @@ export function fetchColonyVarroaMeasurements(colonyId: string): Promise { return apiFetch("/varroa-measurements", { method: "POST", + headers: idempotencyHeaders(options), body: JSON.stringify(input), }); }