feat(collab): order-anteckningar — @siax/collaboration-kernelns första produktkonsument (spår C) #147

Merged
admin merged 2 commits from feat/collab-adopt-20260930 into main 2026-09-30 15:18:54 +00:00
11 changed files with 1205 additions and 3 deletions
+238
View File
@@ -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<Order[] | null>(null);
const [orderId, setOrderId] = useState<string>("");
const [notes, setNotes] = useState<OrderNoteDto[] | null>(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 (
<PortalShell variant="admin" title="Orderanteckningar">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={offlineMode}
onChange={(e) => setOfflineMode(e.target.checked)}
/>
Offline-läge (blockera utskick — varning + no-op, utkast bevaras)
</label>
{offlineMode && (
<div role="alert" className="rounded border border-amber-400 bg-amber-50 p-2 text-sm">
Offline-läge aktivt: inga anteckningar skickas till servern. Utkast bevaras lokalt —
ingen tyst förlust.
</div>
)}
{loadError ? (
<ErrorState onRetry={() => orderId && loadNotes(orderId)} />
) : orders === null ? (
<LoadingSkeleton />
) : orders.length === 0 ? (
<EmptyState title="Inga ordrar" line="Inga ordrar att annotera ännu." />
) : (
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm">
Order:{" "}
<select
aria-label="Välj order"
value={orderId}
onChange={(e) => {
setOrderId(e.target.value);
void loadNotes(e.target.value);
}}
className="rounded border px-2 py-1"
>
<option value="">— välj —</option>
{orders.map((o) => (
<option key={o.order_id} value={o.order_id}>
{o.order_id} ({o.flow}, {o.status})
</option>
))}
</select>
</label>
{orderId === "" ? (
<EmptyState title="Välj order" line="Välj en order för att se och skriva anteckningar." />
) : notes === null ? (
<LoadingSkeleton />
) : notes.length === 0 ? (
<EmptyState title="Tomt" line="Inga anteckningar på den här ordern ännu." />
) : (
<ul className="space-y-2">
{notes.map((n) => (
<li key={n.note_id} className="rounded border p-3 text-sm">
<div className="mb-1 flex justify-between text-xs opacity-70">
<span>
{n.author_id} · v{n.version} · klocka {n.clock}
</span>
<span>{new Date(n.updated_at).toLocaleString("sv-SE")}</span>
</div>
{editing?.note.note_id === n.note_id ? (
<div className="space-y-2">
<textarea
aria-label="Redigera anteckning"
value={editing.body}
onChange={(e) => setEditing({ ...editing, body: e.target.value })}
className="w-full rounded border p-2"
rows={3}
/>
<div className="flex gap-2">
<button className="rounded bg-black px-3 py-1 text-white" onClick={() => void saveEdit()}>
Spara (v{editing.note.version} → v{editing.note.version + 1})
</button>
<button className="rounded border px-3 py-1" onClick={() => setEditing(null)}>
Avbryt
</button>
</div>
</div>
) : (
<>
<p className="whitespace-pre-wrap">{n.body}</p>
<button
className="mt-2 text-xs underline"
onClick={() => setEditing({ note: n, body: n.body })}
>
Redigera
</button>
</>
)}
</li>
))}
</ul>
)}
{orderId !== "" && (
<div className="space-y-2">
<textarea
aria-label="Ny anteckning"
placeholder="Skriv en anteckning på ordern…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="w-full rounded border p-2"
rows={3}
/>
<button
className="rounded bg-black px-4 py-2 text-sm text-white disabled:opacity-40"
disabled={!draft.trim() || notes === null}
onClick={() => void addNote()}
>
Lägg till anteckning
</button>
</div>
)}
</div>
)}
</PortalShell>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRouteAuth } from "@/src/auth/require-route-auth";
import { createAud0Client } from "@/src/clients/aud0-client";
import {
addNote,
findNote,
listNotes,
NoteNotFoundError,
NoteVersionConflictError,
updateNote,
} from "@/lib/order-notes-store";
import { getOrder } from "@/lib/orders-store";
import { isSiaxError, siaxErrorResponseForLegacy } from "../../siax-error-mapping";
// GET/POST/PATCH /api/admin/order-notes — persisted collaboration state på
// admin-orders (kernel: @siax/collaboration). Ligger utanför middleware.ts:s
// matcher (/api/0pt/*), så auth läggs på direkt via requireRouteAuth, precis
// som /api/admin/metrics — fail-closed Zitadel-verifiering, 401 utan token.
//
// Tenant-guard: tenant_id hämtas SERVER-SIDE från ordern (aldrig från
// klienten) och jämförs mot den verifierade kontextens tenant — mismatch är
// 403. Conflict semantics: PATCH med stale `version` -> 409 + aktuell version
// (optimistisk samtidighet; last-write-wins först EFTER en medveten reload).
const AUDIT_CAPABILITY = "admin.order_notes";
async function audit(
tenantId: string | null,
actorId: string,
resourceId: string,
eventType: string,
payload: unknown,
): Promise<void> {
// Best effort: AUD0 är evidence, inte en skriv-buffert — ett audit-fel får
// aldrig rulla tillbaka en lyckad note-skrivning.
try {
await createAud0Client().ingestEvent({
event_id: `aud_${crypto.randomUUID()}`,
event_type: eventType,
tenant_id: tenantId ?? "anonymous",
project_id: "0pt",
actor_id: actorId,
app_id: "0pt",
capability_id: AUDIT_CAPABILITY,
resource_type: "order_note",
resource_id: resourceId,
risk_level: "L1",
payload,
created_at: new Date().toISOString(),
});
} catch {
// swallow — see comment above
}
}
function tenantGuard(ctxTenant: string, orderTenant: string | null | undefined): NextResponse | null {
if (!orderTenant) return null; // legacy anonymous draft — admin-ytan får annotera
if (ctxTenant !== orderTenant) {
return NextResponse.json(
{
error: "OPT_ACCESS_DENIED",
message: "order belongs to a different tenant than the authenticated token",
details: { order_tenant: orderTenant },
},
{ status: 403 },
);
}
return null;
}
export async function GET(req: NextRequest) {
const auth = await requireRouteAuth(req);
if (auth.response) return auth.response;
const orderId = req.nextUrl.searchParams.get("order_id");
if (!orderId) {
return NextResponse.json({ error: "order_id query parameter is required" }, { status: 400 });
}
const order = await getOrder(orderId);
if (!order) {
return NextResponse.json({ error: "order_not_found", order_id: orderId }, { status: 404 });
}
const denied = tenantGuard(auth.ctx.tenantId, order.tenant_id);
if (denied) return denied;
const notes = await listNotes(orderId);
return NextResponse.json({ order_id: orderId, notes });
}
export async function POST(req: NextRequest) {
const auth = await requireRouteAuth(req);
if (auth.response) return auth.response;
let body: { order_id?: string; body?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
}
if (!body.order_id || !body.body || !String(body.body).trim()) {
return NextResponse.json(
{ error: "order_id and non-empty body are required" },
{ status: 400 },
);
}
const order = await getOrder(body.order_id);
if (!order) {
return NextResponse.json({ error: "order_not_found", order_id: body.order_id }, { status: 404 });
}
const denied = tenantGuard(auth.ctx.tenantId, order.tenant_id);
if (denied) return denied;
try {
const note = await addNote({
order_id: body.order_id,
tenant_id: order.tenant_id ?? null,
author_id: auth.ctx.actorId,
body: String(body.body),
});
await audit(note.tenant_id, auth.ctx.actorId, note.note_id, "order_note.created", {
order_id: note.order_id,
clock: note.clock,
});
return NextResponse.json(note, { status: 201 });
} catch (err: unknown) {
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
}
export async function PATCH(req: NextRequest) {
const auth = await requireRouteAuth(req);
if (auth.response) return auth.response;
let body: { note_id?: string; body?: string; version?: number };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
}
if (!body.note_id || !body.body || !Number.isInteger(body.version)) {
return NextResponse.json(
{ error: "note_id, body and integer version are required" },
{ status: 400 },
);
}
// Server-authoritative tenant check: slå upp noten (och dess order) INNAN
// någon skrivning — klienten får aldrig säga vilken tenant en note tillhör.
const existing = await findNote(body.note_id);
if (!existing) {
return NextResponse.json({ error: "note_not_found" }, { status: 404 });
}
const order = await getOrder(existing.order_id);
const denied = tenantGuard(auth.ctx.tenantId, order?.tenant_id ?? existing.tenant_id);
if (denied) return denied;
try {
const note = await updateNote({
note_id: body.note_id,
author_id: auth.ctx.actorId,
body: String(body.body),
expected_version: body.version as number,
});
await audit(note.tenant_id, auth.ctx.actorId, note.note_id, "order_note.updated", {
order_id: note.order_id,
version: note.version,
clock: note.clock,
});
return NextResponse.json(note);
} catch (err: unknown) {
if (err instanceof NoteVersionConflictError) {
return NextResponse.json(
{
error: "version_conflict",
message: err.message,
expected_version: err.expectedVersion,
current_version: err.currentVersion,
},
{ status: 409 },
);
}
if (err instanceof NoteNotFoundError) {
return NextResponse.json({ error: "note_not_found" }, { status: 404 });
}
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ export function LoadingSkeleton({ rows = 5 }: { rows?: number }) {
))}
<span className="sr-only">{t({ en: "Loading content", sv: "Laddar innehåll" })}</span>
<style jsx>{`
<style>{`
.opt-shimmer {
position: relative;
overflow: hidden;
+177
View File
@@ -0,0 +1,177 @@
// 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;
}
}
+233
View File
@@ -0,0 +1,233 @@
// Order notes — SIAX collaboration-kernelns första produktkonsument (spår C).
//
// Server-authoritative store över 0pts data-lager (Postgres när DATABASE_URL
// är satt, annars samma dokumenterade in-memory-fallback som orders-store).
// Kernel-semantik (@siax/collaboration 0.3.0):
// - varje note ÄR en CollaborationOperation (id/actorId/clock/type/payload)
// - nya operationer får sin klocka via nextLamportClock (max lokal/observerad + 1)
// - listning återställs deterministiskt via mergeOperationLog/compareOperations
// (klocka -> actorId -> id), så två repliker listar i SAMMA ordning
// - samtidighet: optimistisk version; stale skrivning kastar
// NoteVersionConflictError -> routen svarar 409 (conflict behavior).
import {
mergeOperationLog,
nextLamportClock,
type CollaborationOperation,
} from "@siax/collaboration";
import { getSqlForDurableStore } from "@/lib/db";
export interface OrderNote {
note_id: string;
order_id: string;
tenant_id: string | null;
author_id: string;
body: string;
version: number;
clock: number;
created_at: string;
updated_at: string;
}
export class NoteVersionConflictError extends Error {
constructor(
public readonly noteId: string,
public readonly expectedVersion: number,
public readonly currentVersion: number,
) {
super(
`Note "${noteId}" was updated by someone else: expected version ${expectedVersion}, current is ${currentVersion}`,
);
this.name = "NoteVersionConflictError";
}
}
export class NoteNotFoundError extends Error {
constructor(public readonly noteId: string) {
super(`Note "${noteId}" does not exist`);
this.name = "NoteNotFoundError";
}
}
export class OrderNotFoundError extends Error {
constructor(public readonly orderId: string) {
super(`Order "${orderId}" does not exist`);
this.name = "OrderNotFoundError";
}
}
// Non-durable fallback used only until a database is provisioned (same
// decision as lib/orders-store.ts — resolveStoreMode fails hard in production
// without a URL, so this path cannot silently swallow production writes).
const mem = new Map<string, OrderNote>();
/** Kernel-projection: a note row IS a collaboration operation. */
function toOperation(note: OrderNote): CollaborationOperation<{ body: string }> {
return {
id: note.note_id,
actorId: note.author_id,
clock: note.clock,
type: "note.upsert",
payload: { body: note.body },
};
}
/** Deterministic snapshot: kernel merge wins, never insertion order. */
function snapshot(notes: Iterable<OrderNote>): OrderNote[] {
const log = mergeOperationLog([...notes].map(toOperation));
const byId = new Map([...notes].map((n) => [n.note_id, n]));
return log
.map((op) => byId.get(op.id))
.filter((n): n is OrderNote => Boolean(n));
}
function rowToNote(row: Record<string, unknown>): OrderNote {
return {
note_id: String(row.note_id),
order_id: String(row.order_id),
tenant_id: row.tenant_id === null || row.tenant_id === undefined ? null : String(row.tenant_id),
author_id: String(row.author_id),
body: String(row.body),
version: Number(row.version),
clock: Number(row.clock),
created_at: new Date(row.created_at as string).toISOString(),
updated_at: new Date(row.updated_at as string).toISOString(),
};
}
type NotesSql = NonNullable<ReturnType<typeof getSqlForDurableStore>>;
async function maxClock(sql: NotesSql, orderId: string): Promise<number> {
const rows = await sql`
SELECT COALESCE(MAX(clock), 0) AS max_clock FROM order_notes WHERE order_id = ${orderId}
`;
return Number(rows[0]?.max_clock ?? 0);
}
export async function listNotes(orderId: string): Promise<OrderNote[]> {
const sql = getSqlForDurableStore("order-notes");
if (!sql) {
return snapshot(
[...mem.values()].filter((n) => n.order_id === orderId),
);
}
const rows = await sql`
SELECT * FROM order_notes WHERE order_id = ${orderId}
`;
return snapshot(rows.map(rowToNote));
}
export interface AddNoteInput {
order_id: string;
tenant_id: string | null;
author_id: string;
body: string;
}
export async function addNote(input: AddNoteInput): Promise<OrderNote> {
const body = input.body.trim();
if (!body) throw new TypeError("note body must be non-empty");
const note: OrderNote = {
note_id: `note_${crypto.randomUUID()}`,
order_id: input.order_id,
tenant_id: input.tenant_id,
author_id: input.author_id,
body,
version: 1,
// Kernel: Lamport-klockan = max(lokal, observerad) + 1.
clock: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
note.clock = nextLamportClock(await peekMaxClock(input.order_id));
const sql = getSqlForDurableStore("order-notes");
if (!sql) {
mem.set(note.note_id, note);
return note;
}
const rows = await sql`
INSERT INTO order_notes (note_id, order_id, tenant_id, author_id, body, version, clock, created_at, updated_at)
VALUES (${note.note_id}, ${note.order_id}, ${note.tenant_id}, ${note.author_id}, ${note.body},
${note.version}, ${note.clock}, ${note.created_at}, ${note.updated_at})
RETURNING *
`;
return rowToNote(rows[0]);
}
async function peekMaxClock(orderId: string): Promise<number> {
const sql = getSqlForDurableStore("order-notes");
if (!sql) {
let max = 0;
for (const n of mem.values()) if (n.order_id === orderId && n.clock > max) max = n.clock;
return max;
}
return maxClock(sql, orderId);
}
export interface UpdateNoteInput {
note_id: string;
author_id: string;
body: string;
/** Optimistic concurrency: must equal the caller's seen version, else 409. */
expected_version: number;
}
export async function updateNote(input: UpdateNoteInput): Promise<OrderNote> {
const body = input.body.trim();
if (!body) throw new TypeError("note body must be non-empty");
if (!Number.isInteger(input.expected_version) || input.expected_version < 1) {
throw new TypeError("expected_version must be a positive integer");
}
const sql = getSqlForDurableStore("order-notes");
if (!sql) {
const current = mem.get(input.note_id);
if (!current) throw new NoteNotFoundError(input.note_id);
if (current.version !== input.expected_version) {
throw new NoteVersionConflictError(input.note_id, input.expected_version, current.version);
}
const updated: OrderNote = {
...current,
body,
version: current.version + 1,
clock: nextLamportClock(current.clock),
updated_at: new Date().toISOString(),
};
mem.set(updated.note_id, updated);
return updated;
}
const rows = await sql`
UPDATE order_notes
SET body = ${body},
version = version + 1,
clock = clock + 1,
updated_at = ${new Date().toISOString()}
WHERE note_id = ${input.note_id} AND version = ${input.expected_version}
RETURNING *
`;
if (rows.length === 0) {
const existing = await sql`SELECT * FROM order_notes WHERE note_id = ${input.note_id}`;
if (existing.length === 0) throw new NoteNotFoundError(input.note_id);
throw new NoteVersionConflictError(
input.note_id,
input.expected_version,
Number(existing[0].version),
);
}
return rowToNote(rows[0]);
}
/** Direct read by note id — used by the route for server-authoritative tenant checks. */
export async function findNote(noteId: string): Promise<OrderNote | null> {
const sql = getSqlForDurableStore("order-notes");
if (!sql) return mem.get(noteId) ?? null;
const rows = await sql`SELECT * FROM order_notes WHERE note_id = ${noteId}`;
return rows.length > 0 ? rowToNote(rows[0]) : null;
}
/** Delete for tests: the in-memory fallback needs isolation between tests. */
export function resetOrderNotesMemoryForTests(): void {
mem.clear();
}
+3 -2
View File
@@ -53,8 +53,10 @@
"@radix-ui/react-tooltip": "1.2.16",
"@siax/0pt-decision": "workspace:*",
"@siax/charts": "^0.5.0",
"@siax/collaboration": "0.3.0",
"@siax/config": "^0.2.1",
"@siax/contracts": "^0.10.0",
"@siax/import-export": "^0.2.0",
"@siax/tokens": "^0.1.2",
"@siax/ui": "^0.14.0",
"autoprefixer": "^10.4.20",
@@ -79,8 +81,7 @@
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "3.25.76",
"@siax/import-export": "^0.2.0"
"zod": "3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
+8
View File
@@ -108,6 +108,9 @@ importers:
'@siax/charts':
specifier: ^0.5.0
version: 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@siax/collaboration':
specifier: 0.3.0
version: 0.3.0
'@siax/config':
specifier: ^0.2.1
version: 0.2.1(tailwindcss@4.3.3)
@@ -1544,6 +1547,9 @@ packages:
react: ^19.2.0
react-dom: ^19.2.0
'@siax/collaboration@0.3.0':
resolution: {integrity: sha512-wq48Q2fE4poiMZrvnUJyakUchgmCEXRdXrkDDbXFSEmah5K1sjbFgxzduIoOg3CBpjcnFC0dYzWTfvKmTOGy0g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcollaboration/-/0.3.0/collaboration-0.3.0.tgz}
'@siax/config@0.2.1':
resolution: {integrity: sha512-6YRGYSczhdSBSZYtzqnH5I+5BHllvxmPICtVDUbUzQ8Bd44IQ7yDQHWynrsVCFEvv+WsqNHHxOo9XREJNi+jRw==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fconfig/-/0.2.1/config-0.2.1.tgz}
peerDependencies:
@@ -4221,6 +4227,8 @@ snapshots:
react-dom: 19.2.8(react@19.2.8)
tailwind-merge: 2.6.1
'@siax/collaboration@0.3.0': {}
'@siax/config@0.2.1(tailwindcss@4.3.3)':
dependencies:
tailwindcss: 4.3.3
+16
View File
@@ -0,0 +1,16 @@
-- 016: order notes — persisted collaboration state för admin-orders
-- (@siax/collaboration-kernelns första produktkonsument, spår C).
-- Varje note är en kernel-CollaborationOperation: `clock` är Lamport-klockan,
-- `version` är optimistisk samtidighetskontroll (stale skrivning -> HTTP 409).
CREATE TABLE IF NOT EXISTS order_notes (
note_id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
tenant_id TEXT,
author_id TEXT NOT NULL,
body TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
clock BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS order_notes_order_idx ON order_notes(order_id);
+226
View File
@@ -0,0 +1,226 @@
/**
* Spår C — @siax/collaboration-kernelns första produktkonsument:
* /api/admin/order-notes (persisted collaboration state på admin-orders).
*
* Mönstret följer tests/api/anonymous-routes-auth.test.ts +
* orders-idempotency.test.ts: route-handlers anropas direkt med handbyggda
* NextRequest. Ingen DATABASE_URL i test-miljön => lib/order-notes-store.ts
* faller tillbaka på sin dokumenterade in-memory-store (production vägrar
* den vägen via resolveStoreMode — se lib/db.ts).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
vi.mock("@/src/auth/require-route-auth", () => ({
requireRouteAuth: vi.fn(),
}));
vi.mock("@/src/clients/aud0-client", () => ({
createAud0Client: vi.fn(() => ({ ingestEvent: vi.fn().mockResolvedValue({}) })),
}));
import { requireRouteAuth } from "@/src/auth/require-route-auth";
import { createAud0Client } from "@/src/clients/aud0-client";
import { GET, PATCH, POST } from "@/app/api/admin/order-notes/route";
import { getOrder, createOrder } from "@/lib/orders-store";
import {
addNote,
listNotes,
resetOrderNotesMemoryForTests,
} from "@/lib/order-notes-store";
const authed = { tenantId: "tenant-test", actorId: "admin-simon", requestId: "req-1" };
function mockAuth(ctx = authed) {
vi.mocked(requireRouteAuth).mockResolvedValue({ ctx } as never);
}
function req(
url: string,
init: { method?: string; headers?: Record<string, string>; body?: string } = {},
): NextRequest {
return new NextRequest(new URL(url, "http://localhost"), init);
}
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
return req(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
let orderId: string;
beforeEach(async () => {
vi.mocked(requireRouteAuth).mockReset();
vi.mocked(createAud0Client).mockClear();
resetOrderNotesMemoryForTests();
mockAuth();
const created = await createOrder("custom", {
markets: ["SE"],
type: "Bolag",
volume: 10,
});
orderId = created.order.order_id;
// Legacy anonymous draft får annoteras av admin — men en tenant-ägd order
// behöver token-match. Skapa därför testordern anonym (tenant null) och
// testa tenant-guarden separat med en ägd order.
});
afterEach(() => {
resetOrderNotesMemoryForTests();
});
describe("GET /api/admin/order-notes", () => {
it("empty order -> 200 med tom lista (explicit empty behavior)", async () => {
const res = await GET(req(`/api/admin/order-notes?order_id=${orderId}`));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.notes).toEqual([]);
});
it("saknad order_id -> 400; okänd order -> 404", async () => {
expect((await GET(req("/api/admin/order-notes"))).status).toBe(400);
expect((await GET(req("/api/admin/order-notes?order_id=nope"))).status).toBe(404);
});
it("utan auth-svar från requireRouteAuth -> fail-closed (401), aldrig data", async () => {
vi.mocked(requireRouteAuth).mockResolvedValueOnce({
response: new NextResponse(null, { status: 401 }),
} as never);
const res = await GET(req(`/api/admin/order-notes?order_id=${orderId}`));
expect(res.status).toBe(401);
});
});
describe("POST + GET happy path (kernel-semantik)", () => {
it("201 skapar noten, GET listar deterministiskt (Lamport-ordning, inte insättningsordning)", async () => {
const r1 = await POST(jsonReq("/api/admin/order-notes", { order_id: orderId, body: "Första" }));
expect(r1.status).toBe(201);
const first = await r1.json();
expect(first.version).toBe(1);
expect(first.clock).toBe(1);
const r2 = await POST(jsonReq("/api/admin/order-notes", { order_id: orderId, body: "Andra" }));
expect(r2.status).toBe(201);
const list = await (await GET(req(`/api/admin/order-notes?order_id=${orderId}`))).json();
expect(list.notes).toHaveLength(2);
// kernel mergeOperationLog: stigande (clock, actorId, id)
const clocks = list.notes.map((n: { clock: number }) => n.clock);
expect([...clocks].sort((a: number, b: number) => a - b)).toEqual(clocks);
expect(list.notes[0].body).toBe("Första");
// audit event emitterades via hostens AUD0-klient
const ingest = vi.mocked(createAud0Client).mock.results.at(-1)?.value as {
ingestEvent: ReturnType<typeof vi.fn>;
};
expect(ingest.ingestEvent).toHaveBeenCalled();
const evt = ingest.ingestEvent.mock.calls[0][0];
expect(evt.capability_id).toBe("admin.order_notes");
expect(evt.event_type).toBe("order_note.created");
});
it("tom body -> 400 (server-authoritative validering)", async () => {
const res = await POST(jsonReq("/api/admin/order-notes", { order_id: orderId, body: " " }));
expect(res.status).toBe(400);
});
it("tenant-guard: order tillhör annan tenant än token -> 403", async () => {
const created = await createOrder("custom", { markets: ["SE"], volume: 5 }, undefined, {
tenantId: "tenant-other",
subject: "buyer-other",
});
const res = await POST(
jsonReq("/api/admin/order-notes", { order_id: created.order.order_id, body: "hej" }),
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toBe("OPT_ACCESS_DENIED");
});
});
describe("PATCH — optimistisk samtidighet (conflict behavior)", () => {
it("rätt version -> 200, version+1, klockan tickar (nextLamportClock)", async () => {
const note = await addNote({ order_id: orderId, tenant_id: null, author_id: "a", body: "v1" });
const res = await PATCH(
jsonReq("/api/admin/order-notes", { note_id: note.note_id, body: "v2", version: 1 }, "PATCH"),
);
expect(res.status).toBe(200);
const updated = await res.json();
expect(updated.body).toBe("v2");
expect(updated.version).toBe(2);
expect(updated.clock).toBe(note.clock + 1);
});
it("stale version -> 409 med aktuell version (multi-user semantics)", async () => {
const note = await addNote({ order_id: orderId, tenant_id: null, author_id: "a", body: "v1" });
await updateNoteViaApi(note.note_id, "v2 fran annan skribent"); // samtidig skribant -> v2
const res = await PATCH(
jsonReq("/api/admin/order-notes", { note_id: note.note_id, body: "v3 stale", version: 1 }, "PATCH"),
);
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error).toBe("version_conflict");
expect(body.current_version).toBe(2);
});
it("okänd note -> 404; ogiltig version -> 400", async () => {
expect(
(
await PATCH(
jsonReq(
"/api/admin/order-notes",
{ note_id: "note_nope", body: "x", version: 1 },
"PATCH",
),
)
).status,
).toBe(404);
const note = await addNote({ order_id: orderId, tenant_id: null, author_id: "a", body: "v1" });
expect(
(
await PATCH(
jsonReq(
"/api/admin/order-notes",
{ note_id: note.note_id, body: "x", version: 1.5 },
"PATCH",
),
)
).status,
).toBe(400);
});
it("PATCH utan auth -> 401 fail-closed", async () => {
vi.mocked(requireRouteAuth).mockResolvedValueOnce({
response: new NextResponse(null, { status: 401 }),
} as never);
const res = await PATCH(
jsonReq("/api/admin/order-notes", { note_id: "x", body: "x", version: 1 }, "PATCH"),
);
expect(res.status).toBe(401);
});
});
async function updateNoteViaApi(noteId: string, body: string) {
const res = await PATCH(jsonReq("/api/admin/order-notes", { note_id: noteId, body, version: 1 }, "PATCH"));
expect(res.status).toBe(200);
return res.json();
}
describe("kernel-återanvändning (capability, inte paket)", () => {
it("listNotes är kernel-mergead: ordningen följer compareOperations (klocka -> actorId -> id), två listningar identiska", async () => {
const a = await addNote({ order_id: orderId, tenant_id: null, author_id: "a", body: "A" });
const b = await addNote({ order_id: orderId, tenant_id: null, author_id: "b", body: "B" });
const first = await listNotes(orderId);
const second = await listNotes(orderId);
expect(second.map((n) => n.note_id)).toEqual(first.map((n) => n.note_id));
// stigande klocka = deterministisk kernel-ordning, oberoende av minnes-mapens iteration
expect(first.map((n) => n.clock)).toEqual([a.clock, b.clock].sort((x, y) => x - y));
expect(first.map((n) => n.note_id)).toEqual([a.note_id, b.note_id]);
});
});
// getOrder används i routen (server-authoritative tenant-uppslag) — håll
// importen levande så refaktorer inte tyst tappar guarden.
void getOrder;
+31
View File
@@ -0,0 +1,31 @@
/**
* SSR-render-proof för den nya admin-ytan /admin/notes (kernel-konsumenten).
* Samma mönster som import-wizard-ssr.test.ts: react-dom/server i Node.
* PortalShell -> usePathname (next/navigation) mockas; sonner toast mockas.
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("next/navigation", () => ({
usePathname: () => "/admin/notes",
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() } }));
vi.mock("@/components/locale-provider", () => ({
LocaleProvider: ({ children }: { children: React.ReactNode }) => children,
useT: () => (x: { en: string; sv: string }) => x.en,
useLocale: () => "en",
}));
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import OrderNotesPage from "@/app/admin/notes/page";
describe("/admin/notes SSR (kernel-konsument-yta)", () => {
it("renderar rubrik, offline-växeln och laddningstillstånd utan krasch (SSR = första paint före useEffect)", () => {
const html = renderToString(createElement(OrderNotesPage));
expect(html).toContain("Orderanteckningar");
expect(html).toContain("Offline-läge");
expect(html).toContain('role="status"'); // loading-skeleton = korrekt SSR-att-få-state
expect(html.length).toBeGreaterThan(500);
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Klient-adaptern (kernel-transport över host-API): offline = no-op + varning
* (INGEN fetch, inget tyst förlorat), 409 -> ConflictError med aktuell
* version, presence via kernelns upsertPresence.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
OfflineError,
OrderNotesClient,
OrderNotesTransport,
} from "@/lib/order-notes-client";
describe("OrderNotesTransport (kernel CollaborationTransport)", () => {
it("send() i offline-läge kastar OfflineError utan nätverksanrop (no-op, ej tyst)", async () => {
const t = new OrderNotesTransport("admin-simon");
t.setOffline(true);
await expect(
t.send({ id: "n1", actorId: "x", clock: 1, type: "note.update", payload: { body: "y" } }),
).rejects.toBeInstanceOf(OfflineError);
});
it("connect/disconnect växlar presence via upsertPresence (online -> offline)", async () => {
const t = new OrderNotesTransport("admin-simon");
await t.connect();
expect(t.getPresence().find((p) => p.participantId === "admin-simon")?.status).toBe("online");
await t.disconnect();
expect(t.getPresence().find((p) => p.participantId === "admin-simon")?.status).toBe("offline");
});
it("observeRemote advanserar Lamport-klockan och notifyar listeners", async () => {
const t = new OrderNotesTransport("me");
const seen: string[] = [];
t.onOperation((op) => seen.push(`${op.id}@${op.clock}`));
t.observeRemote({ id: "n1", actorId: "anna", clock: 4, type: "note.upsert", payload: { body: "x" } });
expect(seen).toEqual(["n1@4"]);
expect(t.getPresence().find((p) => p.participantId === "anna")?.status).toBe("online");
});
});
describe("OrderNotesClient över host-API", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("offline-läge: add() är no-op — fetch anropas EJ, OfflineError kastas (ingen tyst förlust)", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const client = new OrderNotesClient("admin-simon");
client.transport.setOffline(true);
await expect(client.add("order-1", "utkast")).rejects.toBeInstanceOf(OfflineError);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("409 från servern -> ConflictError med serverns aktuella version", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: "version_conflict", current_version: 3 }), { status: 409 }),
);
const client = new OrderNotesClient("admin-simon");
await expect(
client.update(
{ note_id: "n1", order_id: "o1", author_id: "a", body: "old", version: 1, clock: 1, updated_at: "" },
"new",
1,
),
).rejects.toMatchObject({ currentVersion: 3 });
});
it("load(): varje servernot replays som kernel-operation (klockor advanserar)", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
notes: [
{ note_id: "n1", order_id: "o1", author_id: "anna", body: "A", version: 1, clock: 2, updated_at: "" },
{ note_id: "n2", order_id: "o1", author_id: "bosse", body: "B", version: 2, clock: 5, updated_at: "" },
],
}),
{ status: 200 },
),
);
const client = new OrderNotesClient("admin-simon");
const { notes, versions } = await client.load("o1");
expect(notes).toHaveLength(2);
expect(versions.get("n2")).toBe(2);
expect(client.transport.getPresence().find((p) => p.participantId === "bosse")).toBeTruthy();
});
});