diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6bb60e4..1cec2f7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 `admin@apicore.local`/`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. -- 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. -- 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`). +- **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`. +- **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:** Ü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. diff --git a/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx b/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx index c115159..f77e74e 100644 --- a/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx @@ -9,6 +9,8 @@ import { fetchColonies } from "@/lib/colonies/api"; import { createFeeding } from "@/lib/feedings/api"; import { FeedType } from "@/lib/feedings/types"; 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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -59,8 +61,15 @@ export default function NewFeedingPage() { toast.success("Fütterung gespeichert"); router.push(`/colonies/${feeding.colonyId}`); }, - onError: () => { - setError("Fütterung konnte nicht gespeichert werden."); + onError: (mutationError, variables) => { + 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}`); + }); }, }); diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx index bc3e009..d8ca40e 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx @@ -8,6 +8,8 @@ import { Boxes } from "lucide-react"; import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -53,8 +55,15 @@ export default function NewHarvestPage() { toast.success("Ernte gespeichert"); router.push(`/harvests/${harvest.id}`); }, - onError: () => { - setError("Ernte konnte nicht gespeichert werden."); + onError: (mutationError, variables) => { + 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"); + }); }, }); diff --git a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx index 566ad69..18c64b1 100644 --- a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx @@ -9,6 +9,8 @@ import { fetchColonies } from "@/lib/colonies/api"; import { createSwarmControlCheck } from "@/lib/swarm-control/api"; import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels"; 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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -57,8 +59,15 @@ export default function NewSwarmControlCheckPage() { toast.success("Schwarmkontrolle gespeichert"); router.push(`/colonies/${check.colonyId}`); }, - onError: () => { - setError("Schwarmkontrolle konnte nicht gespeichert werden."); + onError: (mutationError, variables) => { + 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}`); + }); }, }); diff --git a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx index 65d02ca..8f3102d 100644 --- a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx @@ -7,6 +7,8 @@ import { toast } from "sonner"; import { Syringe } from "lucide-react"; import { fetchColonies } from "@/lib/colonies/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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -54,8 +56,15 @@ export default function NewTreatmentPage() { toast.success("Behandlung gespeichert"); router.push(`/colonies/${treatment.colonyId}`); }, - onError: () => { - setError("Behandlung konnte nicht gespeichert werden."); + onError: (mutationError, variables) => { + 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}`); + }); }, }); diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx index 95f9518..64f6f71 100644 --- a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx @@ -9,6 +9,8 @@ import { fetchColonies } from "@/lib/colonies/api"; import { createVarroaMeasurement } from "@/lib/varroa/api"; import { VarroaMethod } from "@/lib/varroa/types"; 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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -56,8 +58,15 @@ export default function NewVarroaMeasurementPage() { toast.success("Varroamessung gespeichert"); router.push(`/colonies/${measurement.colonyId}`); }, - onError: () => { - setError("Varroamessung konnte nicht gespeichert werden."); + onError: (mutationError, variables) => { + 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}`); + }); }, }); diff --git a/ApiCore-Frontend/src/components/offline-sync-badge.tsx b/ApiCore-Frontend/src/components/offline-sync-badge.tsx index 34e1df4..aae8f99 100644 --- a/ApiCore-Frontend/src/components/offline-sync-badge.tsx +++ b/ApiCore-Frontend/src/components/offline-sync-badge.tsx @@ -5,17 +5,17 @@ import { CloudOff, RefreshCw } from "lucide-react"; import { toast } from "sonner"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Badge } from "@/components/ui/badge"; -import { countPendingInspections } from "@/lib/offline/db"; +import { countAllPending } from "@/lib/offline/db"; import { syncPendingInspections } from "@/lib/offline/sync"; -const PENDING_QUERY_KEY = ["offline-pending-inspections"]; +const PENDING_QUERY_KEY = ["offline-pending-entries"]; export function OfflineSyncBadge() { const queryClient = useQueryClient(); const { data: pending = 0 } = useQuery({ queryKey: PENDING_QUERY_KEY, - queryFn: countPendingInspections, + queryFn: countAllPending, refetchInterval: 15000, }); @@ -24,7 +24,7 @@ export function OfflineSyncBadge() { onSuccess: (result) => { void queryClient.invalidateQueries({ queryKey: PENDING_QUERY_KEY }); 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" tabIndex={0} className="cursor-pointer" - title="Ausstehende Durchsichten synchronisieren" + title="Ausstehende Einträge synchronisieren" onClick={() => syncMutation.mutate()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { diff --git a/ApiCore-Frontend/src/lib/offline/db.ts b/ApiCore-Frontend/src/lib/offline/db.ts index d65813c..32c4481 100644 --- a/ApiCore-Frontend/src/lib/offline/db.ts +++ b/ApiCore-Frontend/src/lib/offline/db.ts @@ -1,52 +1,187 @@ import { openDB, DBSchema, IDBPDatabase } from "idb"; 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 { - "pending-inspections": { - key: number; - value: PendingInspection; - }; -} +type PendingStoreName = + | "pending-inspections" + | "pending-treatments" + | "pending-feedings" + | "pending-varroa-measurements" + | "pending-swarm-control-checks" + | "pending-harvests"; -export interface PendingInspection { +interface PendingEntry { id: number; - input: CreateInspectionInput; + input: T; queuedAt: string; } +interface OfflineDB extends DBSchema { + "pending-inspections": { key: number; value: PendingEntry }; + "pending-treatments": { key: number; value: PendingEntry }; + "pending-feedings": { key: number; value: PendingEntry }; + "pending-varroa-measurements": { + key: number; + value: PendingEntry; + }; + "pending-swarm-control-checks": { + key: number; + value: PendingEntry; + }; + "pending-harvests": { key: number; value: PendingEntry }; +} + const DB_NAME = "apicore-offline"; -const DB_VERSION = 1; -const STORE_NAME = "pending-inspections"; +// 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; let dbPromise: Promise> | null = null; function getDb(): Promise> { if (!dbPromise) { dbPromise = openDB(DB_NAME, DB_VERSION, { - upgrade(db) { - db.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true }); + 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 }); + } }, }); } return dbPromise; } -export async function queueInspection(input: CreateInspectionInput): Promise { +async function queue(store: PendingStoreName, input: T): Promise { 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 { +async function getPending(store: PendingStoreName): Promise[]> { const db = await getDb(); - return db.getAll(STORE_NAME); + return db.getAll(store) as Promise[]>; } -export async function removePendingInspection(id: number): Promise { +async function removePending(store: PendingStoreName, id: number): Promise { const db = await getDb(); - await db.delete(STORE_NAME, id); + await db.delete(store, id); } -export async function countPendingInspections(): Promise { +async function countPending(store: PendingStoreName): Promise { const db = await getDb(); - return db.count(STORE_NAME); + return db.count(store); +} + +export type PendingInspection = PendingEntry; +export function queueInspection(input: CreateInspectionInput): Promise { + return queue("pending-inspections", input); +} +export function getPendingInspections(): Promise { + return getPending("pending-inspections"); +} +export function removePendingInspection(id: number): Promise { + return removePending("pending-inspections", id); +} +export function countPendingInspections(): Promise { + return countPending("pending-inspections"); +} + +export type PendingTreatment = PendingEntry; +export function queueTreatment(input: CreateTreatmentInput): Promise { + return queue("pending-treatments", input); +} +export function getPendingTreatments(): Promise { + return getPending("pending-treatments"); +} +export function removePendingTreatment(id: number): Promise { + return removePending("pending-treatments", id); +} +export function countPendingTreatments(): Promise { + return countPending("pending-treatments"); +} + +export type PendingFeeding = PendingEntry; +export function queueFeeding(input: CreateFeedingInput): Promise { + return queue("pending-feedings", input); +} +export function getPendingFeedings(): Promise { + return getPending("pending-feedings"); +} +export function removePendingFeeding(id: number): Promise { + return removePending("pending-feedings", id); +} +export function countPendingFeedings(): Promise { + return countPending("pending-feedings"); +} + +export type PendingVarroaMeasurement = PendingEntry; +export function queueVarroaMeasurement(input: CreateVarroaMeasurementInput): Promise { + return queue("pending-varroa-measurements", input); +} +export function getPendingVarroaMeasurements(): Promise { + return getPending("pending-varroa-measurements"); +} +export function removePendingVarroaMeasurement(id: number): Promise { + return removePending("pending-varroa-measurements", id); +} +export function countPendingVarroaMeasurements(): Promise { + return countPending("pending-varroa-measurements"); +} + +export type PendingSwarmControlCheck = PendingEntry; +export function queueSwarmControlCheck(input: CreateSwarmControlCheckInput): Promise { + return queue("pending-swarm-control-checks", input); +} +export function getPendingSwarmControlChecks(): Promise { + return getPending("pending-swarm-control-checks"); +} +export function removePendingSwarmControlCheck(id: number): Promise { + return removePending("pending-swarm-control-checks", id); +} +export function countPendingSwarmControlChecks(): Promise { + return countPending("pending-swarm-control-checks"); +} + +export type PendingHarvest = PendingEntry; +export function queueHarvest(input: CreateHarvestInput): Promise { + return queue("pending-harvests", input); +} +export function getPendingHarvests(): Promise { + return getPending("pending-harvests"); +} +export function removePendingHarvest(id: number): Promise { + return removePending("pending-harvests", id); +} +export function countPendingHarvests(): Promise { + return countPending("pending-harvests"); +} + +export async function countAllPending(): Promise { + const counts = await Promise.all([ + countPendingInspections(), + countPendingTreatments(), + countPendingFeedings(), + countPendingVarroaMeasurements(), + countPendingSwarmControlChecks(), + countPendingHarvests(), + ]); + return counts.reduce((sum, count) => sum + count, 0); } diff --git a/ApiCore-Frontend/src/lib/offline/sync.spec.ts b/ApiCore-Frontend/src/lib/offline/sync.spec.ts index 90cf21d..089d332 100644 --- a/ApiCore-Frontend/src/lib/offline/sync.spec.ts +++ b/ApiCore-Frontend/src/lib/offline/sync.spec.ts @@ -11,6 +11,19 @@ vi.mock("@/lib/inspections/api", () => ({ vi.mock("./db", () => ({ getPendingInspections: 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); diff --git a/ApiCore-Frontend/src/lib/offline/sync.ts b/ApiCore-Frontend/src/lib/offline/sync.ts index 517eafe..e384c10 100644 --- a/ApiCore-Frontend/src/lib/offline/sync.ts +++ b/ApiCore-Frontend/src/lib/offline/sync.ts @@ -1,6 +1,24 @@ import { ApiError } from "@/lib/auth/api-client"; 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 { synced: number; @@ -9,6 +27,9 @@ export interface SyncResult { let inFlightSync: Promise | 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 { if (!inFlightSync) { inFlightSync = runSync().finally(() => { @@ -18,15 +39,25 @@ export function syncPendingInspections(): Promise { return inFlightSync; } -async function runSync(): Promise { - const pending = await getPendingInspections(); +// Generic drain: tries each queued entry in order, removes it on success, +// 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( + getPending: () => Promise<{ id: number; input: T }[]>, + removePending: (id: number) => Promise, + create: (input: T) => Promise, +): Promise { + const pending = await getPending(); let synced = 0; let failed = 0; for (const entry of pending) { try { - await createInspection(entry.input); - await removePendingInspection(entry.id); + await create(entry.input); + await removePending(entry.id); synced += 1; } catch (error) { failed += 1; @@ -38,3 +69,30 @@ async function runSync(): Promise { return { synced, failed }; } + +async function runSync(): Promise { + 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 }, + ); +} diff --git a/TASKS.md b/TASKS.md index 58e7bc8..8fa566a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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] Backup-/Restore-Strategie in ARCHITECTURE.md dokumentiert - [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