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