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
@@ -1,4 +1,6 @@
|
||||
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";
|
||||
@@ -6,49 +8,139 @@ import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
|
||||
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
|
||||
import { CreateHarvestInput } from "@/lib/harvests/types";
|
||||
|
||||
type PendingStoreName =
|
||||
| "pending-inspections"
|
||||
| "pending-treatments"
|
||||
| "pending-feedings"
|
||||
| "pending-varroa-measurements"
|
||||
| "pending-swarm-control-checks"
|
||||
| "pending-harvests";
|
||||
/** 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;
|
||||
}
|
||||
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";
|
||||
|
||||
interface PendingEntry<T> {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
interface OfflineDB extends DBSchema {
|
||||
"pending-inspections": { key: number; value: PendingEntry<CreateInspectionInput> };
|
||||
"pending-treatments": { key: number; value: PendingEntry<CreateTreatmentInput> };
|
||||
"pending-feedings": { key: number; value: PendingEntry<CreateFeedingInput> };
|
||||
"pending-varroa-measurements": {
|
||||
key: number;
|
||||
value: PendingEntry<CreateVarroaMeasurementInput>;
|
||||
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<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-swarm-control-checks": {
|
||||
key: number;
|
||||
value: PendingEntry<CreateSwarmControlCheckInput>;
|
||||
};
|
||||
"pending-harvests": { key: number; value: PendingEntry<CreateHarvestInput> };
|
||||
"offline-packs": { key: string; value: StoredPack };
|
||||
meta: { key: string; value: unknown };
|
||||
}
|
||||
|
||||
const DB_NAME = "apicore-offline";
|
||||
// v1 only had "pending-inspections". v2 adds the other five quick-capture
|
||||
// types - existing users' already-queued inspections must survive the
|
||||
// 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 = 2;
|
||||
// v1 only had "pending-inspections", v2 added the other five quick-capture
|
||||
// types, v3 adds bulk entries, photos, offline packs and meta. 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 = 3;
|
||||
|
||||
const RECORD_STORES: readonly RecordStoreName[] = [
|
||||
"pending-inspections",
|
||||
"pending-treatments",
|
||||
"pending-feedings",
|
||||
"pending-varroa-measurements",
|
||||
"pending-swarm-control-checks",
|
||||
"pending-harvests",
|
||||
];
|
||||
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",
|
||||
};
|
||||
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;
|
||||
|
||||
function getDb(): Promise<IDBPDatabase<OfflineDB>> {
|
||||
/**
|
||||
* 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) {
|
||||
dbPromise = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
|
||||
const opening = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db, oldVersion) {
|
||||
if (oldVersion < 1) {
|
||||
db.createObjectStore("pending-inspections", { keyPath: "id", autoIncrement: true });
|
||||
@@ -66,128 +158,297 @@ function getDb(): Promise<IDBPDatabase<OfflineDB>> {
|
||||
});
|
||||
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");
|
||||
}
|
||||
},
|
||||
// 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;
|
||||
}
|
||||
|
||||
async function queue<T>(
|
||||
store: PendingStoreName,
|
||||
input: T,
|
||||
idempotencyKey?: string,
|
||||
function forget(connection: Promise<IDBPDatabase<OfflineDB>>): void {
|
||||
if (dbPromise === connection) {
|
||||
dbPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
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, { input, idempotencyKey, queuedAt: new Date().toISOString() } as never);
|
||||
const entry: Omit<PendingEntry<RecordQueueInputs[Store]>, "id"> = {
|
||||
input,
|
||||
idempotencyKey,
|
||||
ownerUserId,
|
||||
queuedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.add(store, entry as never);
|
||||
}
|
||||
|
||||
async function getPending<T>(store: PendingStoreName): Promise<PendingEntry<T>[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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 db.getAll(store) as Promise<PendingEntry<T>[]>;
|
||||
return (await db.getAll(store)) as PendingEntry<RecordQueueInputs[Store]>[];
|
||||
}
|
||||
|
||||
async function removePending(store: PendingStoreName, id: number): Promise<void> {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
export async function removeQueueEntry(store: QueueStoreName, id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete(store, id);
|
||||
}
|
||||
|
||||
async function countPending(store: PendingStoreName): Promise<number> {
|
||||
/** 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();
|
||||
return db.count(store);
|
||||
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;
|
||||
}
|
||||
|
||||
export type PendingInspection = PendingEntry<CreateInspectionInput>;
|
||||
export function queueInspection(input: CreateInspectionInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-inspections", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingInspections(): Promise<PendingInspection[]> {
|
||||
return getPending("pending-inspections");
|
||||
}
|
||||
export function removePendingInspection(id: number): Promise<void> {
|
||||
return removePending("pending-inspections", id);
|
||||
}
|
||||
export function countPendingInspections(): Promise<number> {
|
||||
return countPending("pending-inspections");
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export type PendingTreatment = PendingEntry<CreateTreatmentInput>;
|
||||
export function queueTreatment(input: CreateTreatmentInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-treatments", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingTreatments(): Promise<PendingTreatment[]> {
|
||||
return getPending("pending-treatments");
|
||||
}
|
||||
export function removePendingTreatment(id: number): Promise<void> {
|
||||
return removePending("pending-treatments", id);
|
||||
}
|
||||
export function countPendingTreatments(): Promise<number> {
|
||||
return countPending("pending-treatments");
|
||||
type QueuedValue = PendingEntry<unknown> | PendingBulkEntry;
|
||||
|
||||
interface QueuedRecord {
|
||||
store: QueueStoreName;
|
||||
entry: QueuedValue;
|
||||
}
|
||||
|
||||
export type PendingFeeding = PendingEntry<CreateFeedingInput>;
|
||||
export function queueFeeding(input: CreateFeedingInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-feedings", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingFeedings(): Promise<PendingFeeding[]> {
|
||||
return getPending("pending-feedings");
|
||||
}
|
||||
export function removePendingFeeding(id: number): Promise<void> {
|
||||
return removePending("pending-feedings", id);
|
||||
}
|
||||
export function countPendingFeedings(): Promise<number> {
|
||||
return countPending("pending-feedings");
|
||||
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 type PendingVarroaMeasurement = PendingEntry<CreateVarroaMeasurementInput>;
|
||||
export function queueVarroaMeasurement(input: CreateVarroaMeasurementInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-varroa-measurements", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingVarroaMeasurements(): Promise<PendingVarroaMeasurement[]> {
|
||||
return getPending("pending-varroa-measurements");
|
||||
}
|
||||
export function removePendingVarroaMeasurement(id: number): Promise<void> {
|
||||
return removePending("pending-varroa-measurements", id);
|
||||
}
|
||||
export function countPendingVarroaMeasurements(): Promise<number> {
|
||||
return countPending("pending-varroa-measurements");
|
||||
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;
|
||||
}
|
||||
|
||||
export type PendingSwarmControlCheck = PendingEntry<CreateSwarmControlCheckInput>;
|
||||
export function queueSwarmControlCheck(input: CreateSwarmControlCheckInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-swarm-control-checks", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingSwarmControlChecks(): Promise<PendingSwarmControlCheck[]> {
|
||||
return getPending("pending-swarm-control-checks");
|
||||
}
|
||||
export function removePendingSwarmControlCheck(id: number): Promise<void> {
|
||||
return removePending("pending-swarm-control-checks", id);
|
||||
}
|
||||
export function countPendingSwarmControlChecks(): Promise<number> {
|
||||
return countPending("pending-swarm-control-checks");
|
||||
/**
|
||||
* 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 type PendingHarvest = PendingEntry<CreateHarvestInput>;
|
||||
export function queueHarvest(input: CreateHarvestInput, idempotencyKey?: string): Promise<void> {
|
||||
return queue("pending-harvests", input, idempotencyKey);
|
||||
}
|
||||
export function getPendingHarvests(): Promise<PendingHarvest[]> {
|
||||
return getPending("pending-harvests");
|
||||
}
|
||||
export function removePendingHarvest(id: number): Promise<void> {
|
||||
return removePending("pending-harvests", id);
|
||||
}
|
||||
export function countPendingHarvests(): Promise<number> {
|
||||
return countPending("pending-harvests");
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function countAllPending(): Promise<number> {
|
||||
const counts = await Promise.all([
|
||||
countPendingInspections(),
|
||||
countPendingTreatments(),
|
||||
countPendingFeedings(),
|
||||
countPendingVarroaMeasurements(),
|
||||
countPendingSwarmControlChecks(),
|
||||
countPendingHarvests(),
|
||||
]);
|
||||
return counts.reduce((sum, count) => sum + count, 0);
|
||||
export interface QueueItem {
|
||||
store: string;
|
||||
id: number | string;
|
||||
label: string;
|
||||
queuedAt: string;
|
||||
lastError?: QueueError;
|
||||
photoCount?: number;
|
||||
}
|
||||
|
||||
function labelOf({ store, entry }: QueuedRecord): string {
|
||||
if (store === "pending-bulk") {
|
||||
return BULK_LABELS[(entry as PendingBulkEntry).kind];
|
||||
}
|
||||
return RECORD_LABELS[store];
|
||||
}
|
||||
|
||||
/** 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-inspections" || !entry.idempotencyKey) {
|
||||
return item;
|
||||
}
|
||||
const photoCount = await db.countFromIndex(
|
||||
"pending-photos",
|
||||
"inspectionIdempotencyKey",
|
||||
entry.idempotencyKey,
|
||||
);
|
||||
return photoCount > 0 ? { ...item, photoCount } : item;
|
||||
}),
|
||||
);
|
||||
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 included. */
|
||||
export async function clearQueueOf(ownerUserId: string): Promise<void> {
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user