Files
0pt/lib/order-notes-client.ts
admin 04813c34b0
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 7s
Security Scan (SIAX Cloud) / security (push) Successful in 16s
CI (SIAX Cloud) / security (push) Successful in 18s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / quality (push) Failing after 33s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / integration (pull_request) Successful in 31s
CI (SIAX Cloud) / security (pull_request) Successful in 23s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / quality (pull_request) Failing after 21s
feat(collab): order-anteckningar — @siax/collaboration-kernelns första produktkonsument (spår C)
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.
2026-09-30 17:07:21 +02:00

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;
}
}