feat: extend offline queueing to all quick-capture forms

Only Durchsicht had an offline fallback (queue to IndexedDB, sync on
reconnect); Behandlung, Fütterung, Varroamessung, Schwarmkontrolle and
Ernte just failed outright with no signal - exactly the situation a
beekeeper is in at the actual hive. Generalizes lib/offline/db.ts and
sync.ts to six queues (version-bumped IndexedDB schema, existing queued
inspections preserved), wires the same "server rejected it vs. no
network" fallback into all five remaining */new pages, and updates the
sync badge to count/drain all six.

Also: doc-hygiene pass (ARCHITECTURE.md/TASKS.md) to replace the stale
pg_dump/CronJob backup plan with the in-app BackupModule that actually
shipped earlier this session.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-05 17:45:32 +02:00
1 parent ac45514356
commit 1f67cd5548
11 files changed
+297 -46

No files matched your search

+5 -5
View File
@@ -111,10 +111,10 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
**Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `[email protected]`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren. **Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `[email protected]`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Aktuell **noch nicht umgesetzt** (offener Punkt, siehe TASKS.md) – empfohlene Strategie: **Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie:
- Tägliches logisches Backup via `pg_dump -Fc` (custom format, komprimiert, für `pg_restore` geeignet), z. B. als Kubernetes-`CronJob` gegen den `apicore-postgres`-Service, Ablage auf einem separaten PVC oder Objektspeicher außerhalb des Postgres-eigenen Volumes. - **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert wirklich alle Modelle (Betriebsdaten, Benutzer/Rollen/Rechte, Audit-Log, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei; Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
- Kontinuierliches WAL-Archiving/PITR (Point-in-Time Recovery), falls die Storage-Class/das Backup-Tooling das unterstützt, für Wiederherstellung auf einen beliebigen Zeitpunkt zwischen den täglichen Dumps. - **Automatischer Zeitplan:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer.
- Restore-Test: `pg_restore` eines Dumps in eine isolierte Datenbank, danach `npx prisma migrate deploy` zur Kontrolle, dass der Migrationsstand konsistent ist (`prisma migrate status`). - **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen.
**Restore-Ablauf (Kurzfassung):** 1) Zielinstanz/-datenbank bereitstellen. 2) `pg_restore` (oder WAL-Replay bis zum Zielzeitpunkt bei PITR). 3) `npx prisma migrate status` prüfen. 4) `npx prisma migrate deploy` falls seit dem Dump neue Migrationen hinzugekommen sind. 5) `GET /health/ready` gegen die wiederhergestellte Instanz aufrufen, um DB-Erreichbarkeit zu bestätigen, bevor Verkehr umgeschaltet wird. Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
@@ -9,6 +9,8 @@ import { fetchColonies } from "@/lib/colonies/api";
import { createFeeding } from "@/lib/feedings/api"; import { createFeeding } from "@/lib/feedings/api";
import { FeedType } from "@/lib/feedings/types"; import { FeedType } from "@/lib/feedings/types";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels"; import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import { ApiError } from "@/lib/auth/api-client";
import { queueFeeding } from "@/lib/offline/db";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -59,8 +61,15 @@ export default function NewFeedingPage() {
toast.success("Fütterung gespeichert"); toast.success("Fütterung gespeichert");
router.push(`/colonies/${feeding.colonyId}`); router.push(`/colonies/${feeding.colonyId}`);
}, },
onError: () => { onError: (mutationError, variables) => {
setError("Fütterung konnte nicht gespeichert werden."); if (mutationError instanceof ApiError) {
setError("Fütterung konnte nicht gespeichert werden.");
return;
}
void queueFeeding(variables).then(() => {
toast.info("Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert.");
router.push(`/colonies/${variables.colonyId}`);
});
}, },
}); });
@@ -8,6 +8,8 @@ import { Boxes } from "lucide-react";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { createHarvest } from "@/lib/harvests/api"; import { createHarvest } from "@/lib/harvests/api";
import { ApiError } from "@/lib/auth/api-client";
import { queueHarvest } from "@/lib/offline/db";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -53,8 +55,15 @@ export default function NewHarvestPage() {
toast.success("Ernte gespeichert"); toast.success("Ernte gespeichert");
router.push(`/harvests/${harvest.id}`); router.push(`/harvests/${harvest.id}`);
}, },
onError: () => { onError: (mutationError, variables) => {
setError("Ernte konnte nicht gespeichert werden."); if (mutationError instanceof ApiError) {
setError("Ernte konnte nicht gespeichert werden.");
return;
}
void queueHarvest(variables).then(() => {
toast.info("Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert.");
router.push("/harvests");
});
}, },
}); });
@@ -9,6 +9,8 @@ import { fetchColonies } from "@/lib/colonies/api";
import { createSwarmControlCheck } from "@/lib/swarm-control/api"; import { createSwarmControlCheck } from "@/lib/swarm-control/api";
import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels"; import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels";
import { SwarmControlAction, SwarmMood } from "@/lib/swarm-control/types"; import { SwarmControlAction, SwarmMood } from "@/lib/swarm-control/types";
import { ApiError } from "@/lib/auth/api-client";
import { queueSwarmControlCheck } from "@/lib/offline/db";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -57,8 +59,15 @@ export default function NewSwarmControlCheckPage() {
toast.success("Schwarmkontrolle gespeichert"); toast.success("Schwarmkontrolle gespeichert");
router.push(`/colonies/${check.colonyId}`); router.push(`/colonies/${check.colonyId}`);
}, },
onError: () => { onError: (mutationError, variables) => {
setError("Schwarmkontrolle konnte nicht gespeichert werden."); if (mutationError instanceof ApiError) {
setError("Schwarmkontrolle konnte nicht gespeichert werden.");
return;
}
void queueSwarmControlCheck(variables).then(() => {
toast.info("Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert.");
router.push(`/colonies/${variables.colonyId}`);
});
}, },
}); });
@@ -7,6 +7,8 @@ import { toast } from "sonner";
import { Syringe } from "lucide-react"; import { Syringe } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { createTreatment } from "@/lib/treatments/api"; import { createTreatment } from "@/lib/treatments/api";
import { ApiError } from "@/lib/auth/api-client";
import { queueTreatment } from "@/lib/offline/db";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -54,8 +56,15 @@ export default function NewTreatmentPage() {
toast.success("Behandlung gespeichert"); toast.success("Behandlung gespeichert");
router.push(`/colonies/${treatment.colonyId}`); router.push(`/colonies/${treatment.colonyId}`);
}, },
onError: () => { onError: (mutationError, variables) => {
setError("Behandlung konnte nicht gespeichert werden."); if (mutationError instanceof ApiError) {
setError("Behandlung konnte nicht gespeichert werden.");
return;
}
void queueTreatment(variables).then(() => {
toast.info("Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.");
router.push(`/colonies/${variables.colonyId}`);
});
}, },
}); });
@@ -9,6 +9,8 @@ import { fetchColonies } from "@/lib/colonies/api";
import { createVarroaMeasurement } from "@/lib/varroa/api"; import { createVarroaMeasurement } from "@/lib/varroa/api";
import { VarroaMethod } from "@/lib/varroa/types"; import { VarroaMethod } from "@/lib/varroa/types";
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels"; import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { ApiError } from "@/lib/auth/api-client";
import { queueVarroaMeasurement } from "@/lib/offline/db";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -56,8 +58,15 @@ export default function NewVarroaMeasurementPage() {
toast.success("Varroamessung gespeichert"); toast.success("Varroamessung gespeichert");
router.push(`/colonies/${measurement.colonyId}`); router.push(`/colonies/${measurement.colonyId}`);
}, },
onError: () => { onError: (mutationError, variables) => {
setError("Varroamessung konnte nicht gespeichert werden."); if (mutationError instanceof ApiError) {
setError("Varroamessung konnte nicht gespeichert werden.");
return;
}
void queueVarroaMeasurement(variables).then(() => {
toast.info("Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert.");
router.push(`/colonies/${variables.colonyId}`);
});
}, },
}); });
@@ -5,17 +5,17 @@ import { CloudOff, RefreshCw } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { countPendingInspections } from "@/lib/offline/db"; import { countAllPending } from "@/lib/offline/db";
import { syncPendingInspections } from "@/lib/offline/sync"; import { syncPendingInspections } from "@/lib/offline/sync";
const PENDING_QUERY_KEY = ["offline-pending-inspections"]; const PENDING_QUERY_KEY = ["offline-pending-entries"];
export function OfflineSyncBadge() { export function OfflineSyncBadge() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: pending = 0 } = useQuery({ const { data: pending = 0 } = useQuery({
queryKey: PENDING_QUERY_KEY, queryKey: PENDING_QUERY_KEY,
queryFn: countPendingInspections, queryFn: countAllPending,
refetchInterval: 15000, refetchInterval: 15000,
}); });
@@ -24,7 +24,7 @@ export function OfflineSyncBadge() {
onSuccess: (result) => { onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: PENDING_QUERY_KEY }); void queryClient.invalidateQueries({ queryKey: PENDING_QUERY_KEY });
if (result.synced > 0) { if (result.synced > 0) {
toast.success(`${result.synced} Durchsicht(en) synchronisiert`); toast.success(`${result.synced} Eintrag/Einträge synchronisiert`);
} }
}, },
}); });
@@ -51,7 +51,7 @@ export function OfflineSyncBadge() {
role="button" role="button"
tabIndex={0} tabIndex={0}
className="cursor-pointer" className="cursor-pointer"
title="Ausstehende Durchsichten synchronisieren" title="Ausstehende Einträge synchronisieren"
onClick={() => syncMutation.mutate()} onClick={() => syncMutation.mutate()}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
+155 -20
View File
@@ -1,52 +1,187 @@
import { openDB, DBSchema, IDBPDatabase } from "idb"; import { openDB, DBSchema, IDBPDatabase } from "idb";
import { CreateInspectionInput } from "@/lib/inspections/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";
interface OfflineDB extends DBSchema { type PendingStoreName =
"pending-inspections": { | "pending-inspections"
key: number; | "pending-treatments"
value: PendingInspection; | "pending-feedings"
}; | "pending-varroa-measurements"
} | "pending-swarm-control-checks"
| "pending-harvests";
export interface PendingInspection { interface PendingEntry<T> {
id: number; id: number;
input: CreateInspectionInput; input: T;
queuedAt: string; queuedAt: string;
} }
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>;
};
"pending-swarm-control-checks": {
key: number;
value: PendingEntry<CreateSwarmControlCheckInput>;
};
"pending-harvests": { key: number; value: PendingEntry<CreateHarvestInput> };
}
const DB_NAME = "apicore-offline"; const DB_NAME = "apicore-offline";
const DB_VERSION = 1; // v1 only had "pending-inspections". v2 adds the other five quick-capture
const STORE_NAME = "pending-inspections"; // 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;
let dbPromise: Promise<IDBPDatabase<OfflineDB>> | null = null; let dbPromise: Promise<IDBPDatabase<OfflineDB>> | null = null;
function getDb(): Promise<IDBPDatabase<OfflineDB>> { function getDb(): Promise<IDBPDatabase<OfflineDB>> {
if (!dbPromise) { if (!dbPromise) {
dbPromise = openDB<OfflineDB>(DB_NAME, DB_VERSION, { dbPromise = openDB<OfflineDB>(DB_NAME, DB_VERSION, {
upgrade(db) { upgrade(db, oldVersion) {
db.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true }); 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 });
}
}, },
}); });
} }
return dbPromise; return dbPromise;
} }
export async function queueInspection(input: CreateInspectionInput): Promise<void> { async function queue<T>(store: PendingStoreName, input: T): Promise<void> {
const db = await getDb(); const db = await getDb();
await db.add(STORE_NAME, { input, queuedAt: new Date().toISOString() } as PendingInspection); await db.add(store, { input, queuedAt: new Date().toISOString() } as never);
} }
export async function getPendingInspections(): Promise<PendingInspection[]> { async function getPending<T>(store: PendingStoreName): Promise<PendingEntry<T>[]> {
const db = await getDb(); const db = await getDb();
return db.getAll(STORE_NAME); return db.getAll(store) as Promise<PendingEntry<T>[]>;
} }
export async function removePendingInspection(id: number): Promise<void> { async function removePending(store: PendingStoreName, id: number): Promise<void> {
const db = await getDb(); const db = await getDb();
await db.delete(STORE_NAME, id); await db.delete(store, id);
} }
export async function countPendingInspections(): Promise<number> { async function countPending(store: PendingStoreName): Promise<number> {
const db = await getDb(); const db = await getDb();
return db.count(STORE_NAME); return db.count(store);
}
export type PendingInspection = PendingEntry<CreateInspectionInput>;
export function queueInspection(input: CreateInspectionInput): Promise<void> {
return queue("pending-inspections", input);
}
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");
}
export type PendingTreatment = PendingEntry<CreateTreatmentInput>;
export function queueTreatment(input: CreateTreatmentInput): Promise<void> {
return queue("pending-treatments", input);
}
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");
}
export type PendingFeeding = PendingEntry<CreateFeedingInput>;
export function queueFeeding(input: CreateFeedingInput): Promise<void> {
return queue("pending-feedings", input);
}
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");
}
export type PendingVarroaMeasurement = PendingEntry<CreateVarroaMeasurementInput>;
export function queueVarroaMeasurement(input: CreateVarroaMeasurementInput): Promise<void> {
return queue("pending-varroa-measurements", input);
}
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 type PendingSwarmControlCheck = PendingEntry<CreateSwarmControlCheckInput>;
export function queueSwarmControlCheck(input: CreateSwarmControlCheckInput): Promise<void> {
return queue("pending-swarm-control-checks", input);
}
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");
}
export type PendingHarvest = PendingEntry<CreateHarvestInput>;
export function queueHarvest(input: CreateHarvestInput): Promise<void> {
return queue("pending-harvests", input);
}
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 countAllPending(): Promise<number> {
const counts = await Promise.all([
countPendingInspections(),
countPendingTreatments(),
countPendingFeedings(),
countPendingVarroaMeasurements(),
countPendingSwarmControlChecks(),
countPendingHarvests(),
]);
return counts.reduce((sum, count) => sum + count, 0);
} }
@@ -11,6 +11,19 @@ vi.mock("@/lib/inspections/api", () => ({
vi.mock("./db", () => ({ vi.mock("./db", () => ({
getPendingInspections: vi.fn(), getPendingInspections: vi.fn(),
removePendingInspection: vi.fn(), removePendingInspection: vi.fn(),
// The other five queues are exercised by their own dedicated tests
// elsewhere; here they just need to resolve empty so runSync()'s
// Promise.all doesn't touch their (unmocked, real) create functions.
getPendingTreatments: vi.fn().mockResolvedValue([]),
removePendingTreatment: vi.fn(),
getPendingFeedings: vi.fn().mockResolvedValue([]),
removePendingFeeding: vi.fn(),
getPendingVarroaMeasurements: vi.fn().mockResolvedValue([]),
removePendingVarroaMeasurement: vi.fn(),
getPendingSwarmControlChecks: vi.fn().mockResolvedValue([]),
removePendingSwarmControlCheck: vi.fn(),
getPendingHarvests: vi.fn().mockResolvedValue([]),
removePendingHarvest: vi.fn(),
})); }));
const mockedCreateInspection = vi.mocked(createInspection); const mockedCreateInspection = vi.mocked(createInspection);
+63 -5
View File
@@ -1,6 +1,24 @@
import { ApiError } from "@/lib/auth/api-client"; import { ApiError } from "@/lib/auth/api-client";
import { createInspection } from "@/lib/inspections/api"; import { createInspection } from "@/lib/inspections/api";
import { getPendingInspections, removePendingInspection } from "./db"; import { createTreatment } from "@/lib/treatments/api";
import { createFeeding } from "@/lib/feedings/api";
import { createVarroaMeasurement } from "@/lib/varroa/api";
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
import { createHarvest } from "@/lib/harvests/api";
import {
getPendingInspections,
removePendingInspection,
getPendingTreatments,
removePendingTreatment,
getPendingFeedings,
removePendingFeeding,
getPendingVarroaMeasurements,
removePendingVarroaMeasurement,
getPendingSwarmControlChecks,
removePendingSwarmControlCheck,
getPendingHarvests,
removePendingHarvest,
} from "./db";
export interface SyncResult { export interface SyncResult {
synced: number; synced: number;
@@ -9,6 +27,9 @@ export interface SyncResult {
let inFlightSync: Promise<SyncResult> | null = null; let inFlightSync: Promise<SyncResult> | null = null;
// Drains all six offline queues in one pass (not just inspections) - a
// beekeeper working a row of hives with no signal may have queued several
// different record types before connectivity returns.
export function syncPendingInspections(): Promise<SyncResult> { export function syncPendingInspections(): Promise<SyncResult> {
if (!inFlightSync) { if (!inFlightSync) {
inFlightSync = runSync().finally(() => { inFlightSync = runSync().finally(() => {
@@ -18,15 +39,25 @@ export function syncPendingInspections(): Promise<SyncResult> {
return inFlightSync; return inFlightSync;
} }
async function runSync(): Promise<SyncResult> { // Generic drain: tries each queued entry in order, removes it on success,
const pending = await getPendingInspections(); // stops at the first non-ApiError (real network failure, still offline) so
// the rest of the queue is left untouched for the next sync attempt. An
// ApiError (the server actively rejected it, e.g. validation) is counted as
// failed and left in the queue too, rather than silently dropping data the
// user entered - the sync badge stays visible so it's not forgotten.
async function drain<T>(
getPending: () => Promise<{ id: number; input: T }[]>,
removePending: (id: number) => Promise<void>,
create: (input: T) => Promise<unknown>,
): Promise<SyncResult> {
const pending = await getPending();
let synced = 0; let synced = 0;
let failed = 0; let failed = 0;
for (const entry of pending) { for (const entry of pending) {
try { try {
await createInspection(entry.input); await create(entry.input);
await removePendingInspection(entry.id); await removePending(entry.id);
synced += 1; synced += 1;
} catch (error) { } catch (error) {
failed += 1; failed += 1;
@@ -38,3 +69,30 @@ async function runSync(): Promise<SyncResult> {
return { synced, failed }; return { synced, failed };
} }
async function runSync(): Promise<SyncResult> {
const results = await Promise.all([
drain(getPendingInspections, removePendingInspection, createInspection),
drain(getPendingTreatments, removePendingTreatment, createTreatment),
drain(getPendingFeedings, removePendingFeeding, createFeeding),
drain(
getPendingVarroaMeasurements,
removePendingVarroaMeasurement,
createVarroaMeasurement,
),
drain(
getPendingSwarmControlChecks,
removePendingSwarmControlCheck,
createSwarmControlCheck,
),
drain(getPendingHarvests, removePendingHarvest, createHarvest),
]);
return results.reduce(
(total, result) => ({
synced: total.synced + result.synced,
failed: total.failed + result.failed,
}),
{ synced: 0, failed: 0 },
);
}
+1 -1
View File
@@ -147,7 +147,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
- [x] Prisma-Migrationen reproduzierbar (kein `db push` in Produktion, nur `migrate deploy`) - [x] Prisma-Migrationen reproduzierbar (kein `db push` in Produktion, nur `migrate deploy`)
- [x] Backup-/Restore-Strategie in ARCHITECTURE.md dokumentiert - [x] Backup-/Restore-Strategie in ARCHITECTURE.md dokumentiert
- [x] `/health/ready` prüft DB-Erreichbarkeit - [x] `/health/ready` prüft DB-Erreichbarkeit
- [ ] Backup-CronJob (`pg_dump -Fc` gegen `apicore-postgres`) im Cluster einrichten, seit PostgreSQL selbst gehostet wird (`deploy/k8s/postgres/`), siehe ARCHITECTURE.md Abschnitt 8 - [x] Automatisches Backup umgesetzt - abweichend von der ursprünglich geplanten `pg_dump`-CronJob-Strategie als eigenes `BackupModule` (Anwendungsebene, JSON-Export/-Import aller Modelle inkl. Dokumente, cron-gesteuert via `@nestjs/schedule`, Zeitplan per Env-Variable ODER GUI unter Verwaltung > Backup konfigurierbar). Siehe ARCHITECTURE.md Abschnitt 8.
### 21. Datenexport ### 21. Datenexport