Files
0pt/lib/order-notes-client.ts
T

178 lines
5.8 KiB
TypeScript

// 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<string, PresenceState>();
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<void> {
this.setOffline(false);
}
async disconnect(): Promise<void> {
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<void> {
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<string, number>;
}
export class OrderNotesClient {
readonly transport: OrderNotesTransport;
constructor(actorId: string) {
this.transport = new OrderNotesTransport(actorId);
}
async load(orderId: string): Promise<NotesApiResult> {
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<string, number>();
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<OrderNoteDto> {
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<OrderNoteDto> {
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;
}
}