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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user