diff --git a/ROADMAP.md b/ROADMAP.md index 562b32d..727bc1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,6 +52,15 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross - Live-Sync für Journal existierte bereits (Socket → journal-refresh). - _Offen (Kür): echtes gleichzeitiges Zeichnen (Co-Drawing) — bewusst nicht gebaut._ +## Zusatz – Robustheit / Datensicherheit ✅ + +- [x] Features-Speichern atomar (`prisma.$transaction`) — kein Totalverlust-Fenster mehr +- [x] localStorage-Features pro Projekt (`lageplan-features-`) statt globalem Key +- [x] Sync-Queue verwirft dauerhafte 4xx-Fehler (kein Endlos-Retry/verstopfte Queue) +- [x] `beforeunload` persistiert auch leere Listen (gelöschte Elemente gehen nicht verloren) +- Offen (🟡 5): Multi-Device-Versionierung — Editor-Lock ist pro Session, nicht pro User; + Last-Writer-Wins ohne `updatedAt`-Check. Eigener grösserer Schritt. + ## Zusatz – Leitungsrechner professionalisiert ✅ - [x] Formel zentralisiert in `src/lib/hose-calc.ts` (eine Quelle, typisiert, testbar) diff --git a/package.json b/package.json index 821d746..5da18cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.4.9", + "version": "1.4.10", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/src/app/api/projects/[id]/features/route.ts b/src/app/api/projects/[id]/features/route.ts index d890b0e..d62b09f 100644 --- a/src/app/api/projects/[id]/features/route.ts +++ b/src/app/api/projects/[id]/features/route.ts @@ -127,20 +127,24 @@ export async function PUT( }) } - await (prisma as any).feature.deleteMany({ - where: { projectId: id }, - }) - + // ATOMAR: löschen + neu anlegen in EINER Transaktion. + // Sonst droht bei Abbruch zwischen delete und create Totalverlust aller Elemente. + const ops: any[] = [ + (prisma as any).feature.deleteMany({ where: { projectId: id } }), + ] if (features && features.length > 0) { - await (prisma as any).feature.createMany({ - data: features.map((f: any) => ({ - projectId: id, - type: f.type, - geometry: f.geometry, - properties: f.properties || {}, - })), - }) + ops.push( + (prisma as any).feature.createMany({ + data: features.map((f: any) => ({ + projectId: id, + type: f.type, + geometry: f.geometry, + properties: f.properties || {}, + })), + }) + ) } + await (prisma as any).$transaction(ops) const updatedFeatures = await (prisma as any).feature.findMany({ where: { projectId: id }, diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index 9875fae..28e6263 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -437,7 +437,7 @@ export default function AppPage() { if (apiFeatures.length > 0) { setFeatures(apiFeatures) } else { - const savedFeatures = localStorage.getItem('lageplan-features') + const savedFeatures = localStorage.getItem(`lageplan-features-${proj.id}`) if (savedFeatures) setFeatures(JSON.parse(savedFeatures)) } }) @@ -445,14 +445,14 @@ export default function AppPage() { // Project not accessible (different tenant, deleted, etc.) — clear console.log('[Restore] Project not accessible for current user, clearing') localStorage.removeItem('lageplan-project') - localStorage.removeItem('lageplan-features') + localStorage.removeItem(`lageplan-features-${proj.id}`) setCurrentProject(null) setFeatures([]) } }).catch(() => { - // Network error — use cached data as fallback + // Network error — use cached data as fallback (nur Features DESSELBEN Projekts) setCurrentProject(proj) - const savedFeatures = localStorage.getItem('lageplan-features') + const savedFeatures = localStorage.getItem(`lageplan-features-${proj.id}`) if (savedFeatures) setFeatures(JSON.parse(savedFeatures)) }) } @@ -508,7 +508,7 @@ export default function AppPage() { undoStackRef.current = [] redoStackRef.current = [] localStorage.removeItem('lageplan-project') - localStorage.removeItem('lageplan-features') + localStorage.removeItem(`lageplan-features-${deletedId}`) } addAudit(`Einsatz gelöscht`) toast({ @@ -588,6 +588,22 @@ export default function AppPage() { } } + // Einsatz beenden: speichern, Bearbeitungs-Lock lösen und zurück zur Übersicht. + const handleEndProject = async () => { + if (!currentProject) return + if (!confirm(`Einsatz "${currentProject.title}" beenden? Alle Änderungen werden gespeichert und der Einsatz geschlossen.`)) return + const title = currentProject.title + try { await handleSaveProject() } catch { /* Save meldet Fehler selbst */ } + if (isEditingByMe) { try { await handleStopEditing() } catch { /* ignore */ } } + addAudit(`Einsatz "${title}" beendet`) + setCurrentProject(null) + setFeatures([]) + undoStackRef.current = [] + redoStackRef.current = [] + localStorage.removeItem('lageplan-project') + toast({ title: 'Einsatz beendet', description: 'Gespeichert und geschlossen.' }) + } + const handleFeaturesChange = useCallback((newFeatures: DrawFeature[]) => { undoStackRef.current.push(featuresRef.current) redoStackRef.current = [] @@ -798,6 +814,7 @@ export default function AppPage() { void + onEndProject?: () => void onSaveProject: () => void onLoadProject: (project: Project, features: DrawFeature[]) => void onDeleteProject?: (projectId: string) => void @@ -76,6 +78,7 @@ interface TopbarProps { export function Topbar({ project, onNewProject, + onEndProject, onSaveProject, onLoadProject, onDeleteProject, @@ -285,19 +288,17 @@ export function Topbar({ {isFullscreen ? : } - + {project && onEndProject && ( + + )} {/* Desktop: User menu dropdown */} {userName && onLogout && ( @@ -366,10 +367,6 @@ export function Topbar({ Einstellungen - - - Audit Trail {auditLog.length > 0 && `(${auditLog.length})`} - {onLogout && ( diff --git a/src/components/map/measure-panel.tsx b/src/components/map/measure-panel.tsx index 3a4eada..9fafb96 100644 --- a/src/components/map/measure-panel.tsx +++ b/src/components/map/measure-panel.tsx @@ -103,18 +103,18 @@ export function MeasurePanel({ measurement, hoseTypes, onClose }: MeasurePanelPr
diff --git a/src/hooks/use-auto-save.ts b/src/hooks/use-auto-save.ts index e762d85..5e047b8 100644 --- a/src/hooks/use-auto-save.ts +++ b/src/hooks/use-auto-save.ts @@ -21,10 +21,12 @@ export function useAutoSave({ isEditingByMe, setSyncQueueCount, }: UseAutoSaveOptions) { - // Persist features to localStorage on change (including empty array to reflect deletions) + // Persist features to localStorage on change (per project — verhindert, dass Features + // eines Projekts in ein anderes "bluten"). Inkl. leerem Array (spiegelt Löschungen). useEffect(() => { - localStorage.setItem('lageplan-features', JSON.stringify(features)) - }, [features]) + if (!currentProject?.id) return + localStorage.setItem(`lageplan-features-${currentProject.id}`, JSON.stringify(features)) + }, [features, currentProject?.id]) // Auto-save to API — debounced 2s after every feature change + fallback interval const saveTimerRef = useRef | null>(null) @@ -81,7 +83,9 @@ export function useAutoSave({ // Also save on page unload / tab switch useEffect(() => { const handleBeforeUnload = () => { - if (currentProject?.id && featuresRef.current.length > 0) { + // Nur der aktive Bearbeiter persistiert beim Schliessen — auch eine leere Liste, + // damit "alles gelöscht + Tab zu" nicht die alten Elemente in der DB stehen lässt. + if (currentProject?.id && isEditingByMe) { const payload = JSON.stringify({ features: featuresRef.current }) navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' })) } diff --git a/src/lib/offline-sync.ts b/src/lib/offline-sync.ts index baac27f..0c53964 100644 --- a/src/lib/offline-sync.ts +++ b/src/lib/offline-sync.ts @@ -58,8 +58,12 @@ export async function flushSyncQueue(): Promise<{ success: number; failed: numbe }) if (res.ok) { success++ + } else if (res.status >= 400 && res.status < 500) { + // Client-Fehler (403/404/...) = dauerhaft. Retry bringt nichts → verwerfen, + // damit die Queue nicht verstopft und nicht ewig "Sync-Fehler" meldet. + failed++ } else { - // Server error — keep in queue for retry + // Server-Fehler (5xx) — später erneut versuchen remaining.push(item) failed++ }