diff --git a/ROADMAP.md b/ROADMAP.md index 727bc1a..4c6cef8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,8 +58,10 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross - [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. +- [x] **5: Optimistische Nebenläufigkeit** — `Project.featuresVersion` + Versions-Check + beim Speichern. Ein veralteter (z.B. offline) Stand überschreibt neuere Änderungen nicht + mehr still, sondern erzeugt 409 → Client übernimmt Server-Stand + Warnung. Offline-Queue + verwirft konfliktierende 4xx. (Merge pro Element / echtes Co-Drawing weiterhin offen.) ## Zusatz – Leitungsrechner professionalisiert ✅ diff --git a/package.json b/package.json index 5da18cc..9e6f2e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.4.10", + "version": "1.4.11", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/prisma/migrate.js b/prisma/migrate.js index b7f607c..1dd9cf5 100644 --- a/prisma/migrate.js +++ b/prisma/migrate.js @@ -73,6 +73,7 @@ async function migrate() { `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "journalfuehrer" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "mode" TEXT NOT NULL DEFAULT 'EINSATZ'`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "exerciseEvaluation" TEXT`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "featuresVersion" INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingById" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingUserName" TEXT`, `ALTER TABLE projects ADD COLUMN IF NOT EXISTS "editingSessionId" TEXT`, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ce605c8..73030e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -166,6 +166,10 @@ model Project { mapCenter Json @default("{\"lng\": 8.5417, \"lat\": 47.3769}") mapZoom Float @default(15) isLocked Boolean @default(false) + // Optimistische Nebenläufigkeit: wird bei jedem Features-Speichern hochgezählt. + // Verhindert, dass ein veralteter (z.B. offline zwischengespeicherter) Stand + // neuere Änderungen stillschweigend überschreibt. + featuresVersion Int @default(0) // Live editing lock (session-based for same-account multi-device) editingById String? diff --git a/src/app/api/projects/[id]/features/route.ts b/src/app/api/projects/[id]/features/route.ts index d62b09f..96d8e0b 100644 --- a/src/app/api/projects/[id]/features/route.ts +++ b/src/app/api/projects/[id]/features/route.ts @@ -20,12 +20,15 @@ export async function GET( return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 }) } - const features = await (prisma as any).feature.findMany({ - where: { projectId: id }, - orderBy: { createdAt: 'asc' }, - }) + const [features, proj] = await Promise.all([ + (prisma as any).feature.findMany({ + where: { projectId: id }, + orderBy: { createdAt: 'asc' }, + }), + (prisma as any).project.findUnique({ where: { id }, select: { featuresVersion: true } }), + ]) - return NextResponse.json({ features }) + return NextResponse.json({ features, featuresVersion: proj?.featuresVersion ?? 0 }) } catch (error) { console.error('Error fetching features:', error) return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) @@ -113,44 +116,74 @@ export async function PUT( } const body = await request.json() - const { features, mapCenter, mapZoom } = body as { + const { features, mapCenter, mapZoom, baseVersion } = body as { features: Array<{ id?: string; type: string; geometry: object; properties?: object }> mapCenter?: { lng: number; lat: number } mapZoom?: number + baseVersion?: number } - // Persist map viewport alongside features (if provided) - if (mapCenter && mapZoom !== undefined) { - await (prisma as any).project.update({ - where: { id }, - data: { mapCenter, mapZoom }, - }) + try { + // ATOMAR + optimistischer Lock in EINER interaktiven Transaktion: + // - Version prüfen/hochzählen (verhindert Überschreiben durch veralteten Stand) + // - Features löschen + neu anlegen (kein Totalverlust-Fenster) + const result = await (prisma as any).$transaction(async (tx: any) => { + if (typeof baseVersion === 'number') { + // Nur speichern, wenn die Version noch stimmt (atomar über WHERE-Bedingung) + const bump = await tx.project.updateMany({ + where: { id, featuresVersion: baseVersion }, + data: { featuresVersion: { increment: 1 } }, + }) + if (bump.count === 0) { + const conflict: any = new Error('VERSION_CONFLICT') + conflict.code = 'VERSION_CONFLICT' + throw conflict + } + } else { + // Kein baseVersion mitgeschickt (Alt-Client/Beacon) → nur hochzählen + await tx.project.update({ where: { id }, data: { featuresVersion: { increment: 1 } } }) + } + + if (mapCenter && mapZoom !== undefined) { + await tx.project.update({ where: { id }, data: { mapCenter, mapZoom } }) + } + + await tx.feature.deleteMany({ where: { projectId: id } }) + if (features && features.length > 0) { + await tx.feature.createMany({ + data: features.map((f: any) => ({ + projectId: id, + type: f.type, + geometry: f.geometry, + properties: f.properties || {}, + })), + }) + } + + const [updatedFeatures, proj] = await Promise.all([ + tx.feature.findMany({ where: { projectId: id } }), + tx.project.findUnique({ where: { id }, select: { featuresVersion: true } }), + ]) + return { features: updatedFeatures, featuresVersion: proj?.featuresVersion ?? 0 } + }, { timeout: 15000, maxWait: 5000 }) + + return NextResponse.json(result) + } catch (txError: any) { + if (txError?.code === 'VERSION_CONFLICT') { + // Konflikt: aktuellen Server-Stand zurückgeben, damit der Client abgleichen kann + const [currentFeatures, proj] = await Promise.all([ + (prisma as any).feature.findMany({ where: { projectId: id }, orderBy: { createdAt: 'asc' } }), + (prisma as any).project.findUnique({ where: { id }, select: { featuresVersion: true } }), + ]) + return NextResponse.json({ + error: 'Der Einsatz wurde zwischenzeitlich geändert.', + conflict: true, + features: currentFeatures, + featuresVersion: proj?.featuresVersion ?? 0, + }, { status: 409 }) + } + throw txError } - - // 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) { - 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 }, - }) - - return NextResponse.json({ features: updatedFeatures }) } catch (error) { console.error('Error updating features:', error) return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index 28e6263..91c62ac 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -72,6 +72,9 @@ export default function AppPage() { const featuresRef = useRef(features) useEffect(() => { featuresRef.current = features }, [features]) + // Optimistischer Lock: Features-Version des aktuellen Projekts mitführen + const featuresVersionRef = useRef(0) + // Ref for undo-draw-point (removes last point during line drawing) const undoDrawPointRef = useRef<(() => boolean) | null>(null) @@ -385,6 +388,24 @@ export default function AppPage() { const canEdit = canEditMap // Abwärtskompatibel für Karten-bezogene Stellen const isReadOnly = !!editingBy && !isEditingByMe + // Features-Version bei Projektwechsel initialisieren + useEffect(() => { + featuresVersionRef.current = (currentProject as any)?.featuresVersion ?? 0 + }, [currentProject?.id]) + + // Speicher-Konflikt: Server-Stand übernehmen und den Nutzer warnen + const handleSaveConflict = useCallback((serverFeatures: DrawFeature[], serverVersion: number) => { + setFeatures(serverFeatures) + featuresVersionRef.current = serverVersion + undoStackRef.current = [] + redoStackRef.current = [] + toast({ + title: 'Konflikt erkannt', + description: 'Der Einsatz wurde von jemand anderem geändert — der aktuelle Stand wurde geladen. Bitte prüfe deine Änderungen und mache sie ggf. erneut.', + variant: 'destructive', + }) + }, [toast]) + // Auto-save: localStorage persistence + debounced API save + beacon on unload useAutoSave({ currentProject, @@ -394,6 +415,8 @@ export default function AppPage() { socketRef, isEditingByMe, setSyncQueueCount, + featuresVersionRef, + onConflict: handleSaveConflict, }) // Fullscreen toggle @@ -522,7 +545,7 @@ export default function AppPage() { setIsSaving(true) try { - const saveBody: any = { features } + const saveBody: any = { features, baseVersion: featuresVersionRef.current } if (mapRef.current) { const c = mapRef.current.getCenter() saveBody.mapCenter = { lng: c.lng, lat: c.lat } @@ -554,19 +577,34 @@ export default function AppPage() { } const created = await createRes.json() setCurrentProject(created.project) + featuresVersionRef.current = created.project?.featuresVersion ?? 0 // Retry save with new project ID res = await fetch(`/api/projects/${created.project.id}/features`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ features }), + body: JSON.stringify({ features, baseVersion: featuresVersionRef.current }), }) } + // Versionskonflikt: Server-Stand übernehmen statt zu überschreiben + if (res.status === 409) { + const data = await res.json().catch(() => null) + if (data?.conflict) { + handleSaveConflict(data.features || [], data.featuresVersion ?? featuresVersionRef.current) + return + } + } + if (!res.ok) { const err = await res.json().catch(() => ({})) throw new Error(err.error || 'Speichern fehlgeschlagen') } + const okData = await res.json().catch(() => null) + if (okData && typeof okData.featuresVersion === 'number') { + featuresVersionRef.current = okData.featuresVersion + } + // Broadcast features to other clients socketRef.current?.emit('features-updated', { projectId: currentProject.id, diff --git a/src/hooks/use-auto-save.ts b/src/hooks/use-auto-save.ts index 5e047b8..701f2a4 100644 --- a/src/hooks/use-auto-save.ts +++ b/src/hooks/use-auto-save.ts @@ -10,6 +10,10 @@ interface UseAutoSaveOptions { socketRef: React.MutableRefObject isEditingByMe: boolean setSyncQueueCount: (count: number) => void + /** Aktuelle Features-Version des Projekts (optimistischer Lock) */ + featuresVersionRef: React.MutableRefObject + /** Wird bei einem Speicher-Konflikt (409) mit dem Server-Stand aufgerufen */ + onConflict: (serverFeatures: DrawFeature[], serverVersion: number) => void } export function useAutoSave({ @@ -20,6 +24,8 @@ export function useAutoSave({ socketRef, isEditingByMe, setSyncQueueCount, + featuresVersionRef, + onConflict, }: UseAutoSaveOptions) { // Persist features to localStorage on change (per project — verhindert, dass Features // eines Projekts in ein anderes "bluten"). Inkl. leerem Array (spiegelt Löschungen). @@ -34,7 +40,7 @@ export function useAutoSave({ if (!currentProject?.id) return const url = `/api/projects/${currentProject.id}/features` const mapInstance = mapRef.current - const body: any = { features: featuresRef.current } + const body: any = { features: featuresRef.current, baseVersion: featuresVersionRef.current } if (mapInstance) { const c = mapInstance.getCenter() body.mapCenter = { lng: c.lng, lat: c.lat } @@ -56,11 +62,22 @@ export function useAutoSave({ body: JSON.stringify(body), }) if (res.ok) { + const data = await res.json().catch(() => null) + if (data && typeof data.featuresVersion === 'number') { + featuresVersionRef.current = data.featuresVersion + } console.log('[Auto-Save] Features gespeichert') socketRef.current?.emit('features-updated', { projectId: currentProject.id, features: featuresRef.current, }) + } else if (res.status === 409) { + // Konflikt: jemand anderes hat zwischenzeitlich gespeichert → Server-Stand übernehmen + const data = await res.json().catch(() => null) + if (data?.conflict) { + console.warn('[Auto-Save] Versionskonflikt — Server-Stand übernehmen') + onConflict(data.features || [], data.featuresVersion ?? featuresVersionRef.current) + } } else if (res.status === 404) { console.warn('[Auto-Save] Projekt nicht in DB') } @@ -70,7 +87,7 @@ export function useAutoSave({ setSyncQueueCount(getSyncQueue().length) console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e) } - }, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount]) + }, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount, featuresVersionRef, onConflict]) // Debounced save on every feature change (2s delay) useEffect(() => { @@ -86,7 +103,7 @@ export function useAutoSave({ // 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 }) + const payload = JSON.stringify({ features: featuresRef.current, baseVersion: featuresVersionRef.current }) navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' })) } } diff --git a/src/types/index.ts b/src/types/index.ts index 38429cb..c492a1c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,6 +12,7 @@ export interface Project { mapCenter: { lng: number; lat: number } mapZoom: number isLocked: boolean + featuresVersion?: number editingById?: string | null editingUserName?: string | null editingStartedAt?: string | null