fix: resend offline-queued entries with an idempotency key
When the server saved an inspection, harvest, treatment, feeding, varroa measurement or swarm check but the answer was lost (dead zone at the apiary), the form queued the entry and the sync later created it a second time - including a second automation task. Each opened form now creates one Idempotency-Key, sends it with the request and stores it with the queued entry, so the retry returns the first record. Entries queued before this change still sync without a key. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
5d11756a87
commit
5c2ea7bf1e
16 files changed
+139
-33
No files matched your search
@@ -11,6 +11,7 @@ import { FeedType } from "@/lib/feedings/types";
|
|||||||
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
import { queueFeeding } from "@/lib/offline/db";
|
import { queueFeeding } from "@/lib/offline/db";
|
||||||
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -50,8 +51,12 @@ export default function NewFeedingPage() {
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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({
|
const mutation = useMutation({
|
||||||
mutationFn: createFeeding,
|
mutationFn: (input: Parameters<typeof createFeeding>[0]) =>
|
||||||
|
createFeeding(input, { idempotencyKey }),
|
||||||
onSuccess: (feeding) => {
|
onSuccess: (feeding) => {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["colonies", feeding.colonyId, "feedings"],
|
queryKey: ["colonies", feeding.colonyId, "feedings"],
|
||||||
@@ -67,7 +72,7 @@ export default function NewFeedingPage() {
|
|||||||
setError("Fütterung konnte nicht gespeichert werden.");
|
setError("Fütterung konnte nicht gespeichert werden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queueFeeding(variables).then(() => {
|
void queueFeeding(variables, idempotencyKey).then(() => {
|
||||||
toast.info("Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert.");
|
toast.info("Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert.");
|
||||||
router.push(`/colonies/${variables.colonyId}`);
|
router.push(`/colonies/${variables.colonyId}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createHarvest } from "@/lib/harvests/api";
|
|||||||
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
|
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
import { queueHarvest } from "@/lib/offline/db";
|
import { queueHarvest } from "@/lib/offline/db";
|
||||||
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -60,8 +61,12 @@ export default function NewHarvestPage() {
|
|||||||
(withdrawalStatuses ?? []).map((status) => [status.colonyId, status]),
|
(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({
|
const mutation = useMutation({
|
||||||
mutationFn: createHarvest,
|
mutationFn: (input: Parameters<typeof createHarvest>[0]) =>
|
||||||
|
createHarvest(input, { idempotencyKey }),
|
||||||
onSuccess: (harvest) => {
|
onSuccess: (harvest) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
|
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
|
||||||
toast.success("Ernte gespeichert");
|
toast.success("Ernte gespeichert");
|
||||||
@@ -72,7 +77,7 @@ export default function NewHarvestPage() {
|
|||||||
setError("Ernte konnte nicht gespeichert werden.");
|
setError("Ernte konnte nicht gespeichert werden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queueHarvest(variables).then(() => {
|
void queueHarvest(variables, idempotencyKey).then(() => {
|
||||||
toast.info("Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert.");
|
toast.info("Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert.");
|
||||||
router.push("/harvests");
|
router.push("/harvests");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { fetchColonies } from "@/lib/colonies/api";
|
|||||||
import { createInspection } from "@/lib/inspections/api";
|
import { createInspection } from "@/lib/inspections/api";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
import { queueInspection } from "@/lib/offline/db";
|
import { queueInspection } from "@/lib/offline/db";
|
||||||
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -69,8 +70,12 @@ export default function NewInspectionPage() {
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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({
|
const mutation = useMutation({
|
||||||
mutationFn: createInspection,
|
mutationFn: (input: Parameters<typeof createInspection>[0]) =>
|
||||||
|
createInspection(input, { idempotencyKey }),
|
||||||
onSuccess: (inspection) => {
|
onSuccess: (inspection) => {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["colonies", inspection.colonyId, "inspections"],
|
queryKey: ["colonies", inspection.colonyId, "inspections"],
|
||||||
@@ -83,7 +88,7 @@ export default function NewInspectionPage() {
|
|||||||
setError("Durchsicht konnte nicht gespeichert werden.");
|
setError("Durchsicht konnte nicht gespeichert werden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queueInspection(variables).then(() => {
|
void queueInspection(variables, idempotencyKey).then(() => {
|
||||||
toast.info("Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.");
|
toast.info("Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.");
|
||||||
router.push(`/colonies/${variables.colonyId}`);
|
router.push(`/colonies/${variables.colonyId}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { SwarmControlAction, SwarmMood } from "@/lib/swarm-control/types";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
import { queueSwarmControlCheck } from "@/lib/offline/db";
|
import { queueSwarmControlCheck } from "@/lib/offline/db";
|
||||||
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -50,8 +51,12 @@ export default function NewSwarmControlCheckPage() {
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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({
|
const mutation = useMutation({
|
||||||
mutationFn: createSwarmControlCheck,
|
mutationFn: (input: Parameters<typeof createSwarmControlCheck>[0]) =>
|
||||||
|
createSwarmControlCheck(input, { idempotencyKey }),
|
||||||
onSuccess: (check) => {
|
onSuccess: (check) => {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
|
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
|
||||||
@@ -64,7 +69,7 @@ export default function NewSwarmControlCheckPage() {
|
|||||||
setError("Schwarmkontrolle konnte nicht gespeichert werden.");
|
setError("Schwarmkontrolle konnte nicht gespeichert werden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queueSwarmControlCheck(variables).then(() => {
|
void queueSwarmControlCheck(variables, idempotencyKey).then(() => {
|
||||||
toast.info("Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert.");
|
toast.info("Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert.");
|
||||||
router.push(`/colonies/${variables.colonyId}`);
|
router.push(`/colonies/${variables.colonyId}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { fetchColonies } from "@/lib/colonies/api";
|
|||||||
import { createTreatment } from "@/lib/treatments/api";
|
import { createTreatment } from "@/lib/treatments/api";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
import { queueTreatment } from "@/lib/offline/db";
|
import { queueTreatment } from "@/lib/offline/db";
|
||||||
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -52,8 +53,12 @@ export default function NewTreatmentPage() {
|
|||||||
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState("");
|
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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({
|
const mutation = useMutation({
|
||||||
mutationFn: createTreatment,
|
mutationFn: (input: Parameters<typeof createTreatment>[0]) =>
|
||||||
|
createTreatment(input, { idempotencyKey }),
|
||||||
onSuccess: (treatment) => {
|
onSuccess: (treatment) => {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["colonies", treatment.colonyId, "treatments"],
|
queryKey: ["colonies", treatment.colonyId, "treatments"],
|
||||||
@@ -66,7 +71,7 @@ export default function NewTreatmentPage() {
|
|||||||
setError("Behandlung konnte nicht gespeichert werden.");
|
setError("Behandlung konnte nicht gespeichert werden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queueTreatment(variables).then(() => {
|
void queueTreatment(variables, idempotencyKey).then(() => {
|
||||||
toast.info("Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.");
|
toast.info("Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.");
|
||||||
router.push(`/colonies/${variables.colonyId}`);
|
router.push(`/colonies/${variables.colonyId}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { VarroaMethod } from "@/lib/varroa/types";
|
|||||||
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
|
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
import { queueVarroaMeasurement } from "@/lib/offline/db";
|
import { queueVarroaMeasurement } from "@/lib/offline/db";
|
||||||
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -49,8 +50,12 @@ export default function NewVarroaMeasurementPage() {
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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({
|
const mutation = useMutation({
|
||||||
mutationFn: createVarroaMeasurement,
|
mutationFn: (input: Parameters<typeof createVarroaMeasurement>[0]) =>
|
||||||
|
createVarroaMeasurement(input, { idempotencyKey }),
|
||||||
onSuccess: (measurement) => {
|
onSuccess: (measurement) => {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
|
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
|
||||||
@@ -63,7 +68,7 @@ export default function NewVarroaMeasurementPage() {
|
|||||||
setError("Varroamessung konnte nicht gespeichert werden.");
|
setError("Varroamessung konnte nicht gespeichert werden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queueVarroaMeasurement(variables).then(() => {
|
void queueVarroaMeasurement(variables, idempotencyKey).then(() => {
|
||||||
toast.info("Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert.");
|
toast.info("Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert.");
|
||||||
router.push(`/colonies/${variables.colonyId}`);
|
router.push(`/colonies/${variables.colonyId}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiFetch } from "@/lib/auth/api-client";
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||||
import {
|
import {
|
||||||
CreateFeedingInput,
|
CreateFeedingInput,
|
||||||
Feeding,
|
Feeding,
|
||||||
@@ -14,9 +15,13 @@ export function fetchColonyFeedingsSummary(colonyId: string): Promise<FeedingSum
|
|||||||
return apiFetch<FeedingSummaryEntry[]>(`/colonies/${colonyId}/feedings/summary`);
|
return apiFetch<FeedingSummaryEntry[]>(`/colonies/${colonyId}/feedings/summary`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFeeding(input: CreateFeedingInput): Promise<Feeding> {
|
export function createFeeding(
|
||||||
|
input: CreateFeedingInput,
|
||||||
|
options: CreateOptions = {},
|
||||||
|
): Promise<Feeding> {
|
||||||
return apiFetch<Feeding>("/feedings", {
|
return apiFetch<Feeding>("/feedings", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: idempotencyHeaders(options),
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiFetch } from "@/lib/auth/api-client";
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||||
import { CreateHarvestInput, Harvest, UpdateHarvestInput } from "./types";
|
import { CreateHarvestInput, Harvest, UpdateHarvestInput } from "./types";
|
||||||
|
|
||||||
export function fetchHarvests(): Promise<Harvest[]> {
|
export function fetchHarvests(): Promise<Harvest[]> {
|
||||||
@@ -9,9 +10,13 @@ export function fetchHarvest(id: string): Promise<Harvest> {
|
|||||||
return apiFetch<Harvest>(`/harvests/${id}`);
|
return apiFetch<Harvest>(`/harvests/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHarvest(input: CreateHarvestInput): Promise<Harvest> {
|
export function createHarvest(
|
||||||
|
input: CreateHarvestInput,
|
||||||
|
options: CreateOptions = {},
|
||||||
|
): Promise<Harvest> {
|
||||||
return apiFetch<Harvest>("/harvests", {
|
return apiFetch<Harvest>("/harvests", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: idempotencyHeaders(options),
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiFetch } from "@/lib/auth/api-client";
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||||
import {
|
import {
|
||||||
CreateInspectionInput,
|
CreateInspectionInput,
|
||||||
CreateTaskFromInspectionInput,
|
CreateTaskFromInspectionInput,
|
||||||
@@ -11,9 +12,13 @@ export function fetchColonyInspections(colonyId: string): Promise<Inspection[]>
|
|||||||
return apiFetch<Inspection[]>(`/colonies/${colonyId}/inspections`);
|
return apiFetch<Inspection[]>(`/colonies/${colonyId}/inspections`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInspection(input: CreateInspectionInput): Promise<Inspection> {
|
export function createInspection(
|
||||||
|
input: CreateInspectionInput,
|
||||||
|
options: CreateOptions = {},
|
||||||
|
): Promise<Inspection> {
|
||||||
return apiFetch<Inspection>("/inspections", {
|
return apiFetch<Inspection>("/inspections", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: idempotencyHeaders(options),
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface PendingEntry<T> {
|
|||||||
id: number;
|
id: number;
|
||||||
input: T;
|
input: T;
|
||||||
queuedAt: string;
|
queuedAt: string;
|
||||||
|
// Missing on entries queued before keys existed - those sync as before.
|
||||||
|
idempotencyKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OfflineDB extends DBSchema {
|
interface OfflineDB extends DBSchema {
|
||||||
@@ -70,9 +72,13 @@ function getDb(): Promise<IDBPDatabase<OfflineDB>> {
|
|||||||
return dbPromise;
|
return dbPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queue<T>(store: PendingStoreName, input: T): Promise<void> {
|
async function queue<T>(
|
||||||
|
store: PendingStoreName,
|
||||||
|
input: T,
|
||||||
|
idempotencyKey?: string,
|
||||||
|
): Promise<void> {
|
||||||
const db = await getDb();
|
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<T>(store: PendingStoreName): Promise<PendingEntry<T>[]> {
|
async function getPending<T>(store: PendingStoreName): Promise<PendingEntry<T>[]> {
|
||||||
@@ -91,8 +97,8 @@ async function countPending(store: PendingStoreName): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PendingInspection = PendingEntry<CreateInspectionInput>;
|
export type PendingInspection = PendingEntry<CreateInspectionInput>;
|
||||||
export function queueInspection(input: CreateInspectionInput): Promise<void> {
|
export function queueInspection(input: CreateInspectionInput, idempotencyKey?: string): Promise<void> {
|
||||||
return queue("pending-inspections", input);
|
return queue("pending-inspections", input, idempotencyKey);
|
||||||
}
|
}
|
||||||
export function getPendingInspections(): Promise<PendingInspection[]> {
|
export function getPendingInspections(): Promise<PendingInspection[]> {
|
||||||
return getPending("pending-inspections");
|
return getPending("pending-inspections");
|
||||||
@@ -105,8 +111,8 @@ export function countPendingInspections(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PendingTreatment = PendingEntry<CreateTreatmentInput>;
|
export type PendingTreatment = PendingEntry<CreateTreatmentInput>;
|
||||||
export function queueTreatment(input: CreateTreatmentInput): Promise<void> {
|
export function queueTreatment(input: CreateTreatmentInput, idempotencyKey?: string): Promise<void> {
|
||||||
return queue("pending-treatments", input);
|
return queue("pending-treatments", input, idempotencyKey);
|
||||||
}
|
}
|
||||||
export function getPendingTreatments(): Promise<PendingTreatment[]> {
|
export function getPendingTreatments(): Promise<PendingTreatment[]> {
|
||||||
return getPending("pending-treatments");
|
return getPending("pending-treatments");
|
||||||
@@ -119,8 +125,8 @@ export function countPendingTreatments(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PendingFeeding = PendingEntry<CreateFeedingInput>;
|
export type PendingFeeding = PendingEntry<CreateFeedingInput>;
|
||||||
export function queueFeeding(input: CreateFeedingInput): Promise<void> {
|
export function queueFeeding(input: CreateFeedingInput, idempotencyKey?: string): Promise<void> {
|
||||||
return queue("pending-feedings", input);
|
return queue("pending-feedings", input, idempotencyKey);
|
||||||
}
|
}
|
||||||
export function getPendingFeedings(): Promise<PendingFeeding[]> {
|
export function getPendingFeedings(): Promise<PendingFeeding[]> {
|
||||||
return getPending("pending-feedings");
|
return getPending("pending-feedings");
|
||||||
@@ -133,8 +139,8 @@ export function countPendingFeedings(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PendingVarroaMeasurement = PendingEntry<CreateVarroaMeasurementInput>;
|
export type PendingVarroaMeasurement = PendingEntry<CreateVarroaMeasurementInput>;
|
||||||
export function queueVarroaMeasurement(input: CreateVarroaMeasurementInput): Promise<void> {
|
export function queueVarroaMeasurement(input: CreateVarroaMeasurementInput, idempotencyKey?: string): Promise<void> {
|
||||||
return queue("pending-varroa-measurements", input);
|
return queue("pending-varroa-measurements", input, idempotencyKey);
|
||||||
}
|
}
|
||||||
export function getPendingVarroaMeasurements(): Promise<PendingVarroaMeasurement[]> {
|
export function getPendingVarroaMeasurements(): Promise<PendingVarroaMeasurement[]> {
|
||||||
return getPending("pending-varroa-measurements");
|
return getPending("pending-varroa-measurements");
|
||||||
@@ -147,8 +153,8 @@ export function countPendingVarroaMeasurements(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PendingSwarmControlCheck = PendingEntry<CreateSwarmControlCheckInput>;
|
export type PendingSwarmControlCheck = PendingEntry<CreateSwarmControlCheckInput>;
|
||||||
export function queueSwarmControlCheck(input: CreateSwarmControlCheckInput): Promise<void> {
|
export function queueSwarmControlCheck(input: CreateSwarmControlCheckInput, idempotencyKey?: string): Promise<void> {
|
||||||
return queue("pending-swarm-control-checks", input);
|
return queue("pending-swarm-control-checks", input, idempotencyKey);
|
||||||
}
|
}
|
||||||
export function getPendingSwarmControlChecks(): Promise<PendingSwarmControlCheck[]> {
|
export function getPendingSwarmControlChecks(): Promise<PendingSwarmControlCheck[]> {
|
||||||
return getPending("pending-swarm-control-checks");
|
return getPending("pending-swarm-control-checks");
|
||||||
@@ -161,8 +167,8 @@ export function countPendingSwarmControlChecks(): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PendingHarvest = PendingEntry<CreateHarvestInput>;
|
export type PendingHarvest = PendingEntry<CreateHarvestInput>;
|
||||||
export function queueHarvest(input: CreateHarvestInput): Promise<void> {
|
export function queueHarvest(input: CreateHarvestInput, idempotencyKey?: string): Promise<void> {
|
||||||
return queue("pending-harvests", input);
|
return queue("pending-harvests", input, idempotencyKey);
|
||||||
}
|
}
|
||||||
export function getPendingHarvests(): Promise<PendingHarvest[]> {
|
export function getPendingHarvests(): Promise<PendingHarvest[]> {
|
||||||
return getPending("pending-harvests");
|
return getPending("pending-harvests");
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
}
|
||||||
@@ -54,6 +54,26 @@ describe("syncPendingInspections", () => {
|
|||||||
expect(mockedRemovePending).toHaveBeenCalledWith(2);
|
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 () => {
|
it("keeps a rejected entry queued and continues with the rest on a server-side ApiError", async () => {
|
||||||
mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]);
|
mockedGetPending.mockResolvedValue([pendingEntry(1), pendingEntry(2)]);
|
||||||
mockedCreateInspection
|
mockedCreateInspection
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
|
import { CreateOptions } from "./idempotency";
|
||||||
import { createInspection } from "@/lib/inspections/api";
|
import { createInspection } from "@/lib/inspections/api";
|
||||||
import { createTreatment } from "@/lib/treatments/api";
|
import { createTreatment } from "@/lib/treatments/api";
|
||||||
import { createFeeding } from "@/lib/feedings/api";
|
import { createFeeding } from "@/lib/feedings/api";
|
||||||
@@ -46,9 +47,9 @@ export function syncPendingInspections(): Promise<SyncResult> {
|
|||||||
// failed and left in the queue too, rather than silently dropping data the
|
// 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.
|
// user entered - the sync badge stays visible so it's not forgotten.
|
||||||
async function drain<T>(
|
async function drain<T>(
|
||||||
getPending: () => Promise<{ id: number; input: T }[]>,
|
getPending: () => Promise<{ id: number; input: T; idempotencyKey?: string }[]>,
|
||||||
removePending: (id: number) => Promise<void>,
|
removePending: (id: number) => Promise<void>,
|
||||||
create: (input: T) => Promise<unknown>,
|
create: (input: T, options: CreateOptions) => Promise<unknown>,
|
||||||
): Promise<SyncResult> {
|
): Promise<SyncResult> {
|
||||||
const pending = await getPending();
|
const pending = await getPending();
|
||||||
let synced = 0;
|
let synced = 0;
|
||||||
@@ -56,7 +57,7 @@ async function drain<T>(
|
|||||||
|
|
||||||
for (const entry of pending) {
|
for (const entry of pending) {
|
||||||
try {
|
try {
|
||||||
await create(entry.input);
|
await create(entry.input, { idempotencyKey: entry.idempotencyKey });
|
||||||
await removePending(entry.id);
|
await removePending(entry.id);
|
||||||
synced += 1;
|
synced += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiFetch } from "@/lib/auth/api-client";
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||||
import {
|
import {
|
||||||
CreateSwarmControlCheckInput,
|
CreateSwarmControlCheckInput,
|
||||||
SwarmControlCheck,
|
SwarmControlCheck,
|
||||||
@@ -11,9 +12,11 @@ export function fetchColonySwarmControlChecks(colonyId: string): Promise<SwarmCo
|
|||||||
|
|
||||||
export function createSwarmControlCheck(
|
export function createSwarmControlCheck(
|
||||||
input: CreateSwarmControlCheckInput,
|
input: CreateSwarmControlCheckInput,
|
||||||
|
options: CreateOptions = {},
|
||||||
): Promise<SwarmControlCheck> {
|
): Promise<SwarmControlCheck> {
|
||||||
return apiFetch<SwarmControlCheck>("/swarm-control-checks", {
|
return apiFetch<SwarmControlCheck>("/swarm-control-checks", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: idempotencyHeaders(options),
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiFetch } from "@/lib/auth/api-client";
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||||
import {
|
import {
|
||||||
CreateTreatmentInput,
|
CreateTreatmentInput,
|
||||||
Treatment,
|
Treatment,
|
||||||
@@ -21,9 +22,13 @@ export function fetchWithdrawalStatus(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTreatment(input: CreateTreatmentInput): Promise<Treatment> {
|
export function createTreatment(
|
||||||
|
input: CreateTreatmentInput,
|
||||||
|
options: CreateOptions = {},
|
||||||
|
): Promise<Treatment> {
|
||||||
return apiFetch<Treatment>("/treatments", {
|
return apiFetch<Treatment>("/treatments", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: idempotencyHeaders(options),
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiFetch } from "@/lib/auth/api-client";
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||||
import {
|
import {
|
||||||
CreateVarroaMeasurementInput,
|
CreateVarroaMeasurementInput,
|
||||||
UpdateVarroaMeasurementInput,
|
UpdateVarroaMeasurementInput,
|
||||||
@@ -11,9 +12,11 @@ export function fetchColonyVarroaMeasurements(colonyId: string): Promise<VarroaM
|
|||||||
|
|
||||||
export function createVarroaMeasurement(
|
export function createVarroaMeasurement(
|
||||||
input: CreateVarroaMeasurementInput,
|
input: CreateVarroaMeasurementInput,
|
||||||
|
options: CreateOptions = {},
|
||||||
): Promise<VarroaMeasurement> {
|
): Promise<VarroaMeasurement> {
|
||||||
return apiFetch<VarroaMeasurement>("/varroa-measurements", {
|
return apiFetch<VarroaMeasurement>("/varroa-measurements", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: idempotencyHeaders(options),
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user