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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user