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:
1 parent
ac45514356
commit
1f67cd5548
11 files changed
+292
-41
No files matched your search
+5
-5
@@ -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.
|
||||
|
||||
**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.
|
||||
@@ -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: () => {
|
||||
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}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
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");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
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}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
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}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
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}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 === " ") {
|
||||
|
||||
@@ -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<T> {
|
||||
id: number;
|
||||
input: CreateInspectionInput;
|
||||
input: T;
|
||||
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_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<IDBPDatabase<OfflineDB>> | null = null;
|
||||
|
||||
function getDb(): Promise<IDBPDatabase<OfflineDB>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<OfflineDB>(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<void> {
|
||||
async function queue<T>(store: PendingStoreName, input: T): Promise<void> {
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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", () => ({
|
||||
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);
|
||||
|
||||
@@ -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<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> {
|
||||
if (!inFlightSync) {
|
||||
inFlightSync = runSync().finally(() => {
|
||||
@@ -18,15 +39,25 @@ export function syncPendingInspections(): Promise<SyncResult> {
|
||||
return inFlightSync;
|
||||
}
|
||||
|
||||
async function runSync(): Promise<SyncResult> {
|
||||
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<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 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<SyncResult> {
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user