feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import {
|
||||
BULK_MAX_COLONIES,
|
||||
createFeedingsBulk,
|
||||
createTreatmentsBulk,
|
||||
createVarroaBulk,
|
||||
invalidColonyIdsOf,
|
||||
RETRY_LATER_MESSAGES,
|
||||
isRetryLaterError,
|
||||
} from "./api";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const KEY = "0123456789abcdef-key";
|
||||
|
||||
describe("bulk api", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset().mockResolvedValue({ bulkId: "b-1", records: [] } as never);
|
||||
});
|
||||
|
||||
it("allows at most 100 colonies, like the server", () => {
|
||||
expect(BULK_MAX_COLONIES).toBe(100);
|
||||
});
|
||||
|
||||
it("sends the Idempotency-Key", async () => {
|
||||
const input = { date: "2026-10-14", product: "Oxuvar", colonyIds: ["a", "b"] };
|
||||
|
||||
await createTreatmentsBulk(input, { idempotencyKey: KEY });
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith("/treatments/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Idempotency-Key": KEY },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
});
|
||||
|
||||
it("posts feedings and varroa counts to their own bulk endpoints", async () => {
|
||||
await createFeedingsBulk(
|
||||
{ date: "2026-10-14", feedType: "SUGAR_WATER", amount: 2, unit: "l", colonyIds: ["a"] },
|
||||
{ idempotencyKey: KEY },
|
||||
);
|
||||
await createVarroaBulk(
|
||||
{
|
||||
date: "2026-10-14",
|
||||
method: "NATURAL_MITE_FALL",
|
||||
durationMinutes: 4320,
|
||||
entries: [{ colonyId: "a", miteCount: 9 }],
|
||||
},
|
||||
{ idempotencyKey: KEY },
|
||||
);
|
||||
|
||||
expect(vi.mocked(apiFetch).mock.calls.map(([path]) => path)).toEqual([
|
||||
"/feedings/bulk",
|
||||
"/varroa-measurements/bulk",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidColonyIdsOf", () => {
|
||||
it("reads ApiError.body", () => {
|
||||
const error = new ApiError(400, "Einige Völker sind nicht (mehr) erfassbar", {
|
||||
message: "Einige Völker sind nicht (mehr) erfassbar",
|
||||
invalidColonyIds: ["c-3", "c-5"],
|
||||
});
|
||||
|
||||
expect(invalidColonyIdsOf(error)).toEqual(["c-3", "c-5"]);
|
||||
});
|
||||
|
||||
it("is empty for anything else", () => {
|
||||
expect(invalidColonyIdsOf(new ApiError(400, "x", {}))).toEqual([]);
|
||||
expect(invalidColonyIdsOf(new ApiError(400, "x", { invalidColonyIds: "c-3" }))).toEqual([]);
|
||||
expect(invalidColonyIdsOf(new ApiError(400, "x", { invalidColonyIds: ["c-3", 7] }))).toEqual([
|
||||
"c-3",
|
||||
]);
|
||||
expect(invalidColonyIdsOf(new Error("x"))).toEqual([]);
|
||||
expect(invalidColonyIdsOf(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRetryLaterError", () => {
|
||||
it("is true for „wird bereits verarbeitet“ and a just-released key, not for other 409s", () => {
|
||||
expect(
|
||||
isRetryLaterError(
|
||||
new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isRetryLaterError(
|
||||
new ApiError(409, "Diese Anfrage wurde gerade abgebrochen - bitte erneut senden"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isRetryLaterError(
|
||||
new ApiError(409, "Dieser Idempotency-Key wurde bereits für eine andere Aktion verwendet"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isRetryLaterError(new ApiError(400, "wird bereits verarbeitet"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Relative to this file, not the working directory. The backend is absent in
|
||||
// the frontend-only Docker build; the pin below then runs in the full
|
||||
// monorepo checks only.
|
||||
const BACKEND_INTERCEPTOR = resolve(
|
||||
__dirname,
|
||||
"../../../../ApiCore-Backend/src/common/idempotency/idempotency.interceptor.ts",
|
||||
);
|
||||
const hasBackend = existsSync(BACKEND_INTERCEPTOR);
|
||||
|
||||
describe("retry-later messages", () => {
|
||||
// The server has no error code for these two 409s, so the client matches
|
||||
// the German text. The cross-check pins it to the backend source: a reworded
|
||||
// message there fails here instead of silently turning "later" into "rejected".
|
||||
it.each(RETRY_LATER_MESSAGES)("is recognised as retry-later: %s", (message) => {
|
||||
expect(isRetryLaterError(new ApiError(409, message))).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(!hasBackend).each(RETRY_LATER_MESSAGES)("the backend still says: %s", (message) => {
|
||||
expect(readFileSync(BACKEND_INTERCEPTOR, "utf-8")).toContain(message);
|
||||
});
|
||||
|
||||
it("the key-used-for-another-action message is not among them", () => {
|
||||
expect(RETRY_LATER_MESSAGES.some((message) => message.includes("andere Aktion"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import { invalidColonyIdsOfBody } from "./bulk-input";
|
||||
import type { CreateTreatmentInput, Treatment } from "@/lib/treatments/types";
|
||||
import type { CreateFeedingInput, Feeding } from "@/lib/feedings/types";
|
||||
import type { CreateVarroaMeasurementInput, VarroaMeasurement } from "@/lib/varroa/types";
|
||||
|
||||
/** Mirrors ApiCore-Backend/src/common/bulk/bulk-limits.ts - keep both in step. */
|
||||
export const BULK_MAX_COLONIES = 100;
|
||||
|
||||
export interface BulkCreateResult<T> {
|
||||
bulkId: string;
|
||||
records: T[];
|
||||
}
|
||||
|
||||
export interface BulkCreateTreatmentInput extends Omit<CreateTreatmentInput, "colonyId"> {
|
||||
colonyIds: string[];
|
||||
}
|
||||
|
||||
export interface BulkCreateFeedingInput extends Omit<CreateFeedingInput, "colonyId"> {
|
||||
colonyIds: string[];
|
||||
}
|
||||
|
||||
/** What each colony has of its own in a bulk count. */
|
||||
export interface BulkVarroaEntry {
|
||||
colonyId: string;
|
||||
miteCount?: number | null;
|
||||
calculatedValue?: number | null;
|
||||
assessment?: string | null;
|
||||
}
|
||||
|
||||
export interface BulkCreateVarroaInput
|
||||
extends Omit<
|
||||
CreateVarroaMeasurementInput,
|
||||
"colonyId" | "miteCount" | "calculatedValue" | "assessment"
|
||||
> {
|
||||
entries: BulkVarroaEntry[];
|
||||
}
|
||||
|
||||
function postBulk<Record>(
|
||||
path: string,
|
||||
input: unknown,
|
||||
options: CreateOptions,
|
||||
): Promise<BulkCreateResult<Record>> {
|
||||
return apiFetch<BulkCreateResult<Record>>(path, {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders(options),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function createTreatmentsBulk(
|
||||
input: BulkCreateTreatmentInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<BulkCreateResult<Treatment>> {
|
||||
return postBulk("/treatments/bulk", input, options);
|
||||
}
|
||||
|
||||
export function createFeedingsBulk(
|
||||
input: BulkCreateFeedingInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<BulkCreateResult<Feeding>> {
|
||||
return postBulk("/feedings/bulk", input, options);
|
||||
}
|
||||
|
||||
export function createVarroaBulk(
|
||||
input: BulkCreateVarroaInput,
|
||||
options: CreateOptions = {},
|
||||
): Promise<BulkCreateResult<VarroaMeasurement>> {
|
||||
return postBulk("/varroa-measurements/bulk", input, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colonies a refused bulk entry could not be recorded for (400
|
||||
* `invalidColonyIds`: unknown or ended since the pack was loaded). Empty for
|
||||
* every other error.
|
||||
*/
|
||||
export function invalidColonyIdsOf(error: unknown): string[] {
|
||||
return error instanceof ApiError ? invalidColonyIdsOfBody(error.body) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The two 409 texts of the idempotency interceptor that mean „later“. The
|
||||
* server sends no error code for them; api.spec.ts pins both to its source.
|
||||
*/
|
||||
export const RETRY_LATER_MESSAGES = [
|
||||
"Diese Anfrage wird bereits verarbeitet - bitte kurz warten",
|
||||
"Diese Anfrage wurde gerade abgebrochen - bitte erneut senden",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 409 „wird bereits verarbeitet“ (the same key is still running) and „gerade
|
||||
* abgebrochen“ (it was just released): nothing is wrong with the entry, the
|
||||
* answer is „später erneut“. Other 409s (key used for another action) are real.
|
||||
*/
|
||||
export function isRetryLaterError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.status === 409 &&
|
||||
RETRY_LATER_MESSAGES.some((message) => error.message === message)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { colonyIdsOfBulkInput, withoutColonies } from "./bulk-input";
|
||||
|
||||
const TREATMENT = { date: "2026-10-14", product: "Oxuvar", colonyIds: ["a", "b", "c"] };
|
||||
const VARROA = {
|
||||
date: "2026-10-14",
|
||||
method: "NATURAL_MITE_FALL",
|
||||
entries: [
|
||||
{ colonyId: "a", miteCount: 4 },
|
||||
{ colonyId: "b", miteCount: 9 },
|
||||
],
|
||||
};
|
||||
|
||||
describe("colonyIdsOfBulkInput", () => {
|
||||
it("lists the colonies of every kind", () => {
|
||||
expect(colonyIdsOfBulkInput("treatments", TREATMENT)).toEqual(["a", "b", "c"]);
|
||||
expect(colonyIdsOfBulkInput("feedings", TREATMENT)).toEqual(["a", "b", "c"]);
|
||||
expect(colonyIdsOfBulkInput("varroa", VARROA)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("is empty for input of an unknown shape", () => {
|
||||
expect(colonyIdsOfBulkInput("treatments", null)).toEqual([]);
|
||||
expect(colonyIdsOfBulkInput("varroa", { entries: "x" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutColonies", () => {
|
||||
it("drops the colonies and keeps every shared field", () => {
|
||||
expect(withoutColonies("treatments", TREATMENT, ["b"])).toEqual({
|
||||
...TREATMENT,
|
||||
colonyIds: ["a", "c"],
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a varroa colony together with its own count", () => {
|
||||
expect(withoutColonies("varroa", VARROA, ["a"])).toEqual({
|
||||
...VARROA,
|
||||
entries: [{ colonyId: "b", miteCount: 9 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not change the input", () => {
|
||||
const before = structuredClone(TREATMENT);
|
||||
withoutColonies("treatments", TREATMENT, ["a"]);
|
||||
expect(TREATMENT).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BulkKind } from "@/lib/offline/db";
|
||||
|
||||
function listOf(input: unknown, key: "colonyIds" | "entries"): unknown[] {
|
||||
const list = (input as Record<string, unknown> | null)?.[key];
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
/** The colonies a queued bulk entry is for (varroa: one entry per colony). */
|
||||
export function colonyIdsOfBulkInput(kind: BulkKind, input: unknown): string[] {
|
||||
if (kind === "varroa") {
|
||||
return listOf(input, "entries").flatMap((entry) => {
|
||||
const colonyId = (entry as { colonyId?: unknown } | null)?.colonyId;
|
||||
return typeof colonyId === "string" ? [colonyId] : [];
|
||||
});
|
||||
}
|
||||
return listOf(input, "colonyIds").filter((id): id is string => typeof id === "string");
|
||||
}
|
||||
|
||||
/** The input without the given colonies; every shared field stays as entered. */
|
||||
export function withoutColonies(
|
||||
kind: BulkKind,
|
||||
input: unknown,
|
||||
colonyIds: readonly string[],
|
||||
): unknown {
|
||||
if (kind === "varroa") {
|
||||
return {
|
||||
...(input as object),
|
||||
entries: listOf(input, "entries").filter(
|
||||
(entry) => !colonyIds.includes((entry as { colonyId: string }).colonyId),
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...(input as object),
|
||||
colonyIds: listOf(input, "colonyIds").filter((id) => !colonyIds.includes(id as string)),
|
||||
};
|
||||
}
|
||||
|
||||
/** `invalidColonyIds` of a server answer body (400 of a bulk endpoint); empty otherwise. */
|
||||
export function invalidColonyIdsOfBody(body: unknown): string[] {
|
||||
const ids = (body as { invalidColonyIds?: unknown } | null)?.invalidColonyIds;
|
||||
return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
bulkDestination,
|
||||
bulkQueuedText,
|
||||
bulkSavedText,
|
||||
bulkSelectionError,
|
||||
} from "./capture-texts";
|
||||
|
||||
describe("bulkSelectionError", () => {
|
||||
it("asks for a stand first, then for at least one colony", () => {
|
||||
expect(bulkSelectionError(null, [])).toBe("Bitte einen Stand wählen.");
|
||||
expect(bulkSelectionError("apiary-1", [])).toBe("Bitte mindestens ein Volk wählen.");
|
||||
});
|
||||
|
||||
it("allows 100 colonies and names the limit for 101", () => {
|
||||
const ids = (count: number) => Array.from({ length: count }, (_, index) => `c-${index}`);
|
||||
|
||||
expect(bulkSelectionError("apiary-1", ids(100))).toBeUndefined();
|
||||
expect(bulkSelectionError("apiary-1", ids(101))).toBe("Höchstens 100 Völker auf einmal.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk texts", () => {
|
||||
it("says how many colonies were saved, in the singular too", () => {
|
||||
expect(bulkSavedText("Behandlung", 3)).toBe("Behandlung für 3 Völker gespeichert");
|
||||
expect(bulkSavedText("Fütterung", 1)).toBe("Fütterung für 1 Volk gespeichert");
|
||||
});
|
||||
|
||||
it("says that an entry waits offline", () => {
|
||||
expect(bulkQueuedText("Varroamessung", 4)).toBe(
|
||||
"Kein Netzwerk – Varroamessung für 4 Völker wurde offline gespeichert und wird synchronisiert.",
|
||||
);
|
||||
});
|
||||
|
||||
it("goes back to the apiary, or to the overview without one", () => {
|
||||
expect(bulkDestination("apiary-1")).toBe("/apiaries/apiary-1");
|
||||
expect(bulkDestination(null)).toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BULK_MAX_COLONIES } from "./api";
|
||||
|
||||
/** What is wrong with the colonies chosen for a bulk capture, if anything. */
|
||||
export function bulkSelectionError(
|
||||
apiaryId: string | null,
|
||||
colonyIds: readonly string[],
|
||||
): string | undefined {
|
||||
if (!apiaryId) return "Bitte einen Stand wählen.";
|
||||
if (colonyIds.length === 0) return "Bitte mindestens ein Volk wählen.";
|
||||
if (colonyIds.length > BULK_MAX_COLONIES) {
|
||||
return `Höchstens ${BULK_MAX_COLONIES} Völker auf einmal.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function colonies(count: number): string {
|
||||
return count === 1 ? "1 Volk" : `${count} Völker`;
|
||||
}
|
||||
|
||||
/** „Behandlung für 3 Völker gespeichert“ - `kind` is the German name of the record. */
|
||||
export function bulkSavedText(kind: string, count: number): string {
|
||||
return `${kind} für ${colonies(count)} gespeichert`;
|
||||
}
|
||||
|
||||
export function bulkQueuedText(kind: string, count: number): string {
|
||||
return `Kein Netzwerk – ${kind} für ${colonies(count)} wurde offline gespeichert und wird synchronisiert.`;
|
||||
}
|
||||
|
||||
/** Where a saved bulk capture goes next: its stand, or the overview. */
|
||||
export function bulkDestination(apiaryId: string | null): string {
|
||||
return apiaryId ? `/apiaries/${apiaryId}` : "/dashboard";
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { livingColoniesFromPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import { withPackFallback } from "@/lib/offline/pack-fallback";
|
||||
|
||||
/** What a bulk choice needs of a colony - a server colony or one from an offline pack. */
|
||||
export type BulkColony = Pick<Colony, "id" | "code" | "status" | "apiaryId">;
|
||||
export interface BulkApiary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Where the choices came from: the server, or - only without network - the loaded packs. */
|
||||
export type ChoiceSource = "server" | "packs";
|
||||
|
||||
interface Choices<T> {
|
||||
items: T[];
|
||||
source: ChoiceSource;
|
||||
}
|
||||
|
||||
/** Prefix of the queries here; they read the server or this device's storage, never both as one. */
|
||||
const BULK_CHOICES_KEY = ["bulk-choices"] as const;
|
||||
|
||||
function byCode(a: BulkColony, b: BulkColony): number {
|
||||
return a.code.localeCompare(b.code, "de", { numeric: true });
|
||||
}
|
||||
|
||||
function livingOf(colonies: readonly BulkColony[]): BulkColony[] {
|
||||
return colonies
|
||||
.filter(isLivingColony)
|
||||
.map(({ id, code, status, apiaryId }) => ({ id, code, status, apiaryId }))
|
||||
.sort(byCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* The apiaries to choose from: the server's active ones, or - only when the
|
||||
* network failed - those of the user's offline packs. Names only: nothing
|
||||
* else of an apiary (location!) is needed or kept.
|
||||
*/
|
||||
export function useApiaryChoices() {
|
||||
const userId = useCurrentUserId();
|
||||
return useQuery({
|
||||
queryKey: [...BULK_CHOICES_KEY, userId, "apiaries"],
|
||||
queryFn: (): Promise<Choices<BulkApiary>> =>
|
||||
withPackFallback<Choices<BulkApiary>>(
|
||||
async () => ({
|
||||
source: "server",
|
||||
items: (await fetchApiaries({ status: "ACTIVE" })).map(({ id, name }) => ({ id, name })),
|
||||
}),
|
||||
(packs) =>
|
||||
packs.length === 0
|
||||
? null
|
||||
: {
|
||||
source: "packs",
|
||||
items: packs
|
||||
.map(({ pack }) => ({ id: pack.apiary.id, name: pack.apiary.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "de")),
|
||||
},
|
||||
userId ?? "",
|
||||
),
|
||||
// Reads this device's storage when offline, never waits for the network.
|
||||
networkMode: "always",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The living colonies of one apiary - ended ones never appear. Without
|
||||
* network the apiary's offline pack answers; with no pack for it the
|
||||
* request fails and the caller says so.
|
||||
*/
|
||||
export function useApiaryColonies(apiaryId: string | null) {
|
||||
const userId = useCurrentUserId();
|
||||
return useQuery({
|
||||
queryKey: [...BULK_CHOICES_KEY, userId, "colonies", apiaryId],
|
||||
queryFn: (): Promise<Choices<BulkColony>> =>
|
||||
withPackFallback<Choices<BulkColony>>(
|
||||
async () => ({
|
||||
source: "server",
|
||||
items: livingOf(await fetchColonies({ apiaryId: apiaryId ?? undefined })),
|
||||
}),
|
||||
(packs) =>
|
||||
packs.some((stored) => stored.apiaryId === apiaryId)
|
||||
? {
|
||||
source: "packs",
|
||||
items: livingOf(
|
||||
livingColoniesFromPacks(packs).filter((colony) => colony.apiaryId === apiaryId),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
userId ?? "",
|
||||
),
|
||||
enabled: apiaryId !== null && apiaryId !== "",
|
||||
networkMode: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { refreshPacksAfterSync } from "@/lib/apiaries/offline-pack";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { BulkKind, queueBulk } from "@/lib/offline/db";
|
||||
import { CreateOptions } from "@/lib/offline/idempotency";
|
||||
import {
|
||||
CaptureMutation,
|
||||
CaptureOutcome,
|
||||
useCaptureMutation,
|
||||
} from "@/lib/offline/use-capture-mutation";
|
||||
import { invalidColonyIdsOf, isRetryLaterError } from "./api";
|
||||
|
||||
interface BulkCaptureOptions<I, R> {
|
||||
kind: BulkKind;
|
||||
create: (input: I, options: CreateOptions) => Promise<R>;
|
||||
failureMessage: string;
|
||||
onSaved: (outcome: CaptureOutcome<R>, input: I) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface BulkCapture<I> extends CaptureMutation<I> {
|
||||
/** Colonies the server refused (400): unknown or ended since the list was loaded. */
|
||||
invalidColonyIds: string[];
|
||||
/** 409 „wird bereits verarbeitet“: not an error, just save again in a moment. */
|
||||
isRetryLater: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a capture for several colonies: online as one request, offline as one
|
||||
* queued bulk entry. Every distinct selection gets its own idempotency key
|
||||
* (the server binds a key to the user and action, not to what was sent). A
|
||||
* refusal naming colonies reloads the packs of those colonies at once, like a
|
||||
* rejected queued entry does.
|
||||
*/
|
||||
export function useBulkCapture<I, R>({
|
||||
kind,
|
||||
create,
|
||||
failureMessage,
|
||||
onSaved,
|
||||
}: BulkCaptureOptions<I, R>): BulkCapture<I> {
|
||||
const userId = useCurrentUserId();
|
||||
const capture = useCaptureMutation<I, R>({
|
||||
create,
|
||||
queue: (input, idempotencyKey, ownerUserId) =>
|
||||
queueBulk(kind, input, idempotencyKey, ownerUserId),
|
||||
failureMessage,
|
||||
onSaved,
|
||||
keyPerSubmission: true,
|
||||
});
|
||||
const invalidColonyIds = invalidColonyIdsOf(capture.error);
|
||||
const invalidKey = invalidColonyIds.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (userId && invalidKey) {
|
||||
void refreshPacksAfterSync(userId, Date.now(), undefined, {
|
||||
force: true,
|
||||
colonyIds: invalidKey.split(","),
|
||||
});
|
||||
}
|
||||
}, [userId, invalidKey]);
|
||||
|
||||
return { ...capture, invalidColonyIds, isRetryLater: isRetryLaterError(capture.error) };
|
||||
}
|
||||
Reference in new issue
Block a user