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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

+131
View File
@@ -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);
});
});
+102
View File
@@ -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) };
}