Recorder (webm/opus oder mp4, 24 kbit/s, Warnung bei 150 s, Stopp bei 180 s oder im Hintergrund; Mikrofon und Wake-Lock werden in jedem Fall freigegeben, auch bei Fehlern und ausgestecktem Mikrofon) und IndexedDB v6 mit Store pending-voice (Index ownerUserId); volle Speicherquote ergibt VoiceStorageFullError ohne Datenverlust; Abmelden löscht nur die eigenen Notizen. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
668 lines
22 KiB
TypeScript
668 lines
22 KiB
TypeScript
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<T> {
|
|
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;
|
|
}
|
|
|
|
/** A voice note recorded on this device, waiting to be uploaded (Sprachnotiz). */
|
|
export interface StoredVoiceNote {
|
|
id: string;
|
|
ownerUserId: string;
|
|
blob: Blob;
|
|
mime: string;
|
|
durationSec: number;
|
|
colonyId: string;
|
|
inspectionDate: string;
|
|
createdAt: string;
|
|
uploadIdempotencyKey: string;
|
|
state: "recorded" | "uploading";
|
|
lastError?: QueueError;
|
|
}
|
|
|
|
export interface StoredPack {
|
|
apiaryId: string;
|
|
ownerUserId: string;
|
|
savedAt: string;
|
|
pack: OfflinePackV1;
|
|
}
|
|
|
|
type RecordStores = {
|
|
[Store in RecordStoreName]: { key: number; value: PendingEntry<RecordQueueInputs[Store]> };
|
|
};
|
|
|
|
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 };
|
|
};
|
|
"pending-voice": { key: string; value: StoredVoiceNote; indexes: { 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), v6 adds voice
|
|
// notes (Sprache). 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 = 6;
|
|
|
|
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<RecordStoreName, string> = {
|
|
"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<BulkKind, string> = {
|
|
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<IDBPDatabase<OfflineDB>> | 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<IDBPDatabase<OfflineDB>> {
|
|
if (!dbPromise) {
|
|
const opening = openDB<OfflineDB>(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 });
|
|
}
|
|
if (oldVersion < 6) {
|
|
const voice = db.createObjectStore("pending-voice", { keyPath: "id" });
|
|
voice.createIndex("ownerUserId", "ownerUserId");
|
|
}
|
|
},
|
|
// 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<IDBPDatabase<OfflineDB>>): void {
|
|
if (dbPromise === connection) {
|
|
dbPromise = null;
|
|
}
|
|
}
|
|
|
|
/** A new queue entry; the store gives it its id when it is added. */
|
|
export function newPendingEntry<T>(
|
|
input: T,
|
|
idempotencyKey: string,
|
|
ownerUserId: string,
|
|
): Omit<PendingEntry<T>, "id"> {
|
|
return { input, idempotencyKey, ownerUserId, queuedAt: new Date().toISOString() };
|
|
}
|
|
|
|
async function queue<Store extends RecordStoreName>(
|
|
store: Store,
|
|
input: RecordQueueInputs[Store],
|
|
idempotencyKey: string,
|
|
ownerUserId: string,
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
await db.add(store, newPendingEntry(input, idempotencyKey, ownerUserId) as never);
|
|
}
|
|
|
|
export function queueInspection(
|
|
input: CreateInspectionInput,
|
|
idempotencyKey: string,
|
|
ownerUserId: string,
|
|
): Promise<void> {
|
|
return queue("pending-inspections", input, idempotencyKey, ownerUserId);
|
|
}
|
|
|
|
export function queueTreatment(
|
|
input: CreateTreatmentInput,
|
|
idempotencyKey: string,
|
|
ownerUserId: string,
|
|
): Promise<void> {
|
|
return queue("pending-treatments", input, idempotencyKey, ownerUserId);
|
|
}
|
|
|
|
export function queueFeeding(
|
|
input: CreateFeedingInput,
|
|
idempotencyKey: string,
|
|
ownerUserId: string,
|
|
): Promise<void> {
|
|
return queue("pending-feedings", input, idempotencyKey, ownerUserId);
|
|
}
|
|
|
|
export function queueVarroaMeasurement(
|
|
input: CreateVarroaMeasurementInput,
|
|
idempotencyKey: string,
|
|
ownerUserId: string,
|
|
): Promise<void> {
|
|
return queue("pending-varroa-measurements", input, idempotencyKey, ownerUserId);
|
|
}
|
|
|
|
export function queueSwarmControlCheck(
|
|
input: CreateSwarmControlCheckInput,
|
|
idempotencyKey: string,
|
|
ownerUserId: string,
|
|
): Promise<void> {
|
|
return queue("pending-swarm-control-checks", input, idempotencyKey, ownerUserId);
|
|
}
|
|
|
|
export function queueHarvest(
|
|
input: CreateHarvestInput,
|
|
idempotencyKey: string,
|
|
ownerUserId: string,
|
|
): Promise<void> {
|
|
return queue("pending-harvests", input, idempotencyKey, ownerUserId);
|
|
}
|
|
|
|
export function queuePhenologyReport(
|
|
input: CreatePhenologyReportInput,
|
|
idempotencyKey: string,
|
|
ownerUserId: string,
|
|
): Promise<void> {
|
|
return queue("pending-phenology-reports", input, idempotencyKey, ownerUserId);
|
|
}
|
|
|
|
export function queueHiveChange(
|
|
input: HiveChangeQueueInput,
|
|
idempotencyKey: string,
|
|
ownerUserId: string,
|
|
): Promise<void> {
|
|
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<void> {
|
|
const db = await getDb();
|
|
const entry: Omit<PendingBulkEntry, "id"> = {
|
|
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 extends RecordStoreName>(
|
|
store: Store,
|
|
): Promise<PendingEntry<RecordQueueInputs[Store]>[]> {
|
|
const db = await getDb();
|
|
return (await db.getAll(store)) as PendingEntry<RecordQueueInputs[Store]>[];
|
|
}
|
|
|
|
/** One entry as it is stored right now, or undefined once it is gone. */
|
|
export async function getPendingEntry<Store extends RecordStoreName>(
|
|
store: Store,
|
|
id: number,
|
|
): Promise<PendingEntry<RecordQueueInputs[Store]> | undefined> {
|
|
const db = await getDb();
|
|
return (await db.get(store, id)) as PendingEntry<RecordQueueInputs[Store]> | undefined;
|
|
}
|
|
|
|
/** Every bulk entry, oldest first, of every owner. */
|
|
export async function getBulkEntries(): Promise<PendingBulkEntry[]> {
|
|
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<PendingBulkEntry | undefined> {
|
|
const db = await getDb();
|
|
return db.get("pending-bulk", id);
|
|
}
|
|
|
|
export async function removeQueueEntry(store: QueueStoreName, id: number): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<unknown> | PendingBulkEntry;
|
|
|
|
interface QueuedRecord {
|
|
store: QueueStoreName;
|
|
entry: QueuedValue;
|
|
}
|
|
|
|
async function readQueue(): Promise<QueuedRecord[]> {
|
|
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<StoredPhoto[]> {
|
|
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<QueueSummary> {
|
|
const waiting: { ownerUserId?: string; lastError?: QueueError }[] = [
|
|
...(await readQueue()).map(({ entry }) => entry),
|
|
...(await readPhotosWaitingOnTheirOwn()),
|
|
];
|
|
return waiting.reduce<QueueSummary>(
|
|
(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<void> {
|
|
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<CreateInspectionInput> = { ...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<OfflineDB>,
|
|
entry: PendingBulkEntry,
|
|
): Promise<QueueItem["resendWithout"]> {
|
|
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<QueueItem[]> {
|
|
const db = await getDb();
|
|
const own = (await readQueue()).filter(({ entry }) => !isForeignEntry(entry, currentUserId));
|
|
const items = await Promise.all(
|
|
own.map(async (record): Promise<QueueItem> => {
|
|
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<unknown>).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<void> {
|
|
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 and voice notes included. */
|
|
export async function clearQueueOf(ownerUserId: string): Promise<void> {
|
|
const db = await getDb();
|
|
const tx = db.transaction([...QUEUE_STORES, "pending-photos", "pending-voice"], "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);
|
|
const voice = tx.objectStore("pending-voice");
|
|
const voiceIds = await voice.index("ownerUserId").getAllKeys(ownerUserId);
|
|
await Promise.all([
|
|
...queues,
|
|
...photoIds.map((photoId) => photos.delete(photoId)),
|
|
...voiceIds.map((voiceId) => voice.delete(voiceId)),
|
|
]);
|
|
await tx.done;
|
|
}
|