import { openDB, DBSchema, IDBPDatabase } from "idb"; import type { ApiError } from "@/lib/auth/api-client"; import type { OfflinePackV1 } from "@/lib/apiaries/field-types"; import { CreateInspectionInput } from "@/lib/inspections/types"; import { CreateTreatmentInput } from "@/lib/treatments/types"; import { CreateFeedingInput } from "@/lib/feedings/types"; import { CreateVarroaMeasurementInput } from "@/lib/varroa/types"; import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types"; import { CreateHarvestInput } from "@/lib/harvests/types"; import type { CreatePhenologyReportInput } from "@/lib/phenology/types"; import type { HiveOp } from "@/lib/hive/types"; import { colonyIdsOfBulkInput, invalidColonyIdsOfBody, withoutColonies, } from "@/lib/bulk/bulk-input"; /** A change of a colony's hive made on the card, waiting for network. */ export interface HiveChangeQueueInput { colonyId: string; occurredOn: string; source: "CARD"; ops: HiveOp[]; } /** What each single-record queue (one per quick-capture form) holds. */ export interface RecordQueueInputs { "pending-inspections": CreateInspectionInput; "pending-treatments": CreateTreatmentInput; "pending-feedings": CreateFeedingInput; "pending-varroa-measurements": CreateVarroaMeasurementInput; "pending-swarm-control-checks": CreateSwarmControlCheckInput; "pending-harvests": CreateHarvestInput; "pending-phenology-reports": CreatePhenologyReportInput; "pending-hive-changes": HiveChangeQueueInput; } export type RecordStoreName = keyof RecordQueueInputs; /** Every store whose entries wait to be sent and are shown in the queue sheet. */ export type QueueStoreName = RecordStoreName | "pending-bulk"; /** The server's answer when it rejected a queued entry. */ export interface QueueError { status: number; message: string; details?: unknown; at: string; } export function toQueueError(error: ApiError): QueueError { return { status: error.status, message: error.message, details: error.body, at: new Date().toISOString(), }; } export interface PendingEntry { id: number; input: T; queuedAt: string; // Missing on entries queued before keys existed - those sync as before. idempotencyKey?: string; // Missing on entries queued before owners existed (DB v2) - those sync as // before, under whoever is signed in. ownerUserId?: string; // Set when the server rejected the entry; it stays queued until it is sent // or the user discards it. lastError?: QueueError; } export type BulkKind = "treatments" | "feedings" | "varroa"; export interface PendingBulkEntry { id: number; kind: BulkKind; input: unknown; idempotencyKey: string; queuedAt: string; ownerUserId: string; lastError?: QueueError; } export interface StoredPhoto { id: string; sessionId: string; ownerUserId: string; blob: Blob; fileName: string; createdAt: string; state: "draft" | "waiting-for-inspection" | "ready"; inspectionIdempotencyKey?: string; inspectionId?: string; uploadIdempotencyKey: string; lastError?: QueueError; } export interface StoredPack { apiaryId: string; ownerUserId: string; savedAt: string; pack: OfflinePackV1; } type RecordStores = { [Store in RecordStoreName]: { key: number; value: PendingEntry }; }; export interface OfflineDB extends DBSchema, RecordStores { "pending-bulk": { key: number; value: PendingBulkEntry }; "pending-photos": { key: string; value: StoredPhoto; indexes: { sessionId: string; inspectionIdempotencyKey: string; ownerUserId: string }; }; "offline-packs": { key: string; value: StoredPack }; meta: { key: string; value: unknown }; } const DB_NAME = "apicore-offline"; // v1 only had "pending-inspections", v2 added the other five quick-capture // types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant // reports (Pflanzenentwicklung), v5 adds hive changes (Beute). Entries already // queued on a phone must survive every upgrade, so each store is only created // if it doesn't exist yet (guarded by oldVersion, per idb's upgrade() // convention), never recreated. const DB_VERSION = 5; const RECORD_STORES: readonly RecordStoreName[] = [ "pending-inspections", "pending-treatments", "pending-feedings", "pending-varroa-measurements", "pending-swarm-control-checks", "pending-harvests", "pending-phenology-reports", "pending-hive-changes", ]; const QUEUE_STORES: readonly QueueStoreName[] = [...RECORD_STORES, "pending-bulk"]; const RECORD_LABELS: Record = { "pending-inspections": "Durchsicht", "pending-treatments": "Behandlung", "pending-feedings": "Fütterung", "pending-varroa-measurements": "Varroamessung", "pending-swarm-control-checks": "Schwarmkontrolle", "pending-harvests": "Ernte", "pending-phenology-reports": "Pflanzenmeldung", "pending-hive-changes": "Beute-Änderung", }; const BULK_LABELS: Record = { treatments: "Behandlung für mehrere Völker", feedings: "Fütterung für mehrere Völker", varroa: "Varroamessung für mehrere Völker", }; let dbPromise: Promise> | null = null; /** * The one shared connection to this device's offline database. Exported for * the photo store (lib/photos/store.ts), which keeps its own records here. */ export function getDb(): Promise> { if (!dbPromise) { const opening = openDB(DB_NAME, DB_VERSION, { upgrade(db, oldVersion) { if (oldVersion < 1) { db.createObjectStore("pending-inspections", { keyPath: "id", autoIncrement: true }); } if (oldVersion < 2) { db.createObjectStore("pending-treatments", { keyPath: "id", autoIncrement: true }); db.createObjectStore("pending-feedings", { keyPath: "id", autoIncrement: true }); db.createObjectStore("pending-varroa-measurements", { keyPath: "id", autoIncrement: true, }); db.createObjectStore("pending-swarm-control-checks", { keyPath: "id", autoIncrement: true, }); db.createObjectStore("pending-harvests", { keyPath: "id", autoIncrement: true }); } if (oldVersion < 3) { db.createObjectStore("pending-bulk", { keyPath: "id", autoIncrement: true }); const photos = db.createObjectStore("pending-photos", { keyPath: "id" }); photos.createIndex("sessionId", "sessionId"); photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey"); photos.createIndex("ownerUserId", "ownerUserId"); db.createObjectStore("offline-packs", { keyPath: "apiaryId" }); db.createObjectStore("meta"); } if (oldVersion < 4) { db.createObjectStore("pending-phenology-reports", { keyPath: "id", autoIncrement: true, }); } if (oldVersion < 5) { db.createObjectStore("pending-hive-changes", { keyPath: "id", autoIncrement: true }); } }, // A newer app version in another tab wants to upgrade: step aside // instead of blocking it. The next call here opens the new version. blocking() { void opening.then((db) => db.close()); forget(opening); }, terminated() { forget(opening); }, }); // A failed open (storage blocked, private mode) is retried next time. opening.catch(() => forget(opening)); dbPromise = opening; } return dbPromise; } function forget(connection: Promise>): void { if (dbPromise === connection) { dbPromise = null; } } /** A new queue entry; the store gives it its id when it is added. */ export function newPendingEntry( input: T, idempotencyKey: string, ownerUserId: string, ): Omit, "id"> { return { input, idempotencyKey, ownerUserId, queuedAt: new Date().toISOString() }; } async function queue( store: Store, input: RecordQueueInputs[Store], idempotencyKey: string, ownerUserId: string, ): Promise { const db = await getDb(); await db.add(store, newPendingEntry(input, idempotencyKey, ownerUserId) as never); } export function queueInspection( input: CreateInspectionInput, idempotencyKey: string, ownerUserId: string, ): Promise { return queue("pending-inspections", input, idempotencyKey, ownerUserId); } export function queueTreatment( input: CreateTreatmentInput, idempotencyKey: string, ownerUserId: string, ): Promise { return queue("pending-treatments", input, idempotencyKey, ownerUserId); } export function queueFeeding( input: CreateFeedingInput, idempotencyKey: string, ownerUserId: string, ): Promise { return queue("pending-feedings", input, idempotencyKey, ownerUserId); } export function queueVarroaMeasurement( input: CreateVarroaMeasurementInput, idempotencyKey: string, ownerUserId: string, ): Promise { return queue("pending-varroa-measurements", input, idempotencyKey, ownerUserId); } export function queueSwarmControlCheck( input: CreateSwarmControlCheckInput, idempotencyKey: string, ownerUserId: string, ): Promise { return queue("pending-swarm-control-checks", input, idempotencyKey, ownerUserId); } export function queueHarvest( input: CreateHarvestInput, idempotencyKey: string, ownerUserId: string, ): Promise { return queue("pending-harvests", input, idempotencyKey, ownerUserId); } export function queuePhenologyReport( input: CreatePhenologyReportInput, idempotencyKey: string, ownerUserId: string, ): Promise { return queue("pending-phenology-reports", input, idempotencyKey, ownerUserId); } export function queueHiveChange( input: HiveChangeQueueInput, idempotencyKey: string, ownerUserId: string, ): Promise { return queue("pending-hive-changes", input, idempotencyKey, ownerUserId); } /** * One entry for several colonies. The key belongs to this exact selection of * colonies: it is stored with the entry and sent again unchanged. */ export async function queueBulk( kind: BulkKind, input: unknown, idempotencyKey: string, ownerUserId: string, ): Promise { const db = await getDb(); const entry: Omit = { kind, input, idempotencyKey, ownerUserId, queuedAt: new Date().toISOString(), }; await db.add("pending-bulk", entry as PendingBulkEntry); } /** Every entry of one record queue, oldest first, of every owner. */ export async function getPendingEntries( store: Store, ): Promise[]> { const db = await getDb(); return (await db.getAll(store)) as PendingEntry[]; } /** One entry as it is stored right now, or undefined once it is gone. */ export async function getPendingEntry( store: Store, id: number, ): Promise | undefined> { const db = await getDb(); return (await db.get(store, id)) as PendingEntry | undefined; } /** Every bulk entry, oldest first, of every owner. */ export async function getBulkEntries(): Promise { const db = await getDb(); return db.getAll("pending-bulk"); } /** One bulk entry as it is stored right now, or undefined once it is gone. */ export async function getBulkEntry(id: number): Promise { const db = await getDb(); return db.get("pending-bulk", id); } export async function removeQueueEntry(store: QueueStoreName, id: number): Promise { const db = await getDb(); await db.delete(store, id); } /** Keeps a rejected entry queued and remembers why the server refused it. */ export async function markFailed( store: QueueStoreName, id: number, error: QueueError, ): Promise { const db = await getDb(); const tx = db.transaction(store, "readwrite"); const entry = await tx.store.get(id); if (entry) { await tx.store.put({ ...entry, lastError: error }); } await tx.done; } /** * Entries from before owners existed (DB v2) count as the signed-in user's * own. Anything queued under another account is never sent by this one. */ export function isForeignEntry(entry: { ownerUserId?: string }, currentUserId: string): boolean { return entry.ownerUserId !== undefined && entry.ownerUserId !== currentUserId; } /** * „Ohne diese Völker erneut senden“: takes the colonies out of a queued bulk * entry and clears its rejection, so the next sync run sends the rest. The * key stays: the server stored nothing for the rejected try and released it. * An entry left without any colony is removed. */ export async function retryBulkWithout( entryId: number, colonyIds: readonly string[], ): Promise { const db = await getDb(); const tx = db.transaction("pending-bulk", "readwrite"); const entry = await tx.store.get(entryId); if (entry) { const input = withoutColonies(entry.kind, entry.input, colonyIds); if (colonyIdsOfBulkInput(entry.kind, input).length === 0) { await tx.store.delete(entryId); } else { const cleared: PendingBulkEntry = { ...entry, input }; delete cleared.lastError; await tx.store.put(cleared); } } await tx.done; } type QueuedValue = PendingEntry | PendingBulkEntry; interface QueuedRecord { store: QueueStoreName; entry: QueuedValue; } async function readQueue(): Promise { const db = await getDb(); const tx = db.transaction([...QUEUE_STORES], "readonly"); const perStore = await Promise.all( QUEUE_STORES.map(async (store) => { const entries: QueuedValue[] = await tx.objectStore(store).getAll(); return entries.map((entry) => ({ store, entry })); }), ); await tx.done; return perStore.flat(); } export interface QueueSummary { /** The user's entries still waiting to be sent. */ own: number; /** The user's entries the server rejected. */ failed: number; /** Entries of another account on this device - never sent from here. */ foreign: number; } /** * A photo whose inspection is on the server waits on its own and counts like * an entry. Drafts (form not saved yet) and photos still waiting for their * queued inspection are not counted: they ride along with that inspection. */ async function readPhotosWaitingOnTheirOwn(): Promise { const db = await getDb(); const photos = await db.getAll("pending-photos"); return photos.filter((photo) => photo.state === "ready"); } export async function summarizeQueue(currentUserId: string): Promise { const waiting: { ownerUserId?: string; lastError?: QueueError }[] = [ ...(await readQueue()).map(({ entry }) => entry), ...(await readPhotosWaitingOnTheirOwn()), ]; return waiting.reduce( (summary, entry) => { if (isForeignEntry(entry, currentUserId)) { return { ...summary, foreign: summary.foreign + 1 }; } if (entry.lastError) { return { ...summary, failed: summary.failed + 1 }; } return { ...summary, own: summary.own + 1 }; }, { own: 0, failed: 0, foreign: 0 }, ); } /** The server refused the hive part: 403, or a conflict with the hive's current state. */ function isHiveRefusal(error: QueueError | undefined): boolean { if (!error) return false; const code = (error.details as { code?: unknown } | null | undefined)?.code; return error.status === 403 || code === "HIVE_CONFLICT"; } /** A queued inspection that also carries a change of the hive. */ export function hasHiveChange(input: unknown): boolean { return (input as { hiveChange?: unknown } | null)?.hiveChange !== undefined; } /** * Ohne Beute-Änderung senden: takes the hive change out of a queued * inspection and clears its rejection, so the next sync run sends the rest. * The key stays: the server stored nothing for the rejected try. */ export async function retryInspectionWithoutHiveChange(entryId: number): Promise { const db = await getDb(); const tx = db.transaction("pending-inspections", "readwrite"); const entry = await tx.store.get(entryId); if (entry && hasHiveChange(entry.input)) { const input = { ...entry.input }; delete input.hiveChange; const cleared: PendingEntry = { ...entry, input }; delete cleared.lastError; await tx.store.put(cleared); } await tx.done; } export interface QueueItem { store: string; id: number | string; label: string; queuedAt: string; lastError?: QueueError; photoCount?: number; /** The entry changes a colony's hive (a hive entry, or an inspection with a hive change). */ hasHiveChange?: boolean; /** A rejected inspection whose hive change can be left out and the rest sent. */ canResendWithoutHiveChange?: boolean; /** * A rejected bulk entry that can go out without the colonies the server * refused (`codes`: the ones named in the loaded packs, maybe fewer). */ resendWithout?: { colonyIds: string[]; codes: string[] }; } function labelOf({ store, entry }: QueuedRecord): string { if (store === "pending-bulk") { return BULK_LABELS[(entry as PendingBulkEntry).kind]; } return RECORD_LABELS[store]; } async function resendWithoutOf( db: IDBPDatabase, entry: PendingBulkEntry, ): Promise { const all = colonyIdsOfBulkInput(entry.kind, entry.input); const rejected = invalidColonyIdsOfBody(entry.lastError?.details).filter((id) => all.includes(id), ); if (rejected.length === 0 || rejected.length === all.length) { return undefined; } const packs = await db.getAll("offline-packs"); const codeOf = new Map( packs.flatMap((stored) => stored.pack.colonies.map((colony) => [colony.id, colony.code])), ); return { colonyIds: rejected, codes: rejected.flatMap((id) => codeOf.get(id) ?? []), }; } /** The signed-in user's waiting and rejected entries, oldest first. */ export async function listQueue(currentUserId: string): Promise { const db = await getDb(); const own = (await readQueue()).filter(({ entry }) => !isForeignEntry(entry, currentUserId)); const items = await Promise.all( own.map(async (record): Promise => { const { store, entry } = record; const item: QueueItem = { store, id: entry.id, label: labelOf(record), queuedAt: entry.queuedAt, ...(entry.lastError ? { lastError: entry.lastError } : {}), }; if (store === "pending-hive-changes") { return { ...item, hasHiveChange: true }; } if (store === "pending-bulk") { const resendWithout = await resendWithoutOf(db, entry as PendingBulkEntry); return resendWithout ? { ...item, resendWithout } : item; } if (store !== "pending-inspections") { return item; } const hive = hasHiveChange((entry as PendingEntry).input) ? { hasHiveChange: true, ...(isHiveRefusal(entry.lastError) ? { canResendWithoutHiveChange: true } : {}), } : {}; if (!entry.idempotencyKey) { return { ...item, ...hive }; } const photoCount = await db.countFromIndex( "pending-photos", "inspectionIdempotencyKey", entry.idempotencyKey, ); return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive }; }), ); return items.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt)); } function asQueueStore(store: string): QueueStoreName { const known = QUEUE_STORES.find((name) => name === store); if (!known) { throw new Error(`Unknown offline queue: ${store}`); } return known; } /** * Deletes one queued entry without sending it. A queued inspection takes its * photos with it - they must never be uploaded to another record. */ export async function discardQueueItem(item: QueueItem): Promise { const store = asQueueStore(item.store); if (typeof item.id !== "number") { throw new Error(`Invalid queue entry id: ${String(item.id)}`); } const db = await getDb(); const tx = db.transaction([store, "pending-photos"], "readwrite"); const entry = await tx.objectStore(store).get(item.id); if (store === "pending-inspections" && entry?.idempotencyKey) { const photos = tx.objectStore("pending-photos"); const photoIds = await photos .index("inspectionIdempotencyKey") .getAllKeys(entry.idempotencyKey); await Promise.all(photoIds.map((photoId) => photos.delete(photoId))); } await tx.objectStore(store).delete(item.id); await tx.done; } /** Deletes everything one account queued on this device, photos included. */ export async function clearQueueOf(ownerUserId: string): Promise { const db = await getDb(); const tx = db.transaction([...QUEUE_STORES, "pending-photos"], "readwrite"); const queues = QUEUE_STORES.map(async (store) => { const objectStore = tx.objectStore(store); const entries: QueuedValue[] = await objectStore.getAll(); await Promise.all( entries .filter((entry) => entry.ownerUserId === ownerUserId) .map((entry) => objectStore.delete(entry.id)), ); }); const photos = tx.objectStore("pending-photos"); const photoIds = await photos.index("ownerUserId").getAllKeys(ownerUserId); await Promise.all([...queues, ...photoIds.map((photoId) => photos.delete(photoId))]); await tx.done; }