// Klient-adapter: kernel-transport (@siax/collaboration CollaborationTransport) // över 0pts host-API (/api/admin/order-notes). Admin-ytan pratar ALDRIG med // fetch direkt — allt går genom denna adapter, så offline-läge och // versionskonflikter hanteras på ETT ställe: // - offline: send() är ett medvetet NO-OP som kastar OfflineError — utkastet // behålls i UI:t och användaren får en varning. Ingen tyst förlust. // - 409: ConflictError med serverns aktuella version -> UI laddar om. // Presence går via kernelns upsertPresence (status offline/online per författare). import { nextLamportClock, upsertPresence, type CollaborationOperation, type CollaborationTransport, type PresenceState, } from "@siax/collaboration"; export interface OrderNoteDto { note_id: string; order_id: string; author_id: string; body: string; version: number; clock: number; updated_at: string; } export class OfflineError extends Error { constructor() { super("Offline: note change was NOT sent — draft kept locally, nothing lost"); this.name = "OfflineError"; } } export class ConflictError extends Error { constructor( public readonly noteId: string, public readonly currentVersion: number, ) { super(`Note ${noteId} changed server-side (current version ${currentVersion})`); this.name = "ConflictError"; } } export class OrderNotesTransport implements CollaborationTransport<{ body: string }> { private listeners = new Set<(op: CollaborationOperation<{ body: string }>) => void>(); private presenceListeners = new Set<(p: PresenceState) => void>(); private presence = new Map(); private localClock = 0; private offline = false; constructor(readonly actorId: string) {} setOffline(offline: boolean): void { this.offline = offline; this.setPresence({ participantId: this.actorId, status: offline ? "offline" : "online" }); } isOffline(): boolean { return this.offline; } getPresence(): PresenceState[] { return [...this.presence.values()]; } setPresence(presence: PresenceState): void { this.presence = upsertPresence(this.presence, presence); for (const l of this.presenceListeners) l(presence); } async connect(): Promise { this.setOffline(false); } async disconnect(): Promise { this.setOffline(true); } onOperation(listener: (op: CollaborationOperation<{ body: string }>) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } onPresence(listener: (p: PresenceState) => void): () => void { this.presenceListeners.add(listener); return () => this.presenceListeners.delete(listener); } /** Replay a server op into local listeners + advance the Lamport clock. */ observeRemote(operation: CollaborationOperation<{ body: string }>): void { this.localClock = nextLamportClock(this.localClock, operation.clock); this.setPresence({ participantId: operation.actorId, status: this.presence.get(operation.actorId)?.status ?? "online", }); for (const l of this.listeners) l(operation); } async send(operation: CollaborationOperation<{ body: string }>): Promise { if (this.offline) throw new OfflineError(); this.localClock = nextLamportClock(this.localClock, operation.clock); for (const l of this.listeners) l(operation); } } export interface NotesApiResult { notes: OrderNoteDto[]; /** Version per note — the optimistic-concurrency handle the UI PATCHes with. */ versions: Map; } export class OrderNotesClient { readonly transport: OrderNotesTransport; constructor(actorId: string) { this.transport = new OrderNotesTransport(actorId); } async load(orderId: string): Promise { const res = await fetch(`/api/admin/order-notes?order_id=${encodeURIComponent(orderId)}`, { cache: "no-store", }); if (!res.ok) throw new Error(`load failed: ${res.status}`); const body = (await res.json()) as { notes: OrderNoteDto[] }; const versions = new Map(); for (const note of body.notes) { versions.set(note.note_id, note.version); this.transport.observeRemote({ id: note.note_id, actorId: note.author_id, clock: note.clock, type: "note.upsert", payload: { body: note.body }, }); } return { notes: body.notes, versions }; } async add(orderId: string, body: string): Promise { const op: CollaborationOperation<{ body: string }> = { id: `local_${crypto.randomUUID()}`, actorId: this.transport.actorId, clock: 0, type: "note.create", payload: { body }, }; await this.transport.send(op); const res = await fetch("/api/admin/order-notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ order_id: orderId, body }), }); if (!res.ok) throw new Error(`add failed: ${res.status}`); return (await res.json()) as OrderNoteDto; } async update(note: OrderNoteDto, body: string, expectedVersion: number): Promise { await this.transport.send({ id: note.note_id, actorId: "self", clock: note.clock, type: "note.update", payload: { body }, }); const res = await fetch("/api/admin/order-notes", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ note_id: note.note_id, body, version: expectedVersion }), }); if (res.status === 409) { const err = (await res.json()) as { current_version?: number }; throw new ConflictError(note.note_id, err.current_version ?? 0); } if (!res.ok) throw new Error(`update failed: ${res.status}`); return (await res.json()) as OrderNoteDto; } }