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

@@ -0,0 +1,310 @@
import "fake-indexeddb/auto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { getDb } from "@/lib/offline/db";
import {
PACK_AUTO_REFRESH_AFTER_MS,
PACK_MAX_AGE_MS,
PACK_STALE_MS,
deleteAllPacks,
findPackForColony,
getPack,
isPackStale,
listPacks,
loadPack,
packAgeText,
purgeOldPacks,
refreshPacksAfterSync,
} from "./offline-pack";
import {
DAY_MS,
HOUR_MS,
MINUTE_MS,
offlinePack,
packColony,
storedPack,
} from "./offline-pack.fixtures";
import type { StoredPack } from "@/lib/offline/db";
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(),
}));
const NOW = Date.parse("2026-10-13T12:00:00.000Z");
function savedAgo(ms: number): string {
return new Date(NOW - ms).toISOString();
}
async function putPacks(...packs: StoredPack[]): Promise<void> {
const db = await getDb();
await Promise.all(packs.map((pack) => db.put("offline-packs", pack)));
}
async function storedApiaryIds(): Promise<string[]> {
const db = await getDb();
return (await db.getAllKeys("offline-packs")).sort();
}
describe("offline packs", () => {
beforeEach(async () => {
vi.mocked(apiFetch).mockReset();
await deleteAllPacks();
});
afterEach(() => {
vi.useRealTimers();
});
it("stores the pack with owner and time", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
const pack = offlinePack({ apiaryId: "apiary-1" });
vi.mocked(apiFetch).mockResolvedValue(pack);
const stored = await loadPack("apiary-1", "user-1");
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-1/offline-pack");
expect(stored).toEqual({
apiaryId: "apiary-1",
ownerUserId: "user-1",
savedAt: new Date(NOW).toISOString(),
pack,
});
expect(await getPack("apiary-1")).toEqual(stored);
});
it("never stores coordinates or an address, even if the server sent them", async () => {
const pack = offlinePack({ apiaryId: "apiary-1" });
vi.mocked(apiFetch).mockResolvedValue({
...pack,
apiary: { ...pack.apiary, latitude: 52.1, longitude: 13.4, address: "Feldweg 3", notes: "Schlüssel unterm Stein" },
});
await loadPack("apiary-1", "user-1");
const kept = await getPack("apiary-1");
expect(Object.keys(kept?.pack.apiary ?? {}).sort()).toEqual(["id", "location", "name", "status"]);
});
it("refuses a pack it does not understand instead of storing it", async () => {
vi.mocked(apiFetch).mockResolvedValue({ ...offlinePack(), version: 2 });
await expect(loadPack("apiary-1", "user-1")).rejects.toThrow();
expect(await getPack("apiary-1")).toBeNull();
});
it("encodes the apiary id in the request", async () => {
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "a/b" }));
await loadPack("a/b", "user-1");
expect(apiFetch).toHaveBeenCalledWith("/apiaries/a%2Fb/offline-pack");
});
it("findPackForColony", async () => {
await putPacks(
storedPack(offlinePack({ apiaryId: "apiary-1", colonies: [packColony("colony-1", "A1")] })),
storedPack(offlinePack({ apiaryId: "apiary-2", colonies: [packColony("colony-2", "B1")] })),
storedPack(offlinePack({ apiaryId: "apiary-3", colonies: [packColony("colony-3", "C1")] }), {
ownerUserId: "user-2",
}),
);
expect((await findPackForColony("colony-2", "user-1"))?.apiaryId).toBe("apiary-2");
// Another account's pack is never handed out.
expect(await findPackForColony("colony-3", "user-1")).toBeNull();
expect(await findPackForColony("colony-unknown", "user-1")).toBeNull();
});
it("findPackForColony prefers the newest pack when a colony moved between loaded apiaries", async () => {
await putPacks(
storedPack(offlinePack({ apiaryId: "apiary-old", colonies: [packColony("colony-1", "A1")] }), {
savedAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(),
}),
storedPack(offlinePack({ apiaryId: "apiary-new", colonies: [packColony("colony-1", "A1")] }), {
savedAt: new Date(Date.now() - HOUR_MS).toISOString(),
}),
);
expect((await findPackForColony("colony-1", "user-1"))?.apiaryId).toBe("apiary-new");
});
it("lists only the owner's packs", async () => {
await putPacks(
storedPack(offlinePack({ apiaryId: "apiary-1" })),
storedPack(offlinePack({ apiaryId: "apiary-2" }), { ownerUserId: "user-2" }),
);
expect((await listPacks("user-1")).map((pack) => pack.apiaryId)).toEqual(["apiary-1"]);
});
it("never hands out a pack older than 14 days, even before it is purged", async () => {
await putPacks(
storedPack(offlinePack({ apiaryId: "apiary-1" }), {
savedAt: new Date(Date.now() - PACK_MAX_AGE_MS - MINUTE_MS).toISOString(),
}),
);
expect(await listPacks("user-1")).toEqual([]);
expect(await getPack("apiary-1")).toBeNull();
});
it("purges packs older than 14 days", async () => {
await putPacks(
storedPack(offlinePack({ apiaryId: "apiary-old" }), { savedAt: savedAgo(15 * DAY_MS) }),
storedPack(offlinePack({ apiaryId: "apiary-edge" }), { savedAt: savedAgo(PACK_MAX_AGE_MS) }),
storedPack(offlinePack({ apiaryId: "apiary-young" }), { savedAt: savedAgo(13 * DAY_MS) }),
);
const purged = await purgeOldPacks(NOW);
expect(purged).toBe(1);
expect(await storedApiaryIds()).toEqual(["apiary-edge", "apiary-young"]);
});
it("stale after 24 h", () => {
const pack = offlinePack();
expect(isPackStale(storedPack(pack, { savedAt: savedAgo(PACK_STALE_MS - 1) }), NOW)).toBe(false);
expect(isPackStale(storedPack(pack, { savedAt: savedAgo(PACK_STALE_MS) }), NOW)).toBe(true);
expect(isPackStale(storedPack(pack, { savedAt: "kaputt" }), NOW)).toBe(true);
});
it("deletes every pack of every account", async () => {
await putPacks(
storedPack(offlinePack({ apiaryId: "apiary-1" })),
storedPack(offlinePack({ apiaryId: "apiary-2" }), { ownerUserId: "user-2" }),
);
await deleteAllPacks();
expect(await storedApiaryIds()).toEqual([]);
});
it("says how long ago a pack was loaded", () => {
const at = (ms: number) => packAgeText(savedAgo(ms), NOW);
expect(at(20 * 1000)).toBe("vor weniger als einer Minute");
expect(at(MINUTE_MS)).toBe("vor 1 Minute");
expect(at(5 * MINUTE_MS)).toBe("vor 5 Minuten");
expect(at(HOUR_MS)).toBe("vor 1 Stunde");
expect(at(3 * HOUR_MS + 10 * MINUTE_MS)).toBe("vor 3 Stunden");
expect(at(DAY_MS)).toBe("vor 1 Tag");
expect(at(2 * DAY_MS + HOUR_MS)).toBe("vor 2 Tagen");
});
describe("refreshPacksAfterSync", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
});
it("quietly reloads the owner's packs older than 1 h and leaves younger ones alone", async () => {
await putPacks(
storedPack(offlinePack({ apiaryId: "apiary-old" }), {
savedAt: savedAgo(PACK_AUTO_REFRESH_AFTER_MS + MINUTE_MS),
}),
storedPack(offlinePack({ apiaryId: "apiary-young" }), { savedAt: savedAgo(30 * MINUTE_MS) }),
storedPack(offlinePack({ apiaryId: "apiary-foreign" }), {
ownerUserId: "user-2",
savedAt: savedAgo(5 * HOUR_MS),
}),
);
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-old", apiaryName: "Neu" }));
await refreshPacksAfterSync("user-1", NOW);
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-old/offline-pack");
const refreshed = await getPack("apiary-old");
expect(refreshed?.pack.apiary.name).toBe("Neu");
expect(refreshed?.savedAt).toBe(new Date(NOW).toISOString());
});
it("after a rejection reloads a 10-min-old pack holding the colony, not a 2-min-old one", async () => {
await putPacks(
storedPack(
offlinePack({ apiaryId: "apiary-ten", colonies: [packColony("colony-3", "V-03")] }),
{ savedAt: savedAgo(10 * MINUTE_MS) },
),
storedPack(
offlinePack({ apiaryId: "apiary-two", colonies: [packColony("colony-3", "V-03")] }),
{ savedAt: savedAgo(2 * MINUTE_MS) },
),
storedPack(
offlinePack({ apiaryId: "apiary-other", colonies: [packColony("colony-9", "X-09")] }),
{ savedAt: savedAgo(10 * MINUTE_MS) },
),
);
vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-ten", colonies: [] }));
await refreshPacksAfterSync("user-1", NOW, undefined, {
force: true,
colonyIds: ["colony-3"],
});
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-ten/offline-pack");
// The colony ended on the server: the reloaded pack no longer offers it.
expect((await getPack("apiary-ten"))?.pack.colonies).toEqual([]);
});
it("drops a pack the user may no longer read or whose apiary is gone", async () => {
await putPacks(
storedPack(offlinePack({ apiaryId: "apiary-gone" }), { savedAt: savedAgo(2 * HOUR_MS) }),
storedPack(offlinePack({ apiaryId: "apiary-forbidden" }), { savedAt: savedAgo(2 * HOUR_MS) }),
storedPack(offlinePack({ apiaryId: "apiary-broken" }), { savedAt: savedAgo(2 * HOUR_MS) }),
);
vi.mocked(apiFetch).mockImplementation(async (path: string) => {
if (path.includes("apiary-gone")) throw new ApiError(404, "Apiary not found");
if (path.includes("apiary-forbidden")) throw new ApiError(403, "Forbidden");
throw new ApiError(500, "Internal server error");
});
await refreshPacksAfterSync("user-1", NOW);
// A server failure keeps the pack: it is still the best the device knows.
expect(await storedApiaryIds()).toEqual(["apiary-broken"]);
});
it("stops at the first network error and never throws", async () => {
await putPacks(
storedPack(offlinePack({ apiaryId: "apiary-1" }), { savedAt: savedAgo(2 * HOUR_MS) }),
storedPack(offlinePack({ apiaryId: "apiary-2" }), { savedAt: savedAgo(2 * HOUR_MS) }),
);
vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch"));
await expect(refreshPacksAfterSync("user-1", NOW)).resolves.toBeUndefined();
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(await storedApiaryIds()).toEqual(["apiary-1", "apiary-2"]);
});
it("stops once the sync run is cancelled", async () => {
await putPacks(
storedPack(offlinePack({ apiaryId: "apiary-1" }), { savedAt: savedAgo(2 * HOUR_MS) }),
storedPack(offlinePack({ apiaryId: "apiary-2" }), { savedAt: savedAgo(2 * HOUR_MS) }),
);
let cancelled = false;
vi.mocked(apiFetch).mockImplementation(async (path: string) => {
cancelled = true;
return offlinePack({ apiaryId: path.split("/")[2] });
});
await refreshPacksAfterSync("user-1", NOW, () => cancelled);
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("purges packs older than 14 days on the way", async () => {
await putPacks(storedPack(offlinePack({ apiaryId: "apiary-old" }), { savedAt: savedAgo(20 * DAY_MS) }));
await refreshPacksAfterSync("user-1", NOW);
expect(apiFetch).not.toHaveBeenCalled();
expect(await storedApiaryIds()).toEqual([]);
});
});
});