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

+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)
);
}