feat(collab): order-anteckningar — @siax/collaboration-kernelns första produktkonsument (spår C) #147
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user