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
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.
178 lines
5.8 KiB
TypeScript
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;
|
|
}
|
|
}
|