feat: platform backbone and field capture, first part (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

+380 -119
View File
@@ -1,4 +1,6 @@
import { openDB, DBSchema, IDBPDatabase } from "idb";
import type { ApiError } from "@/lib/auth/api-client";
import type { OfflinePackV1 } from "@/lib/apiaries/field-types";
import { CreateInspectionInput } from "@/lib/inspections/types";
import { CreateTreatmentInput } from "@/lib/treatments/types";
import { CreateFeedingInput } from "@/lib/feedings/types";
@@ -6,49 +8,139 @@ import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
import { CreateHarvestInput } from "@/lib/harvests/types";
type PendingStoreName =
| "pending-inspections"
| "pending-treatments"
| "pending-feedings"
| "pending-varroa-measurements"
| "pending-swarm-control-checks"
| "pending-harvests";
/** What each single-record queue (one per quick-capture form) holds. */
export interface RecordQueueInputs {
"pending-inspections": CreateInspectionInput;
"pending-treatments": CreateTreatmentInput;
"pending-feedings": CreateFeedingInput;
"pending-varroa-measurements": CreateVarroaMeasurementInput;
"pending-swarm-control-checks": CreateSwarmControlCheckInput;
"pending-harvests": CreateHarvestInput;
}
export type RecordStoreName = keyof RecordQueueInputs;
/** Every store whose entries wait to be sent and are shown in the queue sheet. */
export type QueueStoreName = RecordStoreName | "pending-bulk";
interface PendingEntry<T> {
/** The server's answer when it rejected a queued entry. */
export interface QueueError {
status: number;
message: string;
details?: unknown;
at: string;
}
export function toQueueError(error: ApiError): QueueError {
return {
status: error.status,
message: error.message,
details: error.body,
at: new Date().toISOString(),
};
}
export interface PendingEntry<T> {
id: number;
input: T;
queuedAt: string;
// Missing on entries queued before keys existed - those sync as before.
idempotencyKey?: string;
// Missing on entries queued before owners existed (DB v2) - those sync as
// before, under whoever is signed in.
ownerUserId?: string;
// Set when the server rejected the entry; it stays queued until it is sent
// or the user discards it.
lastError?: QueueError;
}
interface OfflineDB extends DBSchema {
"pending-inspections": { key: number; value: PendingEntry<CreateInspectionInput> };
"pending-treatments": { key: number; value: PendingEntry<CreateTreatmentInput> };
"pending-feedings": { key: number; value: PendingEntry<CreateFeedingInput> };
"pending-varroa-measurements": {
key: number;
value: PendingEntry<CreateVarroaMeasurementInput>;
export type BulkKind = "treatments" | "feedings" | "varroa";
export interface PendingBulkEntry {
id: number;
kind: BulkKind;
input: unknown;
idempotencyKey: string;
queuedAt: string;
ownerUserId: string;
lastError?: QueueError;
}
export interface StoredPhoto {
id: string;
sessionId: string;
ownerUserId: string;
blob: Blob;
fileName: string;
createdAt: string;
state: "draft" | "waiting-for-inspection" | "ready";
inspectionIdempotencyKey?: string;
inspectionId?: string;
uploadIdempotencyKey: string;
lastError?: QueueError;
}
export interface StoredPack {
apiaryId: string;
ownerUserId: string;
savedAt: string;
pack: OfflinePackV1;
}
type RecordStores = {
[Store in RecordStoreName]: { key: number; value: PendingEntry<RecordQueueInputs[Store]> };
};
export interface OfflineDB extends DBSchema, RecordStores {
"pending-bulk": { key: number; value: PendingBulkEntry };
"pending-photos": {
key: string;
value: StoredPhoto;
indexes: { sessionId: string; inspectionIdempotencyKey: string; ownerUserId: string };
};
"pending-swarm-control-checks": {
key: number;
value: PendingEntry<CreateSwarmControlCheckInput>;
};
"pending-harvests": { key: number; value: PendingEntry<CreateHarvestInput> };
"offline-packs": { key: string; value: StoredPack };
meta: { key: string; value: unknown };
}
const DB_NAME = "apicore-offline";
// v1 only had "pending-inspections". v2 adds the other five quick-capture
// types - existing users' already-queued inspections must survive the
// upgrade, so each store is only created if it doesn't exist yet (guarded by
// oldVersion, per idb's upgrade() convention), never recreated.
const DB_VERSION = 2;
// v1 only had "pending-inspections", v2 added the other five quick-capture
// types, v3 adds bulk entries, photos, offline packs and meta. Entries already
// queued on a phone must survive every upgrade, so each store is only created
// if it doesn't exist yet (guarded by oldVersion, per idb's upgrade()
// convention), never recreated.
const DB_VERSION = 3;
const RECORD_STORES: readonly RecordStoreName[] = [
"pending-inspections",
"pending-treatments",
"pending-feedings",
"pending-varroa-measurements",
"pending-swarm-control-checks",
"pending-harvests",
];
const QUEUE_STORES: readonly QueueStoreName[] = [...RECORD_STORES, "pending-bulk"];
const RECORD_LABELS: Record<RecordStoreName, string> = {
"pending-inspections": "Durchsicht",
"pending-treatments": "Behandlung",
"pending-feedings": "Fütterung",
"pending-varroa-measurements": "Varroamessung",
"pending-swarm-control-checks": "Schwarmkontrolle",
"pending-harvests": "Ernte",
};
const BULK_LABELS: Record<BulkKind, string> = {
treatments: "Behandlung für mehrere Völker",
feedings: "Fütterung für mehrere Völker",
varroa: "Varroamessung für mehrere Völker",
};
let dbPromise: Promise<IDBPDatabase<OfflineDB>> | null = null;
function getDb(): Promise<IDBPDatabase<OfflineDB>> {
/**
* The one shared connection to this device's offline database. Exported for
* the photo store (lib/photos/store.ts), which keeps its own records here.
*/
export function getDb(): Promise<IDBPDatabase<OfflineDB>> {
if (!dbPromise) {
dbPromise = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
const opening = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
db.createObjectStore("pending-inspections", { keyPath: "id", autoIncrement: true });
@@ -66,128 +158,297 @@ function getDb(): Promise<IDBPDatabase<OfflineDB>> {
});
db.createObjectStore("pending-harvests", { keyPath: "id", autoIncrement: true });
}
if (oldVersion < 3) {
db.createObjectStore("pending-bulk", { keyPath: "id", autoIncrement: true });
const photos = db.createObjectStore("pending-photos", { keyPath: "id" });
photos.createIndex("sessionId", "sessionId");
photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey");
photos.createIndex("ownerUserId", "ownerUserId");
db.createObjectStore("offline-packs", { keyPath: "apiaryId" });
db.createObjectStore("meta");
}
},
// A newer app version in another tab wants to upgrade: step aside
// instead of blocking it. The next call here opens the new version.
blocking() {
void opening.then((db) => db.close());
forget(opening);
},
terminated() {
forget(opening);
},
});
// A failed open (storage blocked, private mode) is retried next time.
opening.catch(() => forget(opening));
dbPromise = opening;
}
return dbPromise;
}
async function queue<T>(
store: PendingStoreName,
input: T,
idempotencyKey?: string,
function forget(connection: Promise<IDBPDatabase<OfflineDB>>): void {
if (dbPromise === connection) {
dbPromise = null;
}
}
async function queue<Store extends RecordStoreName>(
store: Store,
input: RecordQueueInputs[Store],
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
const db = await getDb();
await db.add(store, { input, idempotencyKey, queuedAt: new Date().toISOString() } as never);
const entry: Omit<PendingEntry<RecordQueueInputs[Store]>, "id"> = {
input,
idempotencyKey,
ownerUserId,
queuedAt: new Date().toISOString(),
};
await db.add(store, entry as never);
}
async function getPending<T>(store: PendingStoreName): Promise<PendingEntry<T>[]> {
export function queueInspection(
input: CreateInspectionInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-inspections", input, idempotencyKey, ownerUserId);
}
export function queueTreatment(
input: CreateTreatmentInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-treatments", input, idempotencyKey, ownerUserId);
}
export function queueFeeding(
input: CreateFeedingInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-feedings", input, idempotencyKey, ownerUserId);
}
export function queueVarroaMeasurement(
input: CreateVarroaMeasurementInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-varroa-measurements", input, idempotencyKey, ownerUserId);
}
export function queueSwarmControlCheck(
input: CreateSwarmControlCheckInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-swarm-control-checks", input, idempotencyKey, ownerUserId);
}
export function queueHarvest(
input: CreateHarvestInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-harvests", input, idempotencyKey, ownerUserId);
}
/** Every entry of one record queue, oldest first, of every owner. */
export async function getPendingEntries<Store extends RecordStoreName>(
store: Store,
): Promise<PendingEntry<RecordQueueInputs[Store]>[]> {
const db = await getDb();
return db.getAll(store) as Promise<PendingEntry<T>[]>;
return (await db.getAll(store)) as PendingEntry<RecordQueueInputs[Store]>[];
}
async function removePending(store: PendingStoreName, id: number): Promise<void> {
/** One entry as it is stored right now, or undefined once it is gone. */
export async function getPendingEntry<Store extends RecordStoreName>(
store: Store,
id: number,
): Promise<PendingEntry<RecordQueueInputs[Store]> | undefined> {
const db = await getDb();
return (await db.get(store, id)) as PendingEntry<RecordQueueInputs[Store]> | undefined;
}
export async function removeQueueEntry(store: QueueStoreName, id: number): Promise<void> {
const db = await getDb();
await db.delete(store, id);
}
async function countPending(store: PendingStoreName): Promise<number> {
/** Keeps a rejected entry queued and remembers why the server refused it. */
export async function markFailed(
store: QueueStoreName,
id: number,
error: QueueError,
): Promise<void> {
const db = await getDb();
return db.count(store);
const tx = db.transaction(store, "readwrite");
const entry = await tx.store.get(id);
if (entry) {
await tx.store.put({ ...entry, lastError: error });
}
await tx.done;
}
export type PendingInspection = PendingEntry<CreateInspectionInput>;
export function queueInspection(input: CreateInspectionInput, idempotencyKey?: string): Promise<void> {
return queue("pending-inspections", input, idempotencyKey);
}
export function getPendingInspections(): Promise<PendingInspection[]> {
return getPending("pending-inspections");
}
export function removePendingInspection(id: number): Promise<void> {
return removePending("pending-inspections", id);
}
export function countPendingInspections(): Promise<number> {
return countPending("pending-inspections");
/**
* Entries from before owners existed (DB v2) count as the signed-in user's
* own. Anything queued under another account is never sent by this one.
*/
export function isForeignEntry(entry: { ownerUserId?: string }, currentUserId: string): boolean {
return entry.ownerUserId !== undefined && entry.ownerUserId !== currentUserId;
}
export type PendingTreatment = PendingEntry<CreateTreatmentInput>;
export function queueTreatment(input: CreateTreatmentInput, idempotencyKey?: string): Promise<void> {
return queue("pending-treatments", input, idempotencyKey);
}
export function getPendingTreatments(): Promise<PendingTreatment[]> {
return getPending("pending-treatments");
}
export function removePendingTreatment(id: number): Promise<void> {
return removePending("pending-treatments", id);
}
export function countPendingTreatments(): Promise<number> {
return countPending("pending-treatments");
type QueuedValue = PendingEntry<unknown> | PendingBulkEntry;
interface QueuedRecord {
store: QueueStoreName;
entry: QueuedValue;
}
export type PendingFeeding = PendingEntry<CreateFeedingInput>;
export function queueFeeding(input: CreateFeedingInput, idempotencyKey?: string): Promise<void> {
return queue("pending-feedings", input, idempotencyKey);
}
export function getPendingFeedings(): Promise<PendingFeeding[]> {
return getPending("pending-feedings");
}
export function removePendingFeeding(id: number): Promise<void> {
return removePending("pending-feedings", id);
}
export function countPendingFeedings(): Promise<number> {
return countPending("pending-feedings");
async function readQueue(): Promise<QueuedRecord[]> {
const db = await getDb();
const tx = db.transaction([...QUEUE_STORES], "readonly");
const perStore = await Promise.all(
QUEUE_STORES.map(async (store) => {
const entries: QueuedValue[] = await tx.objectStore(store).getAll();
return entries.map((entry) => ({ store, entry }));
}),
);
await tx.done;
return perStore.flat();
}
export type PendingVarroaMeasurement = PendingEntry<CreateVarroaMeasurementInput>;
export function queueVarroaMeasurement(input: CreateVarroaMeasurementInput, idempotencyKey?: string): Promise<void> {
return queue("pending-varroa-measurements", input, idempotencyKey);
}
export function getPendingVarroaMeasurements(): Promise<PendingVarroaMeasurement[]> {
return getPending("pending-varroa-measurements");
}
export function removePendingVarroaMeasurement(id: number): Promise<void> {
return removePending("pending-varroa-measurements", id);
}
export function countPendingVarroaMeasurements(): Promise<number> {
return countPending("pending-varroa-measurements");
export interface QueueSummary {
/** The user's entries still waiting to be sent. */
own: number;
/** The user's entries the server rejected. */
failed: number;
/** Entries of another account on this device - never sent from here. */
foreign: number;
}
export type PendingSwarmControlCheck = PendingEntry<CreateSwarmControlCheckInput>;
export function queueSwarmControlCheck(input: CreateSwarmControlCheckInput, idempotencyKey?: string): Promise<void> {
return queue("pending-swarm-control-checks", input, idempotencyKey);
}
export function getPendingSwarmControlChecks(): Promise<PendingSwarmControlCheck[]> {
return getPending("pending-swarm-control-checks");
}
export function removePendingSwarmControlCheck(id: number): Promise<void> {
return removePending("pending-swarm-control-checks", id);
}
export function countPendingSwarmControlChecks(): Promise<number> {
return countPending("pending-swarm-control-checks");
/**
* A photo whose inspection is on the server waits on its own and counts like
* an entry. Drafts (form not saved yet) and photos still waiting for their
* queued inspection are not counted: they ride along with that inspection.
*/
async function readPhotosWaitingOnTheirOwn(): Promise<StoredPhoto[]> {
const db = await getDb();
const photos = await db.getAll("pending-photos");
return photos.filter((photo) => photo.state === "ready");
}
export type PendingHarvest = PendingEntry<CreateHarvestInput>;
export function queueHarvest(input: CreateHarvestInput, idempotencyKey?: string): Promise<void> {
return queue("pending-harvests", input, idempotencyKey);
}
export function getPendingHarvests(): Promise<PendingHarvest[]> {
return getPending("pending-harvests");
}
export function removePendingHarvest(id: number): Promise<void> {
return removePending("pending-harvests", id);
}
export function countPendingHarvests(): Promise<number> {
return countPending("pending-harvests");
export async function summarizeQueue(currentUserId: string): Promise<QueueSummary> {
const waiting: { ownerUserId?: string; lastError?: QueueError }[] = [
...(await readQueue()).map(({ entry }) => entry),
...(await readPhotosWaitingOnTheirOwn()),
];
return waiting.reduce<QueueSummary>(
(summary, entry) => {
if (isForeignEntry(entry, currentUserId)) {
return { ...summary, foreign: summary.foreign + 1 };
}
if (entry.lastError) {
return { ...summary, failed: summary.failed + 1 };
}
return { ...summary, own: summary.own + 1 };
},
{ own: 0, failed: 0, foreign: 0 },
);
}
export async function countAllPending(): Promise<number> {
const counts = await Promise.all([
countPendingInspections(),
countPendingTreatments(),
countPendingFeedings(),
countPendingVarroaMeasurements(),
countPendingSwarmControlChecks(),
countPendingHarvests(),
]);
return counts.reduce((sum, count) => sum + count, 0);
export interface QueueItem {
store: string;
id: number | string;
label: string;
queuedAt: string;
lastError?: QueueError;
photoCount?: number;
}
function labelOf({ store, entry }: QueuedRecord): string {
if (store === "pending-bulk") {
return BULK_LABELS[(entry as PendingBulkEntry).kind];
}
return RECORD_LABELS[store];
}
/** The signed-in user's waiting and rejected entries, oldest first. */
export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
const db = await getDb();
const own = (await readQueue()).filter(({ entry }) => !isForeignEntry(entry, currentUserId));
const items = await Promise.all(
own.map(async (record): Promise<QueueItem> => {
const { store, entry } = record;
const item: QueueItem = {
store,
id: entry.id,
label: labelOf(record),
queuedAt: entry.queuedAt,
...(entry.lastError ? { lastError: entry.lastError } : {}),
};
if (store !== "pending-inspections" || !entry.idempotencyKey) {
return item;
}
const photoCount = await db.countFromIndex(
"pending-photos",
"inspectionIdempotencyKey",
entry.idempotencyKey,
);
return photoCount > 0 ? { ...item, photoCount } : item;
}),
);
return items.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
}
function asQueueStore(store: string): QueueStoreName {
const known = QUEUE_STORES.find((name) => name === store);
if (!known) {
throw new Error(`Unknown offline queue: ${store}`);
}
return known;
}
/**
* Deletes one queued entry without sending it. A queued inspection takes its
* photos with it - they must never be uploaded to another record.
*/
export async function discardQueueItem(item: QueueItem): Promise<void> {
const store = asQueueStore(item.store);
if (typeof item.id !== "number") {
throw new Error(`Invalid queue entry id: ${String(item.id)}`);
}
const db = await getDb();
const tx = db.transaction([store, "pending-photos"], "readwrite");
const entry = await tx.objectStore(store).get(item.id);
if (store === "pending-inspections" && entry?.idempotencyKey) {
const photos = tx.objectStore("pending-photos");
const photoIds = await photos
.index("inspectionIdempotencyKey")
.getAllKeys(entry.idempotencyKey);
await Promise.all(photoIds.map((photoId) => photos.delete(photoId)));
}
await tx.objectStore(store).delete(item.id);
await tx.done;
}
/** Deletes everything one account queued on this device, photos included. */
export async function clearQueueOf(ownerUserId: string): Promise<void> {
const db = await getDb();
const tx = db.transaction([...QUEUE_STORES, "pending-photos"], "readwrite");
const queues = QUEUE_STORES.map(async (store) => {
const objectStore = tx.objectStore(store);
const entries: QueuedValue[] = await objectStore.getAll();
await Promise.all(
entries
.filter((entry) => entry.ownerUserId === ownerUserId)
.map((entry) => objectStore.delete(entry.id)),
);
});
const photos = tx.objectStore("pending-photos");
const photoIds = await photos.index("ownerUserId").getAllKeys(ownerUserId);
await Promise.all([...queues, ...photoIds.map((photoId) => photos.delete(photoId))]);
await tx.done;
}