From 04813c34b0eb9dc811b47b62f19c88d14adc5440 Mon Sep 17 00:00:00 2001 From: sax3l Date: Wed, 30 Sep 2026 17:07:21 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(collab):=20order-anteckningar=20?= =?UTF-8?q?=E2=80=94=20@siax/collaboration-kernelns=20f=C3=B6rsta=20produk?= =?UTF-8?q?tkonsument=20(sp=C3=A5r=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kernel 0.3.0 används som capability, inte som paket-dekor: - lib/order-notes-store.ts: varje note ÄR en CollaborationOperation; nextLamportClock för nya klockor, mergeOperationLog/compareOperations för deterministisk listning; optimistisk version -> 409 vid stale skrivning. - lib/order-notes-client.ts: kernel-CollaborationTransport över host-API; offline = medvetet no-op + varning (ingen tyst förlust), 409 -> ConflictError. - app/api/admin/order-notes: requireRouteAuth (fail-closed 401), server-authoritative tenant-guard (403), AUD0-audit via hostens klient. - app/admin/notes: admin-yta med offline-växel, konflikt-banner + reload. - sql/016_order_notes.sql: persistens (DB-tabell, version + Lamport-klocka). Tester: API (happy/conflict 409/empty/400/401/403/404), klient-adapter (offline no-op utan fetch, 409-mappning, presence), SSR-render. 18 nya test. --- app/admin/notes/page.tsx | 238 +++++++++++++++++++++++ app/api/admin/order-notes/route.ts | 183 +++++++++++++++++ lib/order-notes-client.ts | 177 +++++++++++++++++ lib/order-notes-store.ts | 233 ++++++++++++++++++++++ package.json | 5 +- pnpm-lock.yaml | 8 + sql/016_order_notes.sql | 16 ++ tests/api/order-notes.test.ts | 226 +++++++++++++++++++++ tests/components/order-notes-ssr.test.ts | 31 +++ tests/lib/order-notes-client.test.ts | 89 +++++++++ 10 files changed, 1204 insertions(+), 2 deletions(-) create mode 100644 app/admin/notes/page.tsx create mode 100644 app/api/admin/order-notes/route.ts create mode 100644 lib/order-notes-client.ts create mode 100644 lib/order-notes-store.ts create mode 100644 sql/016_order_notes.sql create mode 100644 tests/api/order-notes.test.ts create mode 100644 tests/components/order-notes-ssr.test.ts create mode 100644 tests/lib/order-notes-client.test.ts diff --git a/app/admin/notes/page.tsx b/app/admin/notes/page.tsx new file mode 100644 index 0000000..97a0de4 --- /dev/null +++ b/app/admin/notes/page.tsx @@ -0,0 +1,238 @@ +"use client"; + +// Admin-yta: anteckningar på order — @siax/collaboration-kernelns första +// produktkonsument (spår C). Allt nätverk går via lib/order-notes-client.ts +// (kernel-transport över host-API): offline-läge = medvetet no-op med varning +// (utkastet bevaras, ingen tyst förlust), stale skrivning = 409 -> banner + +// reload av serverns aktuella version. +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { PortalShell } from "@/components/portal-shell"; +import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/states"; +import { + ConflictError, + OfflineError, + OrderNotesClient, + type OrderNoteDto, +} from "@/lib/order-notes-client"; + +interface Order { + order_id: string; + flow: string; + status: string; + created_at: string; +} + +export default function OrderNotesPage() { + const [orders, setOrders] = useState(null); + const [orderId, setOrderId] = useState(""); + const [notes, setNotes] = useState(null); + const [loadError, setLoadError] = useState(false); + const [offlineMode, setOfflineMode] = useState(false); + const [draft, setDraft] = useState(""); + const [editing, setEditing] = useState<{ note: OrderNoteDto; body: string } | null>(null); + + const client = useMemo(() => new OrderNotesClient("admin-simon"), []); + + useEffect(() => { + client.transport.setOffline(offlineMode); + }, [client, offlineMode]); + + useEffect(() => { + (async () => { + try { + const res = await fetch("/api/orders", { cache: "no-store" }); + if (!res.ok) throw new Error("orders fetch failed"); + const body = await res.json(); + setOrders(Array.isArray(body) ? body : []); + } catch { + setLoadError(true); + } + })(); + }, []); + + const loadNotes = useCallback( + async (id: string) => { + setNotes(null); + setLoadError(false); + try { + const { notes: loaded } = await client.load(id); + setNotes(loaded); + } catch { + setLoadError(true); + } + }, + [client], + ); + + const addNote = useCallback(async () => { + if (!orderId || !draft.trim()) return; + if (offlineMode) { + // Offline = klient-varning + no-op. Utkastet ligger kvar i rutan — + // INGET skickas, inget försvinner tyst. + toast.warning("Offline-läge: anteckningen SKICKAS EJ. Utkastet är bevarat i rutan."); + return; + } + try { + const note = await client.add(orderId, draft); + setDraft(""); + setNotes((prev) => [...(prev ?? []), note]); + toast.success("Anteckning sparad"); + } catch (err) { + if (err instanceof OfflineError) { + toast.warning("Offline: inget skickades — utkastet är bevarat."); + return; + } + setLoadError(true); + } + }, [client, draft, offlineMode, orderId]); + + const saveEdit = useCallback(async () => { + if (!editing) return; + if (offlineMode) { + toast.warning("Offline-läge: ändringen SKICKAS EJ. Texten är bevarad i redigeringsrutan."); + return; + } + try { + const updated = await client.update(editing.note, editing.body, editing.note.version); + setNotes((prev) => (prev ?? []).map((n) => (n.note_id === updated.note_id ? updated : n))); + setEditing(null); + toast.success("Anteckning uppdaterad"); + } catch (err) { + if (err instanceof ConflictError) { + // Någon annan skrev samtidigt (stale version -> 409). Ladda om + // serverns aktuella version — förlora ALDRIG den lokala texten tyst. + toast.error(`Konflikt: anteckningen ändrades av någon annan (serverversion ${err.currentVersion}). Laddar om — din text finns kvar nedan.`); + setDraft((prev) => (prev ? prev : editing.body)); + await loadNotes(orderId); + setEditing(null); + return; + } + if (err instanceof OfflineError) { + toast.warning("Offline: inget skickades — ändringen är bevarad."); + return; + } + setLoadError(true); + } + }, [client, editing, loadNotes, offlineMode, orderId]); + + return ( + + + {offlineMode && ( +
+ Offline-läge aktivt: inga anteckningar skickas till servern. Utkast bevaras lokalt — + ingen tyst förlust. +
+ )} + + {loadError ? ( + orderId && loadNotes(orderId)} /> + ) : orders === null ? ( + + ) : orders.length === 0 ? ( + + ) : ( +
+ + + {orderId === "" ? ( + + ) : notes === null ? ( + + ) : notes.length === 0 ? ( + + ) : ( +
    + {notes.map((n) => ( +
  • +
    + + {n.author_id} · v{n.version} · klocka {n.clock} + + {new Date(n.updated_at).toLocaleString("sv-SE")} +
    + {editing?.note.note_id === n.note_id ? ( +
    +