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

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

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

Not rolled out. Remaining tasks follow in later commits.

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

No files matched your search

@@ -0,0 +1,121 @@
// @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 {
PHOTO_FALLBACK_QUALITY,
PHOTO_JPEG_QUALITY,
PHOTO_MAX_EDGE_PX,
PhotoMetadataError,
PhotoTooLargeError,
containsExifOrGps,
preparePhoto,
type ImageEncoder,
} from "./reencode";
const MB = 1024 * 1024;
function ascii(text: string): number[] {
return Array.from(text, (char) => char.charCodeAt(0));
}
/** A JPEG segment: marker, two length bytes (length counts itself), payload. */
function segment(marker: number, payload: number[]): number[] {
const length = payload.length + 2;
return [0xff, marker, length >> 8, length & 0xff, ...payload];
}
const SOI = [0xff, 0xd8];
const EOI = [0xff, 0xd9];
const JFIF = segment(0xe0, [...ascii("JFIF\0"), 1, 1, 0, 0, 1, 0, 1, 0, 0]);
const QUANTIZATION = segment(0xdb, [0, ...new Array<number>(64).fill(1)]);
const SCAN = [...segment(0xda, [1, 1, 0, 0, 0x3f, 0]), 0x12, 0x34, 0xff, 0x00, 0x56];
// What a phone camera writes: TIFF header, then (in a real photo) the GPS IFD.
const EXIF_APP1 = segment(0xe1, [...ascii("Exif\0\0"), ...ascii("MM\0*"), 0, 0, 0, 8]);
const XMP_APP1 = segment(0xe1, [
...ascii("http://ns.adobe.com/xap/1.0/\0"),
...ascii('<x:xmpmeta><rdf:Description exif:GPSLatitude="52,31.0N"/></x:xmpmeta>'),
]);
function jpeg(...segments: number[][]): Uint8Array<ArrayBuffer> {
return Uint8Array.from([...SOI, ...segments.flat(), ...EOI]);
}
const CLEAN_JPEG = jpeg(JFIF, QUANTIZATION, SCAN);
/** Answers each encode call with the next of the given results. */
function fakeEncoder(...results: Blob[]): ImageEncoder & { encode: ReturnType<typeof vi.fn> } {
const encode = vi.fn();
for (const result of results) {
encode.mockResolvedValueOnce(result);
}
return { encode };
}
function blobOf(size: number): Blob {
// A clean JPEG start, padded to the wanted size with zeros.
const bytes = new Uint8Array(size);
bytes.set(CLEAN_JPEG.subarray(0, CLEAN_JPEG.length - 2));
return new Blob([bytes], { type: "image/jpeg" });
}
describe("containsExifOrGps", () => {
it("detects an EXIF APP1 segment", () => {
expect(containsExifOrGps(jpeg(EXIF_APP1, QUANTIZATION, SCAN))).toBe(true);
});
it("detects XMP", () => {
expect(containsExifOrGps(jpeg(JFIF, XMP_APP1, QUANTIZATION, SCAN))).toBe(true);
});
it("a clean JPEG passes", () => {
expect(containsExifOrGps(CLEAN_JPEG)).toBe(false);
});
});
describe("preparePhoto", () => {
const original = new Blob([jpeg(EXIF_APP1, QUANTIZATION, SCAN)], { type: "image/jpeg" });
it("re-encodes at the photo size and quality and returns the clean result", async () => {
const clean = new Blob([CLEAN_JPEG], { type: "image/jpeg" });
const encoder = fakeEncoder(clean);
const prepared = await preparePhoto(original, encoder);
expect(encoder.encode).toHaveBeenCalledWith(original, PHOTO_MAX_EDGE_PX, PHOTO_JPEG_QUALITY);
expect(new Uint8Array(await prepared.arrayBuffer())).toEqual(CLEAN_JPEG);
});
it("falls back to quality 0.7 when the first result is over 10 MB", async () => {
const encoder = fakeEncoder(blobOf(11 * MB), blobOf(2 * MB));
const prepared = await preparePhoto(original, encoder);
expect(prepared.size).toBe(2 * MB);
expect(encoder.encode).toHaveBeenNthCalledWith(1, original, PHOTO_MAX_EDGE_PX, 0.85);
expect(encoder.encode).toHaveBeenNthCalledWith(2, original, PHOTO_MAX_EDGE_PX, 0.7);
expect(PHOTO_FALLBACK_QUALITY).toBe(0.7);
});
it("throws PhotoTooLargeError when still too large", async () => {
const encoder = fakeEncoder(blobOf(11 * MB), blobOf(10 * MB + 1));
await expect(preparePhoto(original, encoder)).rejects.toBeInstanceOf(PhotoTooLargeError);
});
it("throws PhotoMetadataError and never returns a blob that still has EXIF", async () => {
// An encoder that copied the camera's metadata into its output.
const encoder = fakeEncoder(new Blob([jpeg(EXIF_APP1, QUANTIZATION, SCAN)]));
await expect(preparePhoto(original, encoder)).rejects.toBeInstanceOf(PhotoMetadataError);
});
it("checks the fallback result for metadata too", async () => {
const withXmp = jpeg(JFIF, XMP_APP1, QUANTIZATION, SCAN);
const padded = new Uint8Array(2 * MB);
padded.set(withXmp);
const encoder = fakeEncoder(blobOf(11 * MB), new Blob([padded]));
await expect(preparePhoto(original, encoder)).rejects.toBeInstanceOf(PhotoMetadataError);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { MAX_DOCUMENT_UPLOAD_BYTES } from "@/lib/documents/limits";
/** Longest edge of a stored photo: enough to see eggs on a frame. */
export const PHOTO_MAX_EDGE_PX = 2560;
export const PHOTO_JPEG_QUALITY = 0.85;
/** Second try when the first result is still above the upload limit. */
export const PHOTO_FALLBACK_QUALITY = 0.7;
export interface ImageEncoder {
encode(file: Blob, maxEdgePx: number, quality: number): Promise<Blob>;
}
/** The photo stays above the upload limit even at the fallback quality. */
export class PhotoTooLargeError extends Error {
constructor() {
super("Foto zu groß (max. 10 MB)");
this.name = "PhotoTooLargeError";
}
}
/** The re-encoded photo still carries EXIF or XMP - it is never kept. */
export class PhotoMetadataError extends Error {
constructor() {
super("Foto enthält noch Metadaten");
this.name = "PhotoMetadataError";
}
}
/**
* 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.
*/
export const canvasImageEncoder: ImageEncoder = {
async encode(file, maxEdgePx, quality) {
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
try {
const scale = Math.min(1, maxEdgePx / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Canvas 2D is not available");
}
// JPEG has no transparency: a transparent PNG gets white, not black.
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Canvas could not encode a JPEG"))),
"image/jpeg",
quality,
);
});
} finally {
bitmap.close();
}
},
};
function asciiBytes(text: string): Uint8Array {
return Uint8Array.from(text, (char) => char.charCodeAt(0));
}
// The EXIF APP1 header (holds the GPS IFD) and the XMP namespace URI.
const EXIF_SIGNATURE = asciiBytes("Exif\0\0");
const XMP_SIGNATURE = asciiBytes("http://ns.adobe.com/xap/");
function includesBytes(haystack: Uint8Array, needle: Uint8Array): boolean {
const first = needle[0];
const last = haystack.length - needle.length;
for (let start = 0; start <= last; start++) {
if (haystack[start] !== first) continue;
let offset = 1;
while (offset < needle.length && haystack[start + offset] === needle[offset]) {
offset++;
}
if (offset === needle.length) return true;
}
return false;
}
/**
* True if the bytes contain an EXIF header ("Exif\0\0", as in a JPEG APP1
* segment) or XMP. Searches the whole file, not only the APP segments: a
* leftover anywhere is reason enough to refuse the photo.
*/
export function containsExifOrGps(bytes: Uint8Array): boolean {
return includesBytes(bytes, EXIF_SIGNATURE) || includesBytes(bytes, XMP_SIGNATURE);
}
/**
* Turns a camera photo into what may be stored on the device and uploaded:
* re-encoded without metadata, at most PHOTO_MAX_EDGE_PX on its long edge
* and within the upload limit. Throws PhotoTooLargeError if even the
* fallback quality is too large, and PhotoMetadataError if the result still
* holds EXIF or XMP - such a photo is never returned, so never uploaded.
*/
export async function preparePhoto(
file: Blob,
encoder: ImageEncoder = canvasImageEncoder,
): Promise<Blob> {
let encoded = await encoder.encode(file, PHOTO_MAX_EDGE_PX, PHOTO_JPEG_QUALITY);
if (encoded.size > MAX_DOCUMENT_UPLOAD_BYTES) {
encoded = await encoder.encode(file, PHOTO_MAX_EDGE_PX, PHOTO_FALLBACK_QUALITY);
}
if (encoded.size > MAX_DOCUMENT_UPLOAD_BYTES) {
throw new PhotoTooLargeError();
}
if (containsExifOrGps(new Uint8Array(await encoded.arrayBuffer()))) {
throw new PhotoMetadataError();
}
return encoded;
}
@@ -0,0 +1,327 @@
// @vitest-environment node
// Node's Blob survives IndexedDB (fake-indexeddb clones it); jsdom's does not.
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { afterEach, beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
type OfflineDbModule = typeof import("@/lib/offline/db");
type StoreModule = typeof import("./store");
type SyncModule = typeof import("@/lib/offline/sync");
type ApiErrorClass = typeof import("@/lib/auth/api-client").ApiError;
type Upload = MockedFunction<typeof import("@/lib/documents/api").uploadDocument>;
let db: OfflineDbModule;
let store: StoreModule;
let sync: SyncModule;
let ApiError: ApiErrorClass;
let createInspection: MockedFunction<typeof import("@/lib/inspections/api").createInspection>;
let syncUpload: Upload;
const INSPECTION = { colonyId: "colony-1", date: "2026-10-13T00:00:00.000Z" };
const JPEG_BYTES = Uint8Array.from([0xff, 0xd8, 0xff, 0xdb, 0x00, 0x03, 0x01, 0xff, 0xd9]);
const SAVED_DOCUMENT = {
id: "doc-1",
entityType: "INSPECTION" as const,
entityId: "srv-1",
fileName: "durchsicht-2026-10-13-1.jpg",
mimeType: "image/jpeg",
sizeBytes: JPEG_BYTES.length,
uploadedByUserId: "user-1",
createdAt: "2026-10-13T10:00:00.000Z",
};
function photoBlob(): Blob {
return new Blob([JPEG_BYTES], { type: "image/jpeg" });
}
function takePhoto(sessionId = "session-1", ownerUserId = "user-1") {
return store.addDraftPhoto({ sessionId, ownerUserId, blob: photoBlob(), date: "2026-10-13" });
}
function uploadMock(): Upload {
return vi.fn() as Upload;
}
describe("photo store", () => {
beforeEach(async () => {
// Fresh database and modules: no open connection survives from the last test.
globalThis.indexedDB = new IDBFactory();
vi.resetModules();
db = await import("@/lib/offline/db");
store = await import("./store");
sync = await import("@/lib/offline/sync");
ApiError = (await import("@/lib/auth/api-client")).ApiError;
createInspection = vi.mocked((await import("@/lib/inspections/api")).createInspection);
createInspection.mockReset();
syncUpload = vi.mocked((await import("@/lib/documents/api")).uploadDocument);
syncUpload.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("draft → waiting → ready after the queued inspection is created", async () => {
const taken = await takePhoto();
expect(taken).toMatchObject({
sessionId: "session-1",
ownerUserId: "user-1",
state: "draft",
fileName: "durchsicht-2026-10-13-1.jpg",
});
expect(taken.uploadIdempotencyKey).toMatch(/^[A-Za-z0-9_-]{16,128}$/);
await store.attachSessionToQueuedInspection("session-1", "inspection-key");
const [waiting] = await store.listSessionPhotos("session-1");
expect(waiting).toMatchObject({
state: "waiting-for-inspection",
inspectionIdempotencyKey: "inspection-key",
});
expect(waiting.inspectionId).toBeUndefined();
await store.resolveQueuedInspection("inspection-key", "srv-1");
const [ready] = await store.listSessionPhotos("session-1");
expect(ready).toMatchObject({ state: "ready", inspectionId: "srv-1" });
});
it("photos of an inspection the server rejected are never uploaded", async () => {
await db.queueInspection(INSPECTION, "inspection-key", "user-1");
await takePhoto();
await store.attachSessionToQueuedInspection("session-1", "inspection-key");
const rejection = new ApiError(400, "Volk V-03 ist beendet.", { statusCode: 400 });
createInspection.mockRejectedValue(rejection);
const run = await sync.syncPendingEntries("user-1");
const upload = uploadMock();
const direct = await store.uploadReadyPhotos("user-1", upload);
expect(run).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
const [inspection] = await db.getPendingEntries("pending-inspections");
expect(inspection.lastError).toMatchObject({ status: 400, message: "Volk V-03 ist beendet." });
expect(direct).toEqual({ synced: 0, failed: 0, skippedForeign: 0 });
expect(upload).not.toHaveBeenCalled();
expect(syncUpload).not.toHaveBeenCalled();
const [photo] = await store.listSessionPhotos("session-1");
expect(photo).toMatchObject({ state: "waiting-for-inspection" });
expect(photo.inspectionId).toBeUndefined();
});
it("discarding the inspection discards its photos", async () => {
await db.queueInspection(INSPECTION, "key-a", "user-1");
await db.queueInspection(INSPECTION, "key-b", "user-1");
await takePhoto("session-a");
await takePhoto("session-a");
await takePhoto("session-b");
await store.attachSessionToQueuedInspection("session-a", "key-a");
await store.attachSessionToQueuedInspection("session-b", "key-b");
await store.discardPhotosForInspection("key-a");
expect(await store.listSessionPhotos("session-a")).toEqual([]);
expect(await store.listSessionPhotos("session-b")).toHaveLength(1);
// The queue sheet discards an inspection together with its photos.
const [, inspectionB] = await db.listQueue("user-1");
await db.discardQueueItem(inspectionB);
expect(await store.listSessionPhotos("session-b")).toEqual([]);
expect(await store.uploadReadyPhotos("user-1", uploadMock())).toEqual({
synced: 0,
failed: 0,
skippedForeign: 0,
});
});
it("uploads with the photo's own Idempotency-Key to INSPECTION/<server id> and deletes it afterwards", async () => {
const taken = await takePhoto();
await store.attachSessionToInspection("session-1", "srv-1");
const upload = uploadMock().mockResolvedValue(SAVED_DOCUMENT);
const result = await store.uploadReadyPhotos("user-1", upload);
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(upload).toHaveBeenCalledWith("INSPECTION", "srv-1", expect.any(Blob), {
idempotencyKey: taken.uploadIdempotencyKey,
fileName: "durchsicht-2026-10-13-1.jpg",
});
const sent = upload.mock.calls[0][2];
expect(new Uint8Array(await sent.arrayBuffer())).toEqual(JPEG_BYTES);
expect(await store.listSessionPhotos("session-1")).toEqual([]);
});
it("keeps a photo with lastError on 413", async () => {
await takePhoto();
await store.attachSessionToInspection("session-1", "srv-1");
const upload = uploadMock().mockRejectedValue(new ApiError(413, "Datei zu groß"));
const result = await store.uploadReadyPhotos("user-1", upload);
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
const [kept] = await store.listSessionPhotos("session-1");
expect(kept).toMatchObject({ state: "ready", inspectionId: "srv-1" });
expect(kept.lastError).toMatchObject({ status: 413, message: "Datei zu groß" });
expect(await store.listPendingPhotos("user-1")).toEqual([
expect.objectContaining({ id: kept.id, lastError: expect.objectContaining({ status: 413 }) }),
]);
});
it("waits for „Erneut senden“ after a permanent rejection, retries a passing one by itself", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-13T08:00:00.000Z"));
const tooLarge = await takePhoto("session-1");
vi.setSystemTime(new Date("2026-10-13T08:01:00.000Z"));
await takePhoto("session-2");
await store.attachSessionToInspection("session-1", "srv-1");
await store.attachSessionToInspection("session-2", "srv-2");
const upload = uploadMock()
.mockRejectedValueOnce(new ApiError(413, "Datei zu groß"))
.mockRejectedValueOnce(new ApiError(503, "Wartung"))
.mockResolvedValue(SAVED_DOCUMENT);
await store.uploadReadyPhotos("user-1", upload);
await store.uploadReadyPhotos("user-1", upload);
// The server outage heals by itself; the too large photo is not sent again.
expect(upload.mock.calls.map(([, entityId]) => entityId)).toEqual(["srv-1", "srv-2", "srv-2"]);
expect((await store.listPendingPhotos("user-1")).map((photo) => photo.id)).toEqual([
tooLarge.id,
]);
// „Erneut senden“: the user decides to try it once more.
await store.resetPhotoError(tooLarge.id);
await store.uploadReadyPhotos("user-1", upload);
expect(upload).toHaveBeenCalledTimes(4);
expect(await store.listPendingPhotos("user-1")).toEqual([]);
});
it("never uploads another user's photo", async () => {
await takePhoto("session-1", "user-2");
await store.attachSessionToInspection("session-1", "srv-1");
const upload = uploadMock().mockResolvedValue(SAVED_DOCUMENT);
const result = await store.uploadReadyPhotos("user-1", upload);
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 1 });
expect(upload).not.toHaveBeenCalled();
expect(await store.listPendingPhotos("user-1")).toEqual([]);
expect(await store.listPendingPhotos("user-2")).toHaveLength(1);
});
it("stops at a network failure and leaves the photos untouched", async () => {
await takePhoto();
await takePhoto();
await store.attachSessionToInspection("session-1", "srv-1");
const upload = uploadMock().mockRejectedValue(new TypeError("Failed to fetch"));
const result = await store.uploadReadyPhotos("user-1", upload);
expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 });
expect(upload).toHaveBeenCalledTimes(1);
const photos = await store.listSessionPhotos("session-1");
expect(photos).toHaveLength(2);
expect(photos.every((photo) => photo.lastError === undefined)).toBe(true);
});
it("stops uploading once the run is cancelled", async () => {
await takePhoto();
await takePhoto();
await store.attachSessionToInspection("session-1", "srv-1");
let cancelled = false;
const upload = uploadMock().mockImplementation(async () => {
cancelled = true; // e.g. logout while the first upload runs
return SAVED_DOCUMENT;
});
const result = await store.uploadReadyPhotos("user-1", upload, () => cancelled);
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(upload).toHaveBeenCalledTimes(1);
expect(await store.listSessionPhotos("session-1")).toHaveLength(1);
});
it("never moves a photo that already belongs to an inspection", async () => {
await takePhoto();
await store.attachSessionToQueuedInspection("session-1", "key-a");
await store.attachSessionToInspection("session-1", "srv-other");
await store.attachSessionToQueuedInspection("session-1", "key-other");
await store.resolveQueuedInspection("key-other", "srv-other");
const [photo] = await store.listSessionPhotos("session-1");
expect(photo).toMatchObject({ state: "waiting-for-inspection", inspectionIdempotencyKey: "key-a" });
expect(photo.inspectionId).toBeUndefined();
});
it("gives the photos back to the form when the inspection could not be queued", async () => {
await takePhoto();
await store.attachSessionToQueuedInspection("session-1", "key-a");
await store.releaseSessionPhotos("session-1");
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);
});
it("discarding a form draft removes its unsaved photos only", async () => {
await takePhoto("session-1");
await takePhoto("session-2");
await store.attachSessionToQueuedInspection("session-2", "key-b");
await takePhoto("session-2"); // taken after the save: still a draft
await store.discardSessionPhotos("session-1");
await store.discardSessionPhotos("session-2");
expect(await store.listSessionPhotos("session-1")).toEqual([]);
const kept = await store.listSessionPhotos("session-2");
expect(kept.map((photo) => photo.state)).toEqual(["waiting-for-inspection"]);
});
it("purges drafts older than 3 days and keeps everything else", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-01T08:00:00.000Z"));
await takePhoto("old-draft");
await takePhoto("old-queued");
await store.attachSessionToQueuedInspection("old-queued", "key-q");
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"));
expect(purged).toBe(1);
expect(await store.listSessionPhotos("old-draft")).toEqual([]);
expect(await store.listSessionPhotos("old-queued")).toHaveLength(1);
expect(await store.listSessionPhotos("recent-draft")).toHaveLength(1);
});
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));
const exif = Uint8Array.from([0xff, 0xd8, 0xff, 0xe1, 0x00, 0x08, ...exifHeader, 0xff, 0xd9]);
await expect(
store.addDraftPhoto({
sessionId: "session-1",
ownerUserId: "user-1",
blob: new Blob([exif], { type: "image/jpeg" }),
date: "2026-10-13",
}),
).rejects.toBeInstanceOf(PhotoMetadataError);
expect(await store.listSessionPhotos("session-1")).toEqual([]);
});
it("numbers the photos of a session without reusing a number", async () => {
const first = await takePhoto();
await takePhoto();
await store.removePhoto(first.id);
const third = await takePhoto();
expect(third.fileName).toBe("durchsicht-2026-10-13-3.jpg");
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"]);
});
});
+297
View File
@@ -0,0 +1,297 @@
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 { newIdempotencyKey } from "@/lib/offline/idempotency";
import type { SyncResult } from "@/lib/offline/sync";
import { PhotoMetadataError, containsExifOrGps } from "./reencode";
/*
* Inspection photos on this device, each one already re-encoded without
* metadata (preparePhoto). A photo moves through three states:
*
* - draft: taken in an inspection form that is not saved yet. Belongs to the
* form's photo session (`photoSessionId` in the form draft).
* - waiting-for-inspection: the inspection went into the offline queue; the
* photo knows only that entry's idempotency key.
* - ready: the inspection exists on the server; the photo knows its id and is
* uploaded by the next sync, then deleted here.
*
* A photo only ever moves forward from draft, so it can never be attached to
* a second record. Discarding a queued inspection discards its photos with
* it (db.discardQueueItem), so they are never uploaded anywhere.
*/
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;
// Rejections that may pass on the next try: session refresh, timeout, the
// same key still in progress, rate limit, server trouble.
const PASSING_STATUSES = new Set([401, 408, 409, 429]);
const FILE_NUMBER = /-(\d+)\.jpg$/;
type PhotoState = StoredPhoto["state"];
export interface NewDraftPhoto {
sessionId: string;
ownerUserId: string;
/** Already re-encoded by preparePhoto - never the camera's original. */
blob: Blob;
/** The inspection date (yyyy-mm-dd), for the file name. */
date: string;
}
function fileNumber(photo: StoredPhoto): number {
const match = FILE_NUMBER.exec(photo.fileName);
return match ? Number(match[1]) : 0;
}
function byCaptureOrder(a: StoredPhoto, b: StoredPhoto): number {
return a.createdAt.localeCompare(b.createdAt) || fileNumber(a) - fileNumber(b);
}
/**
* Stores a photo of an unsaved inspection form right away, so it survives a
* closed tab or a dead battery like the rest of the form draft. A blob that
* still carries EXIF or XMP is refused: only clean photos reach the device's
* storage, so only clean photos can ever be uploaded from it.
*/
export async function addDraftPhoto({
sessionId,
ownerUserId,
blob,
date,
}: NewDraftPhoto): Promise<StoredPhoto> {
// Checked before the transaction opens: awaiting the bytes inside it would
// let IndexedDB commit it early.
if (containsExifOrGps(new Uint8Array(await blob.arrayBuffer()))) {
throw new PhotoMetadataError();
}
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const session = await tx.store.index("sessionId").getAll(sessionId);
// Never reuse the number of a removed photo: names stay unique per session.
const number = Math.max(0, ...session.map(fileNumber)) + 1;
const photo: StoredPhoto = {
id: newIdempotencyKey(),
sessionId,
ownerUserId,
blob,
fileName: `durchsicht-${date}-${number}.jpg`,
createdAt: new Date().toISOString(),
state: "draft",
uploadIdempotencyKey: newIdempotencyKey(),
};
await tx.store.add(photo);
await tx.done;
return photo;
}
/** Every photo of one form's session, in the order they were taken. */
export async function listSessionPhotos(sessionId: string): Promise<StoredPhoto[]> {
const db = await getDb();
const photos = await db.getAllFromIndex("pending-photos", "sessionId", sessionId);
return photos.sort(byCaptureOrder);
}
export async function removePhoto(id: string): Promise<void> {
const db = await getDb();
await db.delete("pending-photos", id);
}
/** Rewrites the session's photos that are in state `from`; others stay as they are. */
async function moveSessionPhotos(
sessionId: string,
from: PhotoState,
change: (photo: StoredPhoto) => StoredPhoto,
): Promise<void> {
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))),
);
await tx.done;
}
/** "Entwurf verwerfen": deletes the session's unsaved photos, never saved ones. */
export async function discardSessionPhotos(sessionId: string): Promise<void> {
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 === "draft").map((photo) => tx.store.delete(photo.id)),
);
await tx.done;
}
/** The inspection was saved online: its photos are ready to upload. */
export function attachSessionToInspection(sessionId: string, inspectionId: string): Promise<void> {
return moveSessionPhotos(sessionId, "draft", (photo) => ({
...photo,
state: "ready",
inspectionId,
}));
}
/** 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,
}));
}
/**
* Undoes attachSessionToQueuedInspection when the inspection could not be
* queued after all: the photos belong to the still open form again.
*/
export function releaseSessionPhotos(sessionId: string): Promise<void> {
return moveSessionPhotos(sessionId, "waiting-for-inspection", (photo) => ({
...photo,
state: "draft",
inspectionIdempotencyKey: undefined,
}));
}
/**
* The queued inspection was created on the server (sync): its photos get the
* server id and are uploaded once all records of the run are sent.
*/
export async function resolveQueuedInspection(
inspectionIdempotencyKey: string,
inspectionId: string,
): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const photos = await tx.store
.index("inspectionIdempotencyKey")
.getAll(inspectionIdempotencyKey);
await Promise.all(
photos
.filter((photo) => photo.state === "waiting-for-inspection")
.map((photo) =>
tx.store.put({ ...photo, state: "ready", inspectionId, inspectionIdempotencyKey: undefined }),
),
);
await tx.done;
}
/** Deletes the photos still waiting for this queued inspection. */
export async function discardPhotosForInspection(inspectionIdempotencyKey: string): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const ids = await tx.store.index("inspectionIdempotencyKey").getAllKeys(inspectionIdempotencyKey);
await Promise.all(ids.map((id) => tx.store.delete(id)));
await tx.done;
}
/** Deletes photos of form drafts older than 3 days; returns how many. */
export async function purgeStaleDraftPhotos(now: number): Promise<number> {
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,
);
await Promise.all(stale.map((photo) => tx.store.delete(photo.id)));
await tx.done;
return stale.length;
}
/**
* 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.
*/
export async function listPendingPhotos(currentUserId: string): Promise<StoredPhoto[]> {
const db = await getDb();
const own = await db.getAllFromIndex("pending-photos", "ownerUserId", currentUserId);
return own.filter((photo) => photo.state === "ready").sort(byCaptureOrder);
}
async function setPhotoError(id: string, lastError: QueueError | undefined): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-photos", "readwrite");
const photo = await tx.store.get(id);
if (photo) {
await tx.store.put({ ...photo, lastError });
}
await tx.done;
}
/** „Erneut senden": the next sync tries the rejected photo again. */
export function resetPhotoError(id: string): Promise<void> {
return setPhotoError(id, undefined);
}
/**
* A rejection that would fail the same way again (too large, wrong type,
* inspection gone) is not re-sent by every sync - that would burn megabytes
* of mobile data each time. Such a photo waits for „Erneut senden".
*/
function isSentBySync(photo: StoredPhoto): boolean {
const status = photo.lastError?.status;
return status === undefined || status >= 500 || PASSING_STATUSES.has(status);
}
/**
* Uploads the run user's ready photos with each photo's own idempotency key
* to INSPECTION/<server id> and deletes each one once the server has it.
* Called by the sync run (runSync) after all record drains, which passes its
* cancellation, so a logout stops it like any other drain.
*
* Each photo is read again right before sending: one removed, still waiting
* or taken over by another account meanwhile is not sent. A network failure
* stops the run and leaves the rest untouched; a server rejection is stored
* on the photo, which stays for the queue sheet.
*/
export async function uploadReadyPhotos(
currentUserId: string,
upload: typeof uploadDocument = uploadDocument,
isCancelled: () => boolean = () => false,
): Promise<SyncResult> {
const db = await getDb();
const ready = (await db.getAll("pending-photos")).filter((photo) => photo.state === "ready");
const own = ready.filter((photo) => photo.ownerUserId === currentUserId).sort(byCaptureOrder);
let synced = 0;
let failed = 0;
let skippedForeign = ready.length - own.length;
for (const { id } of own) {
const photo = await db.get("pending-photos", id);
if (isCancelled()) {
break;
}
if (!photo || photo.state !== "ready" || !photo.inspectionId || !isSentBySync(photo)) {
continue;
}
if (photo.ownerUserId !== currentUserId) {
skippedForeign += 1;
continue;
}
try {
await upload("INSPECTION", photo.inspectionId, photo.blob, {
idempotencyKey: photo.uploadIdempotencyKey,
fileName: photo.fileName,
});
} catch (error) {
failed += 1;
if (!(error instanceof ApiError)) {
break;
}
await setPhotoError(id, toQueueError(error));
continue;
}
await db.delete("pending-photos", id);
synced += 1;
}
return { synced, failed, skippedForeign };
}