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,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearAllFormDrafts } from "@/lib/forms/draft-storage";
import {
HANDOVER_MAX_AGE_MS,
forgetHandOver,
forgetHandOversExcept,
isHandOverExpired,
readHandOverRecords,
readHandOvers,
rememberHandOver,
} from "./handover";
const OWN = { userId: "user-1", sessionId: "session-1", inspectionId: "srv-1" };
const OTHER = { userId: "user-2", sessionId: "session-2", inspectionId: "srv-2" };
describe("photo hand-overs", () => {
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("remembers an unfinished hand-over until it is forgotten", () => {
rememberHandOver(OWN);
rememberHandOver(OTHER);
expect(readHandOvers()).toHaveLength(2);
expect(readHandOvers()).toEqual(expect.arrayContaining([OWN, OTHER]));
forgetHandOver("session-1");
expect(readHandOvers()).toEqual([OTHER]);
});
it("outlives a logout: the photos still belong to that user's saved inspection", () => {
rememberHandOver(OWN);
clearAllFormDrafts();
expect(readHandOvers()).toEqual([OWN]);
});
it("skips what it cannot read and never throws without storage", () => {
window.localStorage.setItem("apicore:photo-handover:broken", "{not json");
window.localStorage.setItem(
"apicore:photo-handover:partial",
JSON.stringify({ userId: "user-1", sessionId: "partial" }),
);
expect(readHandOvers()).toEqual([]);
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError");
});
expect(() => rememberHandOver(OWN)).not.toThrow();
});
it("notes when a hand-over began and lets it expire after the draft lifetime", () => {
rememberHandOver(OWN, 1_000);
const [record] = readHandOverRecords();
expect(record).toEqual({ ...OWN, createdAt: 1_000 });
expect(isHandOverExpired(record, 1_000 + HANDOVER_MAX_AGE_MS)).toBe(false);
expect(isHandOverExpired(record, 1_000 + HANDOVER_MAX_AGE_MS + 1)).toBe(true);
});
it("treats a hand-over without a date as expired", () => {
window.localStorage.setItem("apicore:photo-handover:old", JSON.stringify({ ...OWN, sessionId: "old" }));
const [record] = readHandOverRecords();
expect(record.createdAt).toBe(0);
expect(isHandOverExpired(record, Date.now())).toBe(true);
});
it("removes the hand-overs of every other user, or all of them", () => {
rememberHandOver(OWN);
rememberHandOver(OTHER);
forgetHandOversExcept("user-1");
expect(readHandOvers()).toEqual([OWN]);
forgetHandOversExcept(null);
expect(readHandOvers()).toEqual([]);
});
});
+113
View File
@@ -0,0 +1,113 @@
/*
* An online save whose photos could not be tied to the saved inspection at
* once (the device's storage failed for a moment) leaves a hand-over here.
* It lives outside IndexedDB on purpose: it must survive the very storage
* failure it is about. The photo store finishes it at the next start or sync
* of the same user and keeps its photos from being purged meanwhile.
*/
import { DRAFT_MAX_AGE_MS } from "@/lib/forms/draft-storage";
const PREFIX = "apicore:photo-handover:";
/**
* A hand-over that is still open after this long is given up: its photos are
* then purged like any other draft, and the note goes with them.
*/
export const HANDOVER_MAX_AGE_MS = DRAFT_MAX_AGE_MS;
export interface PhotoHandOver {
/** The signed-in user who saved: only their own sync finishes it. */
userId: string;
sessionId: string;
/** The saved inspection's server id. */
inspectionId: string;
}
/** A hand-over as noted on this device; `createdAt` is epoch milliseconds. */
export interface HandOverRecord extends PhotoHandOver {
createdAt: number;
}
function isHandOver(value: unknown): value is PhotoHandOver {
const candidate = value as Partial<PhotoHandOver> | null;
return (
typeof candidate?.userId === "string" &&
typeof candidate.sessionId === "string" &&
typeof candidate.inspectionId === "string"
);
}
function parse(raw: string | null): HandOverRecord[] {
try {
const value: unknown = JSON.parse(raw ?? "null");
if (!isHandOver(value)) {
return [];
}
const { userId, sessionId, inspectionId } = value;
const createdAt = (value as { createdAt?: unknown }).createdAt;
// A note without a date comes from before dates existed: long expired.
return [{ userId, sessionId, inspectionId, createdAt: typeof createdAt === "number" ? createdAt : 0 }];
} catch {
return [];
}
}
export function rememberHandOver(handOver: PhotoHandOver, now: number = Date.now()): void {
try {
const { userId, sessionId, inspectionId } = handOver;
window.localStorage.setItem(
PREFIX + sessionId,
JSON.stringify({ userId, sessionId, inspectionId, createdAt: now }),
);
} catch {
// Storage full or blocked: the hand-over itself is still tried.
}
}
export function forgetHandOver(sessionId: string): void {
try {
window.localStorage.removeItem(PREFIX + sessionId);
} catch {
// Storage unavailable - nothing stored either.
}
}
export function isHandOverExpired(handOver: { createdAt?: number }, now: number): boolean {
return typeof handOver.createdAt === "number" && now - handOver.createdAt > HANDOVER_MAX_AGE_MS;
}
/** Every unfinished hand-over on this device, of every user, with its date. */
export function readHandOverRecords(): HandOverRecord[] {
try {
const handOvers: HandOverRecord[] = [];
for (let index = 0; index < window.localStorage.length; index++) {
const storageKey = window.localStorage.key(index);
if (storageKey?.startsWith(PREFIX)) {
handOvers.push(...parse(window.localStorage.getItem(storageKey)));
}
}
return handOvers;
} catch {
return [];
}
}
/** Every unfinished hand-over on this device, of every user. */
export function readHandOvers(): PhotoHandOver[] {
return readHandOverRecords().map(({ userId, sessionId, inspectionId }) => ({
userId,
sessionId,
inspectionId,
}));
}
/**
* Sign-out and user switch: a hand-over belongs to the user who saved, and
* their photos are not handed over for anyone else. `null` removes all.
*/
export function forgetHandOversExcept(userId: string | null): void {
readHandOverRecords()
.filter((handOver) => handOver.userId !== userId)
.forEach((handOver) => forgetHandOver(handOver.sessionId));
}
@@ -1,12 +1,13 @@
// @vitest-environment node
// Node's Blob: its bytes can be read back (jsdom's Blob cannot be cloned).
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
PHOTO_FALLBACK_QUALITY,
PHOTO_JPEG_QUALITY,
PHOTO_MAX_EDGE_PX,
PhotoMetadataError,
PhotoTooLargeError,
canvasImageEncoder,
containsExifOrGps,
preparePhoto,
type ImageEncoder,
@@ -119,3 +120,57 @@ describe("preparePhoto", () => {
await expect(preparePhoto(original, encoder)).rejects.toBeInstanceOf(PhotoMetadataError);
});
});
describe("canvasImageEncoder", () => {
const camera = new Blob([jpeg(EXIF_APP1, QUANTIZATION, SCAN)], { type: "image/jpeg" });
/** A decoded 4000 × 3000 photo and a canvas that writes CLEAN_JPEG. */
function stubBrowser(createImageBitmap: ReturnType<typeof vi.fn>) {
const canvas = {
width: 0,
height: 0,
getContext: () => ({ fillStyle: "", fillRect: vi.fn(), drawImage: vi.fn() }),
toBlob: (done: (blob: Blob) => void) => done(new Blob([CLEAN_JPEG], { type: "image/jpeg" })),
};
vi.stubGlobal("createImageBitmap", createImageBitmap);
vi.stubGlobal("document", { createElement: () => canvas });
return canvas;
}
function bitmap() {
return { width: 4000, height: 3000, close: vi.fn() };
}
afterEach(() => {
vi.unstubAllGlobals();
});
it("decodes again without options when the engine rejects imageOrientation", async () => {
const decoded = bitmap();
// Older engines know no "from-image" and throw a TypeError for it.
const decode = vi
.fn()
.mockRejectedValueOnce(new TypeError("Failed to read the 'imageOrientation' property"))
.mockResolvedValueOnce(decoded);
const canvas = stubBrowser(decode);
const encoded = await canvasImageEncoder.encode(camera, PHOTO_MAX_EDGE_PX, PHOTO_JPEG_QUALITY);
expect(decode).toHaveBeenNthCalledWith(1, camera, { imageOrientation: "from-image" });
expect(decode).toHaveBeenNthCalledWith(2, camera);
expect(new Uint8Array(await encoded.arrayBuffer())).toEqual(CLEAN_JPEG);
expect([canvas.width, canvas.height]).toEqual([2560, 1920]);
expect(decoded.close).toHaveBeenCalled();
});
it("does not retry when the image itself cannot be decoded", async () => {
const broken = new Error("The source image could not be decoded.");
const decode = vi.fn().mockRejectedValue(broken);
stubBrowser(decode);
await expect(
canvasImageEncoder.encode(camera, PHOTO_MAX_EDGE_PX, PHOTO_JPEG_QUALITY),
).rejects.toBe(broken);
expect(decode).toHaveBeenCalledTimes(1);
});
});
+19 -4
View File
@@ -26,16 +26,31 @@ export class PhotoMetadataError extends Error {
}
}
/**
* Decodes the photo with the camera's rotation applied to the pixels, so it
* still stands upright once the orientation tag is gone. Older engines know
* no `imageOrientation: "from-image"` and throw a TypeError for the option:
* they decode it without the option instead of failing every photo.
*/
async function decodeUpright(file: Blob): Promise<ImageBitmap> {
try {
return await createImageBitmap(file, { imageOrientation: "from-image" });
} catch (error) {
if (!(error instanceof TypeError)) {
throw error;
}
return createImageBitmap(file);
}
}
/**
* Draws the image onto a canvas and writes a new JPEG. A canvas holds pixels
* only, so nothing of the camera's EXIF (GPS position of the apiary, device,
* time) or XMP can reach the output. The camera's rotation is applied to the
* pixels first (`imageOrientation: "from-image"`), so the photo still stands
* upright once the orientation tag is gone.
* time) or XMP can reach the output.
*/
export const canvasImageEncoder: ImageEncoder = {
async encode(file, maxEdgePx, quality) {
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
const bitmap = await decodeUpright(file);
try {
const scale = Math.min(1, maxEdgePx / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
+214 -7
View File
@@ -3,6 +3,7 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { afterEach, beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
import { readHandOvers, rememberHandOver } from "./handover";
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
@@ -45,6 +46,21 @@ function uploadMock(): Upload {
return vi.fn() as Upload;
}
/** localStorage for the node environment (hand-overs live there). */
function memoryStorage(): Storage {
const items = new Map<string, string>();
return {
get length() {
return items.size;
},
key: (index: number) => [...items.keys()][index] ?? null,
getItem: (key: string) => items.get(key) ?? null,
setItem: (key: string, value: string) => void items.set(key, String(value)),
removeItem: (key: string) => void items.delete(key),
clear: () => items.clear(),
};
}
describe("photo store", () => {
beforeEach(async () => {
// Fresh database and modules: no open connection survives from the last test.
@@ -62,6 +78,7 @@ describe("photo store", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("draft → waiting → ready after the queued inspection is created", async () => {
@@ -254,16 +271,148 @@ describe("photo store", () => {
expect(photo.inspectionId).toBeUndefined();
});
it("gives the photos back to the form when the inspection could not be queued", async () => {
it("queues the inspection and ties the form's photos to it in one step", async () => {
await takePhoto();
await store.attachSessionToQueuedInspection("session-1", "key-a");
await takePhoto();
// Something the device's storage refuses to keep, e.g. when it is full.
const unstorable = { ...INSPECTION, notes: () => "kein Wert" } as never;
await store.releaseSessionPhotos("session-1");
await expect(
store.queueInspectionWithPhotos("session-1", unstorable, "key-a", "user-1"),
).rejects.toThrow();
// Nothing was queued, so the photos still belong to the open form.
expect(await db.listQueue("user-1")).toEqual([]);
const kept = await store.listSessionPhotos("session-1");
expect(kept.map((photo) => [photo.state, photo.inspectionIdempotencyKey])).toEqual([
["draft", undefined],
["draft", undefined],
]);
expect(await store.queueInspectionWithPhotos("session-1", INSPECTION, "key-a", "user-1")).toBe(
2,
);
expect(await db.getPendingEntries("pending-inspections")).toEqual([
expect.objectContaining({ input: INSPECTION, idempotencyKey: "key-a", ownerUserId: "user-1" }),
]);
const [queued] = await db.listQueue("user-1");
expect(queued.photoCount).toBe(2);
const waiting = await store.listSessionPhotos("session-1");
expect(waiting.map((photo) => [photo.state, photo.inspectionIdempotencyKey])).toEqual([
["waiting-for-inspection", "key-a"],
["waiting-for-inspection", "key-a"],
]);
});
it("tells how many photos it handed to the saved inspection", async () => {
expect(await store.attachSessionToInspection("session-1", "srv-1")).toBe(0);
await takePhoto();
await takePhoto();
expect(await store.attachSessionToInspection("session-1", "srv-1")).toBe(2);
// Already handed over: nothing moves twice.
expect(await store.attachSessionToInspection("session-1", "srv-1")).toBe(0);
});
it("moves a fresh form's photos into a restored draft up to the limit and discards the rest", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-12T08:00:00.000Z"));
for (let count = 0; count < 3; count++) await takePhoto("restored");
vi.setSystemTime(new Date("2026-10-13T08:00:00.000Z"));
const fresh = [await takePhoto("fresh"), await takePhoto("fresh"), await takePhoto("fresh")];
const result = await store.moveDraftPhotos("fresh", "restored");
expect(result).toEqual({ moved: 2, discarded: 1 });
const restored = await store.listSessionPhotos("restored");
expect(restored.map((photo) => photo.fileName)).toEqual([
"durchsicht-2026-10-13-1.jpg",
"durchsicht-2026-10-13-2.jpg",
"durchsicht-2026-10-13-3.jpg",
"durchsicht-2026-10-13-4.jpg",
"durchsicht-2026-10-13-5.jpg",
]);
expect(restored.slice(3).map((photo) => photo.id)).toEqual([fresh[0].id, fresh[1].id]);
expect(restored.every((photo) => photo.state === "draft")).toBe(true);
// The one that did not fit is deleted openly (the form says so), not left unreachable.
expect(await store.listSessionPhotos("fresh")).toEqual([]);
});
it("hands the photos to the saved inspection under a hand-over that ends once they belong to it", async () => {
vi.stubGlobal("window", { localStorage: memoryStorage() });
await takePhoto("session-1");
await takePhoto("session-2");
const handOver = { userId: "user-1", sessionId: "session-1", inspectionId: "srv-1" };
expect(await store.handOverSessionPhotos(handOver)).toBe(1);
expect(readHandOvers()).toEqual([]);
const [photo] = await store.listSessionPhotos("session-1");
expect(photo.state).toBe("draft");
expect(photo.inspectionIdempotencyKey).toBeUndefined();
expect((await db.listQueue("user-1")).length).toBe(0);
expect(photo).toMatchObject({ state: "ready", inspectionId: "srv-1" });
// The device's storage fails for a moment: the hand-over stays for later.
const failing = vi.fn().mockRejectedValue(new Error("The database connection is closing."));
const second = { userId: "user-1", sessionId: "session-2", inspectionId: "srv-2" };
await expect(store.handOverSessionPhotos(second, failing)).rejects.toThrow();
expect(readHandOvers()).toEqual([second]);
});
it("finishes only the user's own open hand-overs", async () => {
vi.stubGlobal("window", { localStorage: memoryStorage() });
await takePhoto("session-1");
await takePhoto("session-2", "user-2");
const own = { userId: "user-1", sessionId: "session-1", inspectionId: "srv-1" };
const foreign = { userId: "user-2", sessionId: "session-2", inspectionId: "srv-2" };
rememberHandOver(own);
rememberHandOver(foreign);
expect(await store.finishHandOvers("user-1")).toBe(1);
const [ownPhoto] = await store.listSessionPhotos("session-1");
expect(ownPhoto).toMatchObject({ state: "ready", inspectionId: "srv-1" });
const [foreignPhoto] = await store.listSessionPhotos("session-2");
expect(foreignPhoto.state).toBe("draft");
expect(readHandOvers()).toEqual([foreign]);
});
it("keeps the photos of a hand-over that is not finished yet, however old they are", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-01T08:00:00.000Z"));
await takePhoto("handed-over");
await takePhoto("abandoned");
const handOvers = () => [{ userId: "user-1", sessionId: "handed-over", inspectionId: "srv-1" }];
const purged = await store.purgeStaleDraftPhotos(
Date.parse("2026-10-05T08:00:00.000Z"),
() => [],
handOvers,
);
expect(purged).toBe(1);
expect(await store.listSessionPhotos("handed-over")).toHaveLength(1);
expect(await store.listSessionPhotos("abandoned")).toEqual([]);
});
it("removes only unsaved photos; the queue sheet discards only photos waiting on their own", async () => {
const draft = await takePhoto("session-1");
const ready = await takePhoto("session-2");
await store.attachSessionToInspection("session-2", "srv-2");
const waiting = await takePhoto("session-3");
await store.attachSessionToQueuedInspection("session-3", "key-3");
await store.removePhoto(ready.id);
await store.removePhoto(waiting.id);
await store.removePhoto(draft.id);
expect(await store.listSessionPhotos("session-1")).toEqual([]);
expect(await store.listSessionPhotos("session-2")).toHaveLength(1);
expect(await store.listSessionPhotos("session-3")).toHaveLength(1);
await store.discardReadyPhoto(waiting.id);
await store.discardReadyPhoto(ready.id);
expect(await store.listSessionPhotos("session-3")).toHaveLength(1);
expect(await store.listSessionPhotos("session-2")).toEqual([]);
});
it("discarding a form draft removes its unsaved photos only", async () => {
@@ -289,7 +438,10 @@ describe("photo store", () => {
vi.setSystemTime(new Date("2026-10-03T08:00:00.000Z"));
await takePhoto("recent-draft");
const purged = await store.purgeStaleDraftPhotos(Date.parse("2026-10-04T08:00:01.000Z"));
const purged = await store.purgeStaleDraftPhotos(
Date.parse("2026-10-04T08:00:01.000Z"),
() => [],
);
expect(purged).toBe(1);
expect(await store.listSessionPhotos("old-draft")).toEqual([]);
@@ -297,6 +449,23 @@ describe("photo store", () => {
expect(await store.listSessionPhotos("recent-draft")).toHaveLength(1);
});
it("keeps the photos of a form draft that can still be restored, however old they are", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-01T08:00:00.000Z"));
await takePhoto("restorable");
await takePhoto("abandoned");
const now = Date.parse("2026-10-05T08:00:00.000Z");
// The draft was saved again yesterday: it is offered for 3 more days.
const readDrafts = vi.fn(() => [{ photoSessionId: "restorable", colonyStrength: "8" }, null]);
const purged = await store.purgeStaleDraftPhotos(now, readDrafts);
expect(readDrafts).toHaveBeenCalledWith(now);
expect(purged).toBe(1);
expect(await store.listSessionPhotos("restorable")).toHaveLength(1);
expect(await store.listSessionPhotos("abandoned")).toEqual([]);
});
it("never stores a photo that still carries EXIF", async () => {
const { PhotoMetadataError } = await import("./reencode");
const exifHeader = Array.from("Exif\0\0", (char) => char.charCodeAt(0));
@@ -324,4 +493,42 @@ describe("photo store", () => {
const names = (await store.listSessionPhotos("session-1")).map((photo) => photo.fileName);
expect(names).toEqual(["durchsicht-2026-10-13-2.jpg", "durchsicht-2026-10-13-3.jpg"]);
});
it("gives up a hand-over after its lifetime: its photos are purged and the note goes", async () => {
vi.stubGlobal("window", { localStorage: memoryStorage() });
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-01T08:00:00.000Z"));
await takePhoto("expired");
await takePhoto("open");
const start = Date.parse("2026-10-01T08:00:00.000Z");
rememberHandOver({ userId: "user-1", sessionId: "expired", inspectionId: "srv-1" }, start);
rememberHandOver(
{ userId: "user-1", sessionId: "open", inspectionId: "srv-2" },
start + 3 * 24 * 3600 * 1000,
);
const purged = await store.purgeStaleDraftPhotos(start + 3 * 24 * 3600 * 1000 + 60_000, () => []);
expect(purged).toBe(1);
expect(await store.listSessionPhotos("expired")).toEqual([]);
expect(await store.listSessionPhotos("open")).toHaveLength(1);
expect(readHandOvers().map((handOver) => handOver.sessionId)).toEqual(["open"]);
});
it("deletes the unsaved photos of everyone but the given user, or of everyone", async () => {
await takePhoto("mine", "user-1");
await takePhoto("theirs", "user-2");
await takePhoto("queued", "user-2");
await store.attachSessionToQueuedInspection("queued", "key-q");
await store.discardDraftPhotosExcept("user-1");
expect(await store.listSessionPhotos("mine")).toHaveLength(1);
expect(await store.listSessionPhotos("theirs")).toEqual([]);
// Queued photos belong to their queue entry, not to a form.
expect(await store.listSessionPhotos("queued")).toHaveLength(1);
await store.discardDraftPhotosExcept(null);
expect(await store.listSessionPhotos("mine")).toEqual([]);
});
});
+237 -34
View File
@@ -1,8 +1,24 @@
import { ApiError } from "@/lib/auth/api-client";
import { uploadDocument } from "@/lib/documents/api";
import { getDb, toQueueError, type QueueError, type StoredPhoto } from "@/lib/offline/db";
import { DRAFT_MAX_AGE_MS, readLiveFormDrafts } from "@/lib/forms/draft-storage";
import type { CreateInspectionInput } from "@/lib/inspections/types";
import {
getDb,
newPendingEntry,
toQueueError,
type QueueError,
type StoredPhoto,
} from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import type { SyncResult } from "@/lib/offline/sync";
import {
forgetHandOver,
isHandOverExpired,
readHandOverRecords,
rememberHandOver,
type HandOverRecord,
type PhotoHandOver,
} from "./handover";
import { PhotoMetadataError, containsExifOrGps } from "./reencode";
/*
@@ -23,9 +39,9 @@ import { PhotoMetadataError, containsExifOrGps } from "./reencode";
export const PHOTOS_PER_INSPECTION_MAX = 5;
// As long as an unsaved form draft lives (use-form-draft.ts): once the draft
// is gone, nobody can reach its photos any more.
const DRAFT_PHOTO_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
// As long as an unsaved form draft lives (use-form-draft.ts): a photo taken
// in a form that never saved its draft is kept that long too.
const DRAFT_PHOTO_MAX_AGE_MS = DRAFT_MAX_AGE_MS;
// Rejections that may pass on the next try: session refresh, timeout, the
// same key still in progress, rate limit, server trouble.
@@ -97,24 +113,96 @@ export async function listSessionPhotos(sessionId: string): Promise<StoredPhoto[
return photos.sort(byCaptureOrder);
}
export async function removePhoto(id: string): Promise<void> {
/** Deletes one photo, but only while it is in state `state`. */
async function deletePhotoIn(id: string, state: PhotoState): Promise<void> {
const db = await getDb();
await db.delete("pending-photos", id);
const tx = db.transaction("pending-photos", "readwrite");
const photo = await tx.store.get(id);
if (photo?.state === state) {
await tx.store.delete(id);
}
await tx.done;
}
/** Rewrites the session's photos that are in state `from`; others stay as they are. */
/**
* „Entfernen" in the form: deletes a photo not saved yet. One that already
* belongs to a saved or queued inspection is never touched from a form.
*/
export function removePhoto(id: string): Promise<void> {
return deletePhotoIn(id, "draft");
}
/** „Verwerfen" in the queue sheet: a photo waiting on its own (state ready). */
export function discardReadyPhoto(id: string): Promise<void> {
return deletePhotoIn(id, "ready");
}
/**
* Rewrites the session's photos that are in state `from`; others stay as they
* are. Returns how many it rewrote.
*/
async function moveSessionPhotos(
sessionId: string,
from: PhotoState,
change: (photo: StoredPhoto) => StoredPhoto,
): Promise<void> {
): Promise<number> {
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const photos = await tx.store.index("sessionId").getAll(sessionId);
await Promise.all(
photos.filter((photo) => photo.state === from).map((photo) => tx.store.put(change(photo))),
);
const moving = photos.filter((photo) => photo.state === from);
await Promise.all(moving.map((photo) => tx.store.put(change(photo))));
await tx.done;
return moving.length;
}
function waitingFor(photo: StoredPhoto, inspectionIdempotencyKey: string): StoredPhoto {
return { ...photo, state: "waiting-for-inspection", inspectionIdempotencyKey };
}
export interface MovedPhotos {
moved: number;
/** Did not fit (PHOTOS_PER_INSPECTION_MAX) and were deleted. */
discarded: number;
}
/**
* „Weitermachen" with an earlier draft: the photos already taken in the
* fresh form join the restored draft's session, in the order they were
* taken, as long as it holds fewer than PHOTOS_PER_INSPECTION_MAX. The rest
* are deleted - the form can no longer reach them, so it says so instead of
* leaving them behind unseen.
*/
export async function moveDraftPhotos(
fromSessionId: string,
toSessionId: string,
): Promise<MovedPhotos> {
if (fromSessionId === toSessionId) {
return { moved: 0, discarded: 0 };
}
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const sessions = tx.store.index("sessionId");
const target = await sessions.getAll(toSessionId);
const drafts = (await sessions.getAll(fromSessionId))
.filter((photo) => photo.state === "draft")
.sort(byCaptureOrder);
const room = Math.max(0, PHOTOS_PER_INSPECTION_MAX - target.length);
const moving = drafts.slice(0, room);
const overflow = drafts.slice(room);
const lastNumber = Math.max(0, ...target.map(fileNumber));
await Promise.all([
...moving.map((photo, index) =>
tx.store.put({
...photo,
sessionId: toSessionId,
// Numbered on from the session it joins: names stay unique there.
fileName: photo.fileName.replace(FILE_NUMBER, `-${lastNumber + index + 1}.jpg`),
}),
),
...overflow.map((photo) => tx.store.delete(photo.id)),
]);
await tx.done;
return { moved: moving.length, discarded: overflow.length };
}
/** "Entwurf verwerfen": deletes the session's unsaved photos, never saved ones. */
@@ -128,8 +216,14 @@ export async function discardSessionPhotos(sessionId: string): Promise<void> {
await tx.done;
}
/** The inspection was saved online: its photos are ready to upload. */
export function attachSessionToInspection(sessionId: string, inspectionId: string): Promise<void> {
/**
* The inspection was saved online: its photos are ready to upload. Returns
* how many photos it handed over - 0 means there is nothing to send.
*/
export function attachSessionToInspection(
sessionId: string,
inspectionId: string,
): Promise<number> {
return moveSessionPhotos(sessionId, "draft", (photo) => ({
...photo,
state: "ready",
@@ -137,28 +231,92 @@ export function attachSessionToInspection(sessionId: string, inspectionId: strin
}));
}
/** The inspection went into the offline queue: its photos wait for it. */
export function attachSessionToQueuedInspection(
sessionId: string,
inspectionIdempotencyKey: string,
): Promise<void> {
return moveSessionPhotos(sessionId, "draft", (photo) => ({
...photo,
state: "waiting-for-inspection",
inspectionIdempotencyKey,
}));
/**
* Online save: ties the form's photos to the saved inspection. A hand-over
* (lib/photos/handover.ts) is noted first and removed once they belong to
* it: if storage fails now, the next start or sync of this user finishes
* it, and until then the photos are not purged. Returns how many photos it
* handed over; throws, with the hand-over still noted, if that failed.
*/
export async function handOverSessionPhotos(
handOver: PhotoHandOver,
attach: typeof attachSessionToInspection = attachSessionToInspection,
): Promise<number> {
rememberHandOver(handOver);
const handedOver = await attach(handOver.sessionId, handOver.inspectionId);
forgetHandOver(handOver.sessionId);
return handedOver;
}
/**
* Undoes attachSessionToQueuedInspection when the inspection could not be
* queued after all: the photos belong to the still open form again.
* Finishes the user's hand-overs that failed before - never another user's.
* One that fails again stays noted for the next try. Returns how many photos
* it handed over.
*/
export function releaseSessionPhotos(sessionId: string): Promise<void> {
return moveSessionPhotos(sessionId, "waiting-for-inspection", (photo) => ({
...photo,
state: "draft",
inspectionIdempotencyKey: undefined,
}));
export async function finishHandOvers(userId: string): Promise<number> {
let handedOver = 0;
for (const handOver of readHandOverRecords()) {
if (handOver.userId !== userId) {
continue;
}
try {
handedOver += await attachSessionToInspection(handOver.sessionId, handOver.inspectionId);
forgetHandOver(handOver.sessionId);
} catch {
// Still noted: the next start or sync tries again.
}
}
return handedOver;
}
/** The inspection is in the offline queue: its photos wait for it. */
export function attachSessionToQueuedInspection(
sessionId: string,
inspectionIdempotencyKey: string,
): Promise<number> {
return moveSessionPhotos(sessionId, "draft", (photo) =>
waitingFor(photo, inspectionIdempotencyKey),
);
}
function abortQuietly(tx: { abort: () => void }): void {
try {
tx.abort();
} catch {
// Already finished or aborted by the failed request itself.
}
}
/**
* Offline save: queues the inspection and ties the form's unsaved photos to
* its entry in one transaction. Either both happen or neither: if queueing
* fails, the photos still belong to the open form; a photo never waits for
* an entry that does not exist. Returns how many photos went along.
*/
export async function queueInspectionWithPhotos(
sessionId: string,
input: CreateInspectionInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<number> {
const db = await getDb();
const tx = db.transaction(["pending-inspections", "pending-photos"], "readwrite");
try {
const photos = tx.objectStore("pending-photos");
const drafts = (await photos.index("sessionId").getAll(sessionId)).filter(
(photo) => photo.state === "draft",
);
await Promise.all(drafts.map((photo) => photos.put(waitingFor(photo, idempotencyKey))));
await tx
.objectStore("pending-inspections")
.add(newPendingEntry(input, idempotencyKey, ownerUserId) as never);
await tx.done;
return drafts.length;
} catch (error) {
abortQuietly(tx);
await tx.done.catch(() => undefined);
throw error;
}
}
/**
@@ -193,19 +351,64 @@ export async function discardPhotosForInspection(inspectionIdempotencyKey: strin
await tx.done;
}
/** Deletes photos of form drafts older than 3 days; returns how many. */
export async function purgeStaleDraftPhotos(now: number): Promise<number> {
/** The photo sessions that drafts on this device still refer to. */
function sessionsOfDrafts(drafts: readonly unknown[]): Set<string> {
const sessions = drafts.flatMap((values) => {
const sessionId = (values as { photoSessionId?: unknown } | null)?.photoSessionId;
return typeof sessionId === "string" ? [sessionId] : [];
});
return new Set(sessions);
}
/**
* Deletes unsaved photos older than 3 days that no form draft offers any
* more; returns how many. A draft that is still offered keeps all its
* photos, however old: „Weitermachen" must bring them back. So does a
* saved inspection whose hand-over is not finished yet.
*/
export async function purgeStaleDraftPhotos(
now: number,
readDrafts: (now: number) => unknown[] = readLiveFormDrafts,
readOpenHandOvers: () => (PhotoHandOver & Partial<HandOverRecord>)[] = readHandOverRecords,
): Promise<number> {
// Read before the transaction opens: it must not wait for anything else.
const offered = sessionsOfDrafts(readDrafts(now));
for (const handOver of readOpenHandOvers()) {
if (isHandOverExpired(handOver, now)) {
// Given up: its photos are purged like any draft, the note goes too.
forgetHandOver(handOver.sessionId);
} else {
offered.add(handOver.sessionId);
}
}
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const stale = (await tx.store.getAll()).filter(
(photo) =>
photo.state === "draft" && now - Date.parse(photo.createdAt) > DRAFT_PHOTO_MAX_AGE_MS,
photo.state === "draft" &&
!offered.has(photo.sessionId) &&
now - Date.parse(photo.createdAt) > DRAFT_PHOTO_MAX_AGE_MS,
);
await Promise.all(stale.map((photo) => tx.store.delete(photo.id)));
await tx.done;
return stale.length;
}
/**
* Sign-out and user switch: deletes the unsaved photos (state draft) of every
* user but `userId`; `null` deletes all of them. Photos that belong to a
* queued or saved inspection stay with their queue entry.
*/
export async function discardDraftPhotosExcept(userId: string | null): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const drafts = (await tx.store.getAll()).filter(
(photo) => photo.state === "draft" && photo.ownerUserId !== userId,
);
await Promise.all(drafts.map((photo) => tx.store.delete(photo.id)));
await tx.done;
}
/**
* The user's photos waiting to be uploaded on their own (their inspection is
* on the server), rejected ones included - the queue sheet lists them.