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

+104 -7
View File
@@ -7,6 +7,11 @@ import { CreateFeedingInput } from "@/lib/feedings/types";
import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
import { CreateHarvestInput } from "@/lib/harvests/types";
import {
colonyIdsOfBulkInput,
invalidColonyIdsOfBody,
withoutColonies,
} from "@/lib/bulk/bulk-input";
/** What each single-record queue (one per quick-capture form) holds. */
export interface RecordQueueInputs {
@@ -191,6 +196,15 @@ function forget(connection: Promise<IDBPDatabase<OfflineDB>>): void {
}
}
/** A new queue entry; the store gives it its id when it is added. */
export function newPendingEntry<T>(
input: T,
idempotencyKey: string,
ownerUserId: string,
): Omit<PendingEntry<T>, "id"> {
return { input, idempotencyKey, ownerUserId, queuedAt: new Date().toISOString() };
}
async function queue<Store extends RecordStoreName>(
store: Store,
input: RecordQueueInputs[Store],
@@ -198,13 +212,7 @@ async function queue<Store extends RecordStoreName>(
ownerUserId: string,
): Promise<void> {
const db = await getDb();
const entry: Omit<PendingEntry<RecordQueueInputs[Store]>, "id"> = {
input,
idempotencyKey,
ownerUserId,
queuedAt: new Date().toISOString(),
};
await db.add(store, entry as never);
await db.add(store, newPendingEntry(input, idempotencyKey, ownerUserId) as never);
}
export function queueInspection(
@@ -255,6 +263,27 @@ export function queueHarvest(
return queue("pending-harvests", input, idempotencyKey, ownerUserId);
}
/**
* One entry for several colonies. The key belongs to this exact selection of
* colonies: it is stored with the entry and sent again unchanged.
*/
export async function queueBulk(
kind: BulkKind,
input: unknown,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
const db = await getDb();
const entry: Omit<PendingBulkEntry, "id"> = {
kind,
input,
idempotencyKey,
ownerUserId,
queuedAt: new Date().toISOString(),
};
await db.add("pending-bulk", entry as PendingBulkEntry);
}
/** Every entry of one record queue, oldest first, of every owner. */
export async function getPendingEntries<Store extends RecordStoreName>(
store: Store,
@@ -272,6 +301,18 @@ export async function getPendingEntry<Store extends RecordStoreName>(
return (await db.get(store, id)) as PendingEntry<RecordQueueInputs[Store]> | undefined;
}
/** Every bulk entry, oldest first, of every owner. */
export async function getBulkEntries(): Promise<PendingBulkEntry[]> {
const db = await getDb();
return db.getAll("pending-bulk");
}
/** One bulk entry as it is stored right now, or undefined once it is gone. */
export async function getBulkEntry(id: number): Promise<PendingBulkEntry | undefined> {
const db = await getDb();
return db.get("pending-bulk", id);
}
export async function removeQueueEntry(store: QueueStoreName, id: number): Promise<void> {
const db = await getDb();
await db.delete(store, id);
@@ -300,6 +341,32 @@ export function isForeignEntry(entry: { ownerUserId?: string }, currentUserId: s
return entry.ownerUserId !== undefined && entry.ownerUserId !== currentUserId;
}
/**
* „Ohne diese Völker erneut senden“: takes the colonies out of a queued bulk
* entry and clears its rejection, so the next sync run sends the rest. The
* key stays: the server stored nothing for the rejected try and released it.
* An entry left without any colony is removed.
*/
export async function retryBulkWithout(
entryId: number,
colonyIds: readonly string[],
): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-bulk", "readwrite");
const entry = await tx.store.get(entryId);
if (entry) {
const input = withoutColonies(entry.kind, entry.input, colonyIds);
if (colonyIdsOfBulkInput(entry.kind, input).length === 0) {
await tx.store.delete(entryId);
} else {
const cleared: PendingBulkEntry = { ...entry, input };
delete cleared.lastError;
await tx.store.put(cleared);
}
}
await tx.done;
}
type QueuedValue = PendingEntry<unknown> | PendingBulkEntry;
interface QueuedRecord {
@@ -366,6 +433,11 @@ export interface QueueItem {
queuedAt: string;
lastError?: QueueError;
photoCount?: number;
/**
* A rejected bulk entry that can go out without the colonies the server
* refused (`codes`: the ones named in the loaded packs, maybe fewer).
*/
resendWithout?: { colonyIds: string[]; codes: string[] };
}
function labelOf({ store, entry }: QueuedRecord): string {
@@ -375,6 +447,27 @@ function labelOf({ store, entry }: QueuedRecord): string {
return RECORD_LABELS[store];
}
async function resendWithoutOf(
db: IDBPDatabase<OfflineDB>,
entry: PendingBulkEntry,
): Promise<QueueItem["resendWithout"]> {
const all = colonyIdsOfBulkInput(entry.kind, entry.input);
const rejected = invalidColonyIdsOfBody(entry.lastError?.details).filter((id) =>
all.includes(id),
);
if (rejected.length === 0 || rejected.length === all.length) {
return undefined;
}
const packs = await db.getAll("offline-packs");
const codeOf = new Map(
packs.flatMap((stored) => stored.pack.colonies.map((colony) => [colony.id, colony.code])),
);
return {
colonyIds: rejected,
codes: rejected.flatMap((id) => codeOf.get(id) ?? []),
};
}
/** The signed-in user's waiting and rejected entries, oldest first. */
export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
const db = await getDb();
@@ -389,6 +482,10 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
queuedAt: entry.queuedAt,
...(entry.lastError ? { lastError: entry.lastError } : {}),
};
if (store === "pending-bulk") {
const resendWithout = await resendWithoutOf(db, entry as PendingBulkEntry);
return resendWithout ? { ...item, resendWithout } : item;
}
if (store !== "pending-inspections" || !entry.idempotencyKey) {
return item;
}