Merge branch 'main' into fix/prod-evidence-2026-09-29
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 8s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / integration (pull_request) Successful in 30s
CI (SIAX Cloud) / security (push) Successful in 1m1s
Security Scan (SIAX Cloud) / security (push) Successful in 1m28s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m33s
CI (SIAX Cloud) / quality (push) Successful in 1m59s
CI (SIAX Cloud) / quality (pull_request) Successful in 2m22s
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 8s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / security (pull_request) Successful in 22s
CI (SIAX Cloud) / integration (pull_request) Successful in 30s
CI (SIAX Cloud) / security (push) Successful in 1m1s
Security Scan (SIAX Cloud) / security (push) Successful in 1m28s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m33s
CI (SIAX Cloud) / quality (push) Successful in 1m59s
CI (SIAX Cloud) / quality (pull_request) Successful in 2m22s
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
// C8: "Importera ordrar" — the first real product host of the @siax/ui
|
||||
// ImportWizard + @siax/import-export runtime. The wizard renders the frozen
|
||||
// store snapshot; THIS host owns every pipeline step's execution and all
|
||||
// server I/O. The server (POST /api/orders/import) is the only authority:
|
||||
// it re-parses and re-validates the raw CSV, so the client-side parse below
|
||||
// exists purely to give the wizard rows to render.
|
||||
|
||||
import { ImportWizard } from "@siax/ui";
|
||||
import { createImportRunStore, type ImportRunStore, type ImportRunSnapshot } from "@siax/import-export";
|
||||
import { createImportJob } from "@siax/contracts/import-export";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
const IMPORT_COLUMNS = ["flow", "service_type", "type", "volume", "frequency", "markets"] as const;
|
||||
|
||||
interface ServerReport {
|
||||
imported?: number;
|
||||
totalRows?: number;
|
||||
validRows?: number;
|
||||
errorCount?: number;
|
||||
errors?: { row: number; field: string; code: string; message?: string }[];
|
||||
order_ids?: string[];
|
||||
}
|
||||
|
||||
/** Client-side RFC 4180 parse — presentation only; the server re-parses the raw CSV. */
|
||||
function clientParse(text: string): Record<string, string>[] {
|
||||
const rows: string[][] = [];
|
||||
let field = "";
|
||||
let row: string[] = [];
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else inQuotes = false;
|
||||
} else field += ch;
|
||||
} else if (ch === '"') inQuotes = true;
|
||||
else if (ch === ",") {
|
||||
row.push(field);
|
||||
field = "";
|
||||
} else if (ch === "\n") {
|
||||
row.push(field);
|
||||
field = "";
|
||||
rows.push(row);
|
||||
row = [];
|
||||
} else if (ch !== "\r") field += ch;
|
||||
}
|
||||
if (field.length > 0 || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
const nonEmpty = rows.filter((r) => r.length > 1 || (r[0] ?? "").trim() !== "");
|
||||
if (nonEmpty.length === 0) return [];
|
||||
const header = nonEmpty[0].map((h) => h.trim().toLowerCase());
|
||||
return nonEmpty.slice(1).map((cells) => {
|
||||
const obj: Record<string, string> = {};
|
||||
header.forEach((h, idx) => {
|
||||
if (h) obj[h] = (cells[idx] ?? "").trim();
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
|
||||
async function postImport(mode: "validate" | "dry_run" | "execute", csv: string): Promise<{ ok: boolean; status: number; data: ServerReport & { error?: { message?: string } } }> {
|
||||
const res = await fetch("/api/orders/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode, csv }),
|
||||
});
|
||||
let data: ServerReport & { error?: { message?: string } } = {};
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
data = { error: { message: `HTTP ${res.status}` } };
|
||||
}
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
export default function AdminImportPage() {
|
||||
const [rawCsv, setRawCsv] = useState<string | null>(null);
|
||||
const [store, setStore] = useState<ImportRunStore | null>(null);
|
||||
const [serverReport, setServerReport] = useState<ServerReport | null>(null);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const [recentOrders, setRecentOrders] = useState<{ order_id: string; flow: string; status: string }[] | null>(null);
|
||||
|
||||
const onFile = useCallback(async (file: File) => {
|
||||
const text = await file.text();
|
||||
setRawCsv(text);
|
||||
setServerReport(null);
|
||||
setServerError(null);
|
||||
setRecentOrders(null);
|
||||
const rows = clientParse(text);
|
||||
const s = createImportRunStore({
|
||||
job: createImportJob({
|
||||
id: globalThis.crypto?.randomUUID?.() ?? `imp-${Date.now()}`,
|
||||
tenantId: "current",
|
||||
entity: "orders",
|
||||
format: "csv",
|
||||
source: { system: "0pt-admin", channel: "web" },
|
||||
createdAt: new Date().toISOString(),
|
||||
}),
|
||||
rows,
|
||||
});
|
||||
s.detect();
|
||||
setStore(s);
|
||||
}, []);
|
||||
|
||||
const onValidate = useCallback(async () => {
|
||||
if (!store || !rawCsv) return;
|
||||
setServerError(null);
|
||||
const { ok, status, data } = await postImport("validate", rawCsv);
|
||||
setServerReport(data);
|
||||
store.validate(); // local snapshot for the wizard report (same frozen kernel)
|
||||
if (!ok) setServerError(`Servervalidering avvisade filen (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`);
|
||||
}, [store, rawCsv]);
|
||||
|
||||
const onDryRun = useCallback(async () => {
|
||||
if (!store || !rawCsv) return;
|
||||
setServerError(null);
|
||||
const { ok, status, data } = await postImport("dry_run", rawCsv);
|
||||
setServerReport(data);
|
||||
store.dryRun();
|
||||
if (!ok) setServerError(`Servern nekade generalrepet (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`);
|
||||
}, [store, rawCsv]);
|
||||
|
||||
const onExecute = useCallback(async () => {
|
||||
if (!store || !rawCsv) return;
|
||||
setServerError(null);
|
||||
const { ok, status, data } = await postImport("execute", rawCsv);
|
||||
setServerReport(data);
|
||||
if (!ok) {
|
||||
// Server is the authority — a failed execute is terminal for this run.
|
||||
store.fail(`Servern avvisade exekveringen (HTTP ${status}): ${data.error?.message ?? "okänt fel"}`);
|
||||
return;
|
||||
}
|
||||
// Host executor proxies the server result into the store's hard gates.
|
||||
const snapshot: ImportRunSnapshot = await store.execute(async () => ({ imported: data.imported ?? 0 }));
|
||||
// Reload-persistence proof: re-read the REAL order list after import.
|
||||
try {
|
||||
const res = await fetch("/api/orders", { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const orders = (await res.json()) as { order_id: string; flow: string; status: string }[];
|
||||
const importedIds = new Set(data.order_ids ?? []);
|
||||
setRecentOrders(orders.filter((o) => importedIds.has(o.order_id)));
|
||||
}
|
||||
} catch {
|
||||
/* panel stays null — server report above still shows the truth */
|
||||
}
|
||||
void snapshot;
|
||||
}, [store, rawCsv]);
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 720, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||
<h1 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>Importera ordrar</h1>
|
||||
<p style={{ marginBottom: "1rem", opacity: 0.8 }}>
|
||||
CSV med kolumnerna: {IMPORT_COLUMNS.join(", ")}. Endast <code>flow</code> är obligatorisk
|
||||
(catalog | custom | subscription). Parsering och validering sker på servern —
|
||||
filen laddas inte upp förrän ett steg körs.
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
aria-label="Välj CSV-fil"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void onFile(f);
|
||||
}}
|
||||
/>
|
||||
{store && (
|
||||
<div style={{ marginTop: "1.5rem" }}>
|
||||
<ImportWizard
|
||||
store={store}
|
||||
columns={IMPORT_COLUMNS.map((c) => ({ source: c, target: c }))}
|
||||
onMap={() => {
|
||||
store.map({ columns: IMPORT_COLUMNS.map((c) => ({ source: c, target: c, required: c === "flow" })) });
|
||||
}}
|
||||
onValidate={onValidate}
|
||||
onDryRun={onDryRun}
|
||||
onExecute={onExecute}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{serverError && (
|
||||
<p role="alert" style={{ marginTop: "1rem", color: "crimson" }}>
|
||||
{serverError}
|
||||
</p>
|
||||
)}
|
||||
{serverReport && (serverReport.errorCount ?? 0) > 0 && (
|
||||
<section style={{ marginTop: "1.5rem" }} aria-label="Felrapport från servern">
|
||||
<h2 style={{ fontSize: "1.1rem" }}>Felrader (serverns rapport)</h2>
|
||||
<ul>
|
||||
{(serverReport.errors ?? []).map((e, i) => (
|
||||
<li key={i}>
|
||||
Rad {e.row} · {e.field} · {e.code}
|
||||
{e.message ? ` — ${e.message}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{recentOrders && (
|
||||
<section style={{ marginTop: "1.5rem" }} aria-label="Importerade ordrar efter reload">
|
||||
<h2 style={{ fontSize: "1.1rem" }}>Importerade ordrar (lästa från /api/orders efter import)</h2>
|
||||
{recentOrders.length === 0 ? (
|
||||
<p>Inga importerade ordrar hittades vid reload.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{recentOrders.map((o) => (
|
||||
<li key={o.order_id}>
|
||||
<code>{o.order_id}</code> — {o.flow} — {o.status}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createOrder, type OrderConfig, type OrderOwner } from "@/lib/orders-store";
|
||||
import { requireOrderOwner } from "@/src/auth/require-order-owner";
|
||||
import { isSiaxError, siaxErrorResponseForLegacy } from "../../siax-error-mapping";
|
||||
import { createAud0Client } from "@/src/clients/aud0-client";
|
||||
import { validateRows, defineMapping, type RowError } from "@siax/contracts/import-export";
|
||||
import { serviceById, type Flow } from "@/lib/services";
|
||||
import { ENTITY_TYPES, FREQUENCIES, type EntityType, type Frequency } from "@/lib/data";
|
||||
|
||||
// C8: CSV-import of orders — the symmetric counterpart of GET /api/orders/export
|
||||
// (våg 3.4). The server is the ONLY authority: the raw CSV is parsed here
|
||||
// (RFC 4180, no eval), re-validated here via the frozen @siax/contracts kernel
|
||||
// (validateRows) plus host field rules, and executed through the EXISTING order
|
||||
// creation logic (createOrder) so imported rows are real tenant-owned orders.
|
||||
// Client-side wizard parsing is presentation entropy, never trusted.
|
||||
//
|
||||
// Fail-closed properties:
|
||||
// - No principal (requireOrderOwner) → 401, no parse, no writes.
|
||||
// - Unknown header column → 400 (never silently dropped).
|
||||
// - More than MAX_IMPORT_ROWS data rows → 413 (cap, never truncate silently).
|
||||
// - execute() re-validates and imports only rows that pass; failing rows are
|
||||
// reported per row (row/field/code) and never created. Each order insert is
|
||||
// individually atomic via createOrder (per-row transactional semantics);
|
||||
// a failing row can never corrupt a passing one.
|
||||
// - Audit: every successful execute emits an AUD0 event via 0pt's sole producer
|
||||
// boundary (durable outbox when DATABASE_URL is configured). Emission failure
|
||||
// is logged, never blocks the import (the outbox pattern owns delivery).
|
||||
|
||||
export const MAX_IMPORT_ROWS = 500;
|
||||
const MAX_CSV_CHARS = 1_000_000;
|
||||
const IMPORT_COLUMNS = ["flow", "service_type", "type", "volume", "frequency", "markets"] as const;
|
||||
const FLOWS: readonly Flow[] = ["catalog", "custom", "subscription"];
|
||||
const MAX_VOLUME = 1_000_000;
|
||||
|
||||
interface ImportRowError extends RowError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ParsedCsv {
|
||||
rows: Record<string, string>[];
|
||||
headerError?: string;
|
||||
}
|
||||
|
||||
/** Minimal RFC 4180 parser: quoted fields, doubled quotes, CRLF or LF. No eval. */
|
||||
export function parseCsv(text: string): ParsedCsv {
|
||||
const rows: string[][] = [];
|
||||
let field = "";
|
||||
let row: string[] = [];
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
field += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === ",") {
|
||||
row.push(field);
|
||||
field = "";
|
||||
} else if (ch === "\n") {
|
||||
row.push(field);
|
||||
field = "";
|
||||
rows.push(row);
|
||||
row = [];
|
||||
} else if (ch === "\r") {
|
||||
// consumed by the following \n (or ignored for bare CR)
|
||||
} else {
|
||||
field += ch;
|
||||
}
|
||||
}
|
||||
if (field.length > 0 || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
const nonEmpty = rows.filter((r) => r.length > 1 || (r[0] ?? "").trim() !== "");
|
||||
if (nonEmpty.length === 0) return { rows: [] };
|
||||
const header = nonEmpty[0].map((h) => h.trim().toLowerCase());
|
||||
const unknown = header.filter((h) => h !== "" && !(IMPORT_COLUMNS as readonly string[]).includes(h));
|
||||
if (unknown.length > 0) {
|
||||
return { rows: [], headerError: `Okänd kolumn: ${unknown.join(", ")} (tillåtna: ${IMPORT_COLUMNS.join(", ")})` };
|
||||
}
|
||||
return {
|
||||
rows: nonEmpty.slice(1).map((cells) => {
|
||||
const obj: Record<string, string> = {};
|
||||
header.forEach((h, idx) => {
|
||||
if (h) obj[h] = (cells[idx] ?? "").trim();
|
||||
});
|
||||
return obj;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Host field rules on top of the kernel's required/type checks. */
|
||||
function hostValidate(rows: Record<string, string>[]): ImportRowError[] {
|
||||
const errors: ImportRowError[] = [];
|
||||
rows.forEach((r, idx) => {
|
||||
const rowNo = idx + 1;
|
||||
const flow = (r.flow ?? "").trim() as Flow;
|
||||
if (flow && !FLOWS.includes(flow)) {
|
||||
errors.push({ row: rowNo, field: "flow", code: "bad_type", message: `Ogiltigt flow "${r.flow}" (tillåtna: ${FLOWS.join(", ")})` });
|
||||
}
|
||||
const st = (r.service_type ?? "").trim();
|
||||
if (st && !serviceById(st)) {
|
||||
errors.push({ row: rowNo, field: "service_type", code: "bad_type", message: `Okänd service_type "${st}"` });
|
||||
}
|
||||
const type = (r.type ?? "").trim();
|
||||
if (type && !ENTITY_TYPES.some((t) => t.id === type)) {
|
||||
errors.push({ row: rowNo, field: "type", code: "bad_type", message: `Ogiltig typ "${type}" (tillåtna: ${ENTITY_TYPES.map((t) => t.id).join(", ")})` });
|
||||
}
|
||||
const volume = (r.volume ?? "").trim();
|
||||
if (volume === "") {
|
||||
// optional — order logic applies flow defaults
|
||||
} else if (!/^\d+$/.test(volume) || Number(volume) < 1 || Number(volume) > MAX_VOLUME) {
|
||||
errors.push({ row: rowNo, field: "volume", code: "bad_type", message: `volume måste vara ett positivt heltal ≤ ${MAX_VOLUME}` });
|
||||
}
|
||||
const freq = (r.frequency ?? "").trim();
|
||||
if (freq && !FREQUENCIES.includes(freq as Frequency)) {
|
||||
errors.push({ row: rowNo, field: "frequency", code: "bad_type", message: `Ogiltig frequency "${freq}" (tillåtna: ${FREQUENCIES.join(", ")})` });
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
function buildConfig(r: Record<string, string>): OrderConfig {
|
||||
const config: OrderConfig = {};
|
||||
if ((r.service_type ?? "").trim()) config.service_type = r.service_type.trim();
|
||||
if ((r.type ?? "").trim()) config.type = r.type.trim() as EntityType;
|
||||
if ((r.volume ?? "").trim()) config.volume = Number(r.volume.trim());
|
||||
if ((r.frequency ?? "").trim()) config.frequency = r.frequency.trim() as Frequency;
|
||||
if ((r.markets ?? "").trim()) {
|
||||
config.markets = r.markets.split(";").map((m) => m.trim()).filter(Boolean);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export interface ImportReport {
|
||||
totalRows: number;
|
||||
validRows: number;
|
||||
errorCount: number;
|
||||
errors: ImportRowError[];
|
||||
}
|
||||
|
||||
/** Server-authoritative validation: kernel required/type checks + host rules. */
|
||||
export function validateImport(csv: string): { report: ImportReport; rows: Record<string, string>[]; headerError?: string } {
|
||||
const { rows, headerError } = parseCsv(csv);
|
||||
if (headerError) return { report: { totalRows: 0, validRows: 0, errorCount: 1, errors: [{ row: 0, field: "header", code: "format", message: headerError }] }, rows: [], headerError };
|
||||
const mapping = defineMapping({ columns: IMPORT_COLUMNS.map((c) => ({ source: c, target: c, required: c === "flow" })) });
|
||||
const kernel = validateRows(rows, mapping);
|
||||
const host = hostValidate(rows);
|
||||
const kernelErrors: ImportRowError[] = kernel.errors.map((e) => ({ ...e, message: e.message ?? `Valideringsfel (${e.code})` }));
|
||||
const errors = [...kernelErrors, ...host];
|
||||
return {
|
||||
report: {
|
||||
totalRows: rows.length,
|
||||
validRows: rows.length - new Set(errors.map((e) => e.row)).size,
|
||||
errorCount: errors.length,
|
||||
errors,
|
||||
},
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const auth = await requireOrderOwner(req);
|
||||
if (auth.response) return auth.response;
|
||||
const owner: OrderOwner = auth.owner;
|
||||
|
||||
let body: { mode?: string; csv?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: { code: "bad_json", message: "Invalid JSON" } }, { status: 400 });
|
||||
}
|
||||
const mode = body.mode;
|
||||
const csv = body.csv;
|
||||
if (!mode || !["validate", "dry_run", "execute"].includes(mode)) {
|
||||
return NextResponse.json({ error: { code: "bad_mode", message: "mode måste vara validate | dry_run | execute" } }, { status: 400 });
|
||||
}
|
||||
if (typeof csv !== "string" || csv.trim() === "") {
|
||||
return NextResponse.json({ error: { code: "bad_csv", message: "csv saknas eller är tom" } }, { status: 400 });
|
||||
}
|
||||
if (csv.length > MAX_CSV_CHARS) {
|
||||
return NextResponse.json({ error: { code: "csv_too_large", message: `CSV större än ${MAX_CSV_CHARS} tecken` } }, { status: 413 });
|
||||
}
|
||||
try {
|
||||
const { report, rows, headerError } = validateImport(csv);
|
||||
if (headerError) {
|
||||
return NextResponse.json({ error: { code: "bad_header", message: headerError }, report }, { status: 400 });
|
||||
}
|
||||
if (rows.length > MAX_IMPORT_ROWS) {
|
||||
return NextResponse.json({ error: { code: "row_cap_exceeded", message: `Max ${MAX_IMPORT_ROWS} rader per import` } }, { status: 413 });
|
||||
}
|
||||
|
||||
if (mode === "validate" || mode === "dry_run") {
|
||||
// No writes — dry_run is exactly validate plus an explicit rehearsal count.
|
||||
return NextResponse.json({ mode, ...report, would_import: report.validRows });
|
||||
}
|
||||
|
||||
// mode === "execute": server re-validates and imports ONLY passing rows.
|
||||
const failedRows = new Set(report.errors.map((e) => e.row));
|
||||
const orderIds: string[] = [];
|
||||
let imported = 0;
|
||||
for (let idx = 0; idx < rows.length; idx++) {
|
||||
if (failedRows.has(idx + 1)) continue;
|
||||
const r = rows[idx];
|
||||
try {
|
||||
const { order } = await createOrder((r.flow ?? "").trim(), buildConfig(r), undefined, owner);
|
||||
orderIds.push(order.order_id);
|
||||
imported++;
|
||||
} catch (err: unknown) {
|
||||
report.errors.push({ row: idx + 1, field: "row", code: "format", message: `Skapning misslyckades: ${err instanceof Error ? err.message : "okänt fel"}` });
|
||||
}
|
||||
}
|
||||
|
||||
// AUD0 audit — 0pt's sole producer boundary; durable outbox under DATABASE_URL.
|
||||
try {
|
||||
const aud0 = createAud0Client();
|
||||
await aud0.ingestEvent({
|
||||
event_id: (globalThis.crypto?.randomUUID?.() ?? `evt-${Date.now()}`),
|
||||
event_type: "0pt.orders.import",
|
||||
tenant_id: owner.tenantId,
|
||||
project_id: "0pt-orders",
|
||||
actor_id: owner.subject,
|
||||
app_id: "0pt",
|
||||
capability_id: "orders.import",
|
||||
resource_type: "order",
|
||||
resource_id: orderIds[0] ?? "none",
|
||||
risk_level: "L2",
|
||||
payload: { imported, total_rows: report.totalRows, error_count: report.errors.length, order_ids: orderIds },
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
} catch (auditErr: unknown) {
|
||||
console.error("[0pt-orders-import] AUD0 emission failed (import not rolled back):", auditErr);
|
||||
}
|
||||
|
||||
return NextResponse.json({ mode, imported, totalRows: report.totalRows, validRows: report.validRows, errorCount: report.errors.length, errors: report.errors.slice(0, 100), order_ids: orderIds });
|
||||
} catch (err: unknown) {
|
||||
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();
|
||||
}
|
||||
+8
-6
@@ -52,11 +52,13 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.19",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@siax/0pt-decision": "workspace:*",
|
||||
"@siax/charts": "^0.4.0",
|
||||
"@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.12.0",
|
||||
"@siax/ui": "^0.14.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -99,9 +101,9 @@
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"esbuild"
|
||||
]
|
||||
"overrides": {
|
||||
"@siax/ui>@siax/contracts": "0.10.0",
|
||||
"@siax/ui>@siax/import-export": "0.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+36
-291
@@ -4,6 +4,10 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
'@siax/ui>@siax/contracts': 0.10.0
|
||||
'@siax/ui>@siax/import-export': 0.2.0
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -102,20 +106,26 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:packages/decision
|
||||
'@siax/charts':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0(@siax/ui@0.12.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
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)
|
||||
'@siax/contracts':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0
|
||||
'@siax/import-export':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
'@siax/tokens':
|
||||
specifier: ^0.1.2
|
||||
version: 0.1.2
|
||||
'@siax/ui':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.5.5(postcss@8.5.28)
|
||||
@@ -1531,13 +1541,15 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@siax/charts@0.4.0':
|
||||
resolution: {integrity: sha512-Lbq/iDJLL3zL3qBKDaNevIUujeaJED8qLwuRt+qka771Y4QOSiwlbmTYR9Tz1F0kKNjxVvLQq178EDDmGYg4ew==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.4.0/charts-0.4.0.tgz}
|
||||
'@siax/charts@0.5.0':
|
||||
resolution: {integrity: sha512-hQA5s0OD0L5aka9yg2mzajoW1tU+RIjZco4lrJFRw8k+EBRPES6ooKi8OAUCsuO/5u+RWrQZsea9nWJCu0oI+g==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcharts/-/0.5.0/charts-0.5.0.tgz}
|
||||
peerDependencies:
|
||||
'@siax/ui': 0.12.0
|
||||
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:
|
||||
@@ -1546,11 +1558,14 @@ packages:
|
||||
'@siax/contracts@0.10.0':
|
||||
resolution: {integrity: sha512-2WVHNsR80hkwWEfVxKvi9/lhW0CDpNGLvVvyFu39bakN4+IXfo5y16gCpqs3v9NC0/0+YGt7kAW1qhsvlm/VKw==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fcontracts/-/0.10.0/contracts-0.10.0.tgz}
|
||||
|
||||
'@siax/import-export@0.2.0':
|
||||
resolution: {integrity: sha512-QCY6gXNeazsV8hBY2cGClFRMVkNKUPbpch93ZQOQVtClxJ6+JfXdQObRu7o40bt3L4/OBlMdzVW+pqPB8AKPiA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fimport-export/-/0.2.0/import-export-0.2.0.tgz}
|
||||
|
||||
'@siax/tokens@0.1.2':
|
||||
resolution: {integrity: sha512-pz4tP8cyzhWh5gGw9GQLHjl4E/w/WemfYR5x5rdejneg+OSJYEmf6pvGCirj7247DhPqX0BH19uJK8ZN8S/Hcg==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Ftokens/-/0.1.2/tokens-0.1.2.tgz}
|
||||
|
||||
'@siax/ui@0.12.0':
|
||||
resolution: {integrity: sha512-WUsZJrvbOg46su0E+gjqyXds+4s4r5Mzuye8oXEsAHNFJPzU+90B6QjfpjAqOWCBCE6mPdXOL3te6QqYrSl5pA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.12.0/ui-0.12.0.tgz}
|
||||
'@siax/ui@0.14.0':
|
||||
resolution: {integrity: sha512-cpQ+USiwShCY1oiA4oCp405OdOzYZkUUQenhZY2fPneLxpmm57I11KgF5TjgT7e7VZAIg2LZcmH/7GNo1TV4XA==, tarball: https://git.siax.io/api/packages/sax3l/npm/%40siax%2Fui/-/0.14.0/ui-0.14.0.tgz}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
@@ -1669,33 +1684,6 @@ packages:
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-ease@3.0.2':
|
||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-path@3.1.1':
|
||||
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||
|
||||
'@types/d3-time@3.0.4':
|
||||
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
@@ -1988,50 +1976,6 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-array@3.2.4:
|
||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time@3.1.0:
|
||||
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
date-fns-jalali@4.1.0-0:
|
||||
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
|
||||
|
||||
@@ -2047,9 +1991,6 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||
|
||||
@@ -2076,9 +2017,6 @@ packages:
|
||||
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
echarts@6.1.0:
|
||||
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||
|
||||
@@ -2188,9 +2126,6 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -2198,10 +2133,6 @@ packages:
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-equals@5.4.0:
|
||||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fast-json-stable-stringify@2.1.0:
|
||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||
|
||||
@@ -2314,10 +2245,6 @@ packages:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
interpret@3.1.1:
|
||||
resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -2490,13 +2417,6 @@ packages:
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
@@ -2568,10 +2488,6 @@ packages:
|
||||
resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2652,9 +2568,6 @@ packages:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
property-information@7.2.0:
|
||||
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
|
||||
|
||||
@@ -2689,9 +2602,6 @@ packages:
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-is@18.3.1:
|
||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||
|
||||
react-remove-scroll-bar@2.3.8:
|
||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2724,12 +2634,6 @@ packages:
|
||||
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
react-smooth@4.0.4:
|
||||
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2740,12 +2644,6 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-transition-group@4.4.5:
|
||||
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
|
||||
peerDependencies:
|
||||
react: '>=16.6.0'
|
||||
react-dom: '>=16.6.0'
|
||||
|
||||
react18-json-view@0.2.10:
|
||||
resolution: {integrity: sha512-rYEbaCG/U4THY1qp1xY14/Kbnp9yY3W6Qm3Rmu+jlCIdxzMS5EcD+wI97kCKRoN3CuJyJU8hqkax5xWfl8A4EA==}
|
||||
peerDependencies:
|
||||
@@ -2755,17 +2653,6 @@ packages:
|
||||
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
|
||||
|
||||
recharts@2.15.4:
|
||||
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
|
||||
engines: {node: '>=14'}
|
||||
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
rechoir@0.8.0:
|
||||
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
@@ -2920,9 +2807,6 @@ packages:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -3030,9 +2914,6 @@ packages:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
victory-vendor@36.9.2:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
|
||||
vite-node@3.2.4:
|
||||
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -4338,12 +4219,15 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.62.2':
|
||||
optional: true
|
||||
|
||||
'@siax/charts@0.4.0(@siax/ui@0.12.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
'@siax/charts@0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@siax/ui': 0.12.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
|
||||
clsx: 2.1.1
|
||||
echarts: 6.1.0
|
||||
react: 19.2.8
|
||||
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:
|
||||
@@ -4351,9 +4235,13 @@ snapshots:
|
||||
|
||||
'@siax/contracts@0.10.0': {}
|
||||
|
||||
'@siax/import-export@0.2.0':
|
||||
dependencies:
|
||||
'@siax/contracts': 0.10.0
|
||||
|
||||
'@siax/tokens@0.1.2': {}
|
||||
|
||||
'@siax/ui@0.12.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
|
||||
'@siax/ui@0.14.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -4376,8 +4264,10 @@ snapshots:
|
||||
'@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@siax/charts': 0.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@siax/config': 0.2.1(tailwindcss@4.3.3)
|
||||
'@siax/contracts': 0.10.0
|
||||
'@siax/import-export': 0.2.0
|
||||
'@siax/tokens': 0.1.2
|
||||
'@tanstack/react-table': 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@tanstack/react-virtual': 3.14.13(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -4392,7 +4282,6 @@ snapshots:
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
react-resizable-panels: 3.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
react18-json-view: 0.2.10(react@19.2.8)
|
||||
recharts: 2.15.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
sonner: 1.7.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
tailwind-merge: 2.6.1
|
||||
vaul: 1.1.2(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -4496,30 +4385,6 @@ snapshots:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-ease@3.0.2': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-path@3.1.1': {}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
|
||||
'@types/d3-time@3.0.4': {}
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
@@ -4847,44 +4712,6 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
dependencies:
|
||||
internmap: 2.0.3
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
dependencies:
|
||||
d3-time: 3.1.0
|
||||
|
||||
d3-time@3.1.0:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
date-fns-jalali@4.1.0-0: {}
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
@@ -4893,8 +4720,6 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
@@ -4930,11 +4755,6 @@ snapshots:
|
||||
|
||||
diff@9.0.0: {}
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
csstype: 3.2.3
|
||||
|
||||
echarts@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
@@ -5086,14 +4906,10 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-equals@5.4.0: {}
|
||||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
@@ -5186,8 +5002,6 @@ snapshots:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
interpret@3.1.1: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
@@ -5314,12 +5128,6 @@ snapshots:
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
lucide-react@0.400.0(react@19.2.8):
|
||||
@@ -5384,8 +5192,6 @@ snapshots:
|
||||
|
||||
node-releases@2.0.55: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -5465,12 +5271,6 @@ snapshots:
|
||||
kleur: 3.0.3
|
||||
sisteransi: 1.0.5
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
property-information@7.2.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
@@ -5508,8 +5308,6 @@ snapshots:
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
@@ -5539,14 +5337,6 @@ snapshots:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
react-smooth@4.0.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
fast-equals: 5.4.0
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
react-transition-group: 4.4.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.8):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
@@ -5555,15 +5345,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.18
|
||||
|
||||
react-transition-group@4.4.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
dom-helpers: 5.2.1
|
||||
loose-envify: 1.4.0
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
|
||||
react18-json-view@0.2.10(react@19.2.8):
|
||||
dependencies:
|
||||
copy-to-clipboard: 3.3.3
|
||||
@@ -5571,23 +5352,6 @@ snapshots:
|
||||
|
||||
react@19.2.8: {}
|
||||
|
||||
recharts-scale@0.4.5:
|
||||
dependencies:
|
||||
decimal.js-light: 2.5.1
|
||||
|
||||
recharts@2.15.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
eventemitter3: 4.0.7
|
||||
lodash: 4.18.1
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
react-is: 18.3.1
|
||||
react-smooth: 4.0.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
recharts-scale: 0.4.5
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
rechoir@0.8.0:
|
||||
dependencies:
|
||||
resolve: 1.22.12
|
||||
@@ -5752,8 +5516,6 @@ snapshots:
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
@@ -5853,23 +5615,6 @@ snapshots:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
victory-vendor@36.9.2:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-node@3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
|
||||
@@ -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,156 @@
|
||||
/**
|
||||
* C8: tests for POST /api/orders/import — the symmetric counterpart of the
|
||||
* våg-3.4 CSV export and the first real product host of the @siax/ui
|
||||
* ImportWizard kernel. Follows the suite pattern of calling the route handler
|
||||
* directly with a hand-built NextRequest and mocked auth/store/audit.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const mockOwner = { owner: { tenantId: "tenant-test", subject: "buyer-test" } };
|
||||
const requireOrderOwnerMock = vi.fn();
|
||||
vi.mock("@/src/auth/require-order-owner", () => ({
|
||||
requireOrderOwner: (...args: unknown[]) => requireOrderOwnerMock(...args),
|
||||
}));
|
||||
|
||||
const createOrderMock = vi.fn();
|
||||
vi.mock("@/lib/orders-store", () => ({
|
||||
createOrder: (...args: unknown[]) => createOrderMock(...args),
|
||||
}));
|
||||
|
||||
const ingestEventMock = vi.fn().mockResolvedValue({});
|
||||
vi.mock("@/src/clients/aud0-client", () => ({
|
||||
createAud0Client: () => ({ ingestEvent: ingestEventMock }),
|
||||
}));
|
||||
|
||||
import { POST, parseCsv, validateImport, MAX_IMPORT_ROWS } from "@/app/api/orders/import/route";
|
||||
|
||||
function postReq(body: unknown, withAuth = true): NextRequest {
|
||||
requireOrderOwnerMock.mockResolvedValue(withAuth ? mockOwner : { response: NextResponse.json({ error: "OPT_UNAUTHENTICATED" }, { status: 401 }) });
|
||||
return new NextRequest(new URL("/api/orders/import", "http://localhost"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: withAuth ? { authorization: "Bearer x" } : {},
|
||||
});
|
||||
}
|
||||
|
||||
const HEADER = "flow,service_type,type,volume,frequency,markets";
|
||||
|
||||
function csv(lines: string[]): string {
|
||||
return [HEADER, ...lines].join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createOrderMock.mockReset();
|
||||
ingestEventMock.mockClear();
|
||||
createOrderMock.mockImplementation(async (flow: string, config: unknown, _key: unknown, owner: { tenantId: string }) => ({
|
||||
order: { order_id: `ord_${createOrderMock.mock.calls.length}`, flow, status: "draft", config, tenant_id: owner.tenantId },
|
||||
replayed: false,
|
||||
}));
|
||||
});
|
||||
|
||||
describe("parseCsv (server-parsed, RFC 4180, no eval)", () => {
|
||||
it("parses quoted fields with doubled quotes", () => {
|
||||
const { rows } = parseCsv('flow,markets\r\ncustom,"A;""B,C"""\r\n');
|
||||
expect(rows[0].flow).toBe("custom");
|
||||
expect(rows[0].markets).toBe('A;"B,C"');
|
||||
});
|
||||
|
||||
it("rejects unknown header columns (fail-closed, never silently dropped)", () => {
|
||||
const { headerError } = parseCsv("flow,evil\r\ncustom,x\r\n");
|
||||
expect(headerError).toMatch(/Okänd kolumn: evil/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateImport — kernel + host rules", () => {
|
||||
it("reports missing required flow as missing_required", () => {
|
||||
const { report } = validateImport(csv([",icp,Bolag,10,Engångs,SE"]));
|
||||
expect(report.errorCount).toBeGreaterThan(0);
|
||||
expect(report.errors.some((e) => e.code === "missing_required")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports bad host fields with row/field/code", () => {
|
||||
const { report } = validateImport(csv(["nope,icp,Bolag,-5,Engångs,SE"]));
|
||||
const fields = report.errors.map((e) => e.field).sort();
|
||||
expect(fields).toContain("flow");
|
||||
expect(fields).toContain("volume");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/orders/import", () => {
|
||||
it("happy path: execute imports all valid rows through createOrder with the verified owner + emits AUD0", async () => {
|
||||
const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE", "catalog,icp,Fordon,2,Engångs,", "subscription,,Beslutsfattare,50,Veckovis,SE;NO"]) }));
|
||||
expect(r.status).toBe(200);
|
||||
const body = await r.json();
|
||||
expect(body.imported).toBe(3);
|
||||
expect(body.errors).toHaveLength(0);
|
||||
expect(body.order_ids).toHaveLength(3);
|
||||
expect(createOrderMock).toHaveBeenCalledTimes(3);
|
||||
expect(createOrderMock.mock.calls[0][3]).toEqual({ tenantId: "tenant-test", subject: "buyer-test" });
|
||||
expect(createOrderMock.mock.calls[0][1]).toMatchObject({ service_type: "icp", type: "Bolag", volume: 10, frequency: "Engångs", markets: ["SE"] });
|
||||
expect(createOrderMock.mock.calls[2][1]).toMatchObject({ markets: ["SE", "NO"] });
|
||||
expect(ingestEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(ingestEventMock.mock.calls[0][0]).toMatchObject({ event_type: "0pt.orders.import", tenant_id: "tenant-test", risk_level: "L2" });
|
||||
});
|
||||
|
||||
it("partial success: failing rows are reported per row and never created; passing rows import", async () => {
|
||||
const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE", "bogus_flow,icp,Bolag,10,Engångs,SE", "catalog,icp,Bolag,notanumber,Engångs,SE"]) }));
|
||||
expect(r.status).toBe(200);
|
||||
const body = await r.json();
|
||||
expect(body.imported).toBe(1);
|
||||
expect(body.errorCount).toBe(2);
|
||||
const badFlow = body.errors.find((e: { field: string }) => e.field === "flow");
|
||||
const badVol = body.errors.find((e: { field: string }) => e.field === "volume");
|
||||
expect(badFlow.row).toBe(2);
|
||||
expect(badVol.row).toBe(3);
|
||||
expect(createOrderMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("validate/dry_run never write (createOrder not called)", async () => {
|
||||
const r1 = await POST(postReq({ mode: "validate", csv: csv(["custom,icp,Bolag,10,Engångs,SE"]) }));
|
||||
expect(r1.status).toBe(200);
|
||||
const b1 = await r1.json();
|
||||
expect(b1.validRows).toBe(1);
|
||||
expect(b1.would_import).toBe(1);
|
||||
const r2 = await POST(postReq({ mode: "dry_run", csv: csv(["custom,icp,Bolag,10,Engångs,SE"]) }));
|
||||
expect(r2.status).toBe(200);
|
||||
expect(createOrderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fail-closed auth: no principal → 401 and createOrder is never reached", async () => {
|
||||
const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE"]) }, false));
|
||||
expect(r.status).toBe(401);
|
||||
expect(createOrderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("row cap: more than MAX_IMPORT_ROWS data rows → 413, no writes", async () => {
|
||||
const lines = Array.from({ length: MAX_IMPORT_ROWS + 1 }, () => "custom,icp,Bolag,10,Engångs,SE");
|
||||
const r = await POST(postReq({ mode: "execute", csv: csv(lines) }));
|
||||
expect(r.status).toBe(413);
|
||||
expect(createOrderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unknown header → 400 fail-closed", async () => {
|
||||
const r = await POST(postReq({ mode: "execute", csv: "flow,evil\r\ncustom,x\r\n" }));
|
||||
expect(r.status).toBe(400);
|
||||
const body = await r.json();
|
||||
expect(body.error.code).toBe("bad_header");
|
||||
});
|
||||
|
||||
it("empty/missing csv → 400", async () => {
|
||||
const r = await POST(postReq({ mode: "execute", csv: " " }));
|
||||
expect(r.status).toBe(400);
|
||||
});
|
||||
|
||||
it("execute-time creation failure is reported per row without aborting the batch", async () => {
|
||||
createOrderMock.mockImplementationOnce(async () => {
|
||||
throw new Error("db down");
|
||||
});
|
||||
const r = await POST(postReq({ mode: "execute", csv: csv(["custom,icp,Bolag,10,Engångs,SE", "catalog,icp,Bolag,1,Engångs,SE"]) }));
|
||||
expect(r.status).toBe(200);
|
||||
const body = await r.json();
|
||||
expect(body.imported).toBe(1);
|
||||
expect(body.errors[0].row).toBe(1);
|
||||
expect(body.errors[0].message).toContain("db down");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* C8: SSR-render proof that the host can render the @siax/ui ImportWizard over
|
||||
* the @siax/import-export runtime store (the exact packages the admin import
|
||||
* page uses). Node environment — react-dom/server, no DOM needed.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// ImportWizard subscribes to the runtime store via useSyncExternalStore, which
|
||||
// demands getServerSnapshot under react-dom/server. For a static render proof
|
||||
// the snapshot read is enough — reactivity is the browser's concern.
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const React = await importOriginal<typeof import("react")>();
|
||||
return { ...React, useSyncExternalStore: ((_subscribe: unknown, getSnapshot: () => unknown) => getSnapshot()) as typeof React.useSyncExternalStore };
|
||||
});
|
||||
import { createElement } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { ImportWizard } from "@siax/ui";
|
||||
import { createImportRunStore } from "@siax/import-export";
|
||||
import { createImportJob } from "@siax/contracts/import-export";
|
||||
|
||||
function makeStore(rows: Record<string, string>[]) {
|
||||
return createImportRunStore({
|
||||
job: createImportJob({
|
||||
id: "test-import",
|
||||
tenantId: "tenant-test",
|
||||
entity: "orders",
|
||||
format: "csv",
|
||||
source: { system: "0pt-admin", channel: "web" },
|
||||
createdAt: "2026-09-30T00:00:00Z",
|
||||
}),
|
||||
rows,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ImportWizard renders over the runtime store (host integration)", () => {
|
||||
it("empty store → explicit empty state with the pipeline <ol>", () => {
|
||||
const html = renderToString(
|
||||
createElement(ImportWizard, { store: makeStore([]), onExecute: async () => {} }),
|
||||
);
|
||||
expect(html).toContain("<ol");
|
||||
expect(html.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("rows present → pipeline steps render; mapped stage shows mapping table", () => {
|
||||
const store = makeStore([{ flow: "custom", service_type: "icp", type: "Bolag", volume: "10", frequency: "Engångs", markets: "SE" }]);
|
||||
store.detect();
|
||||
let html = renderToString(createElement(ImportWizard, { store, columns: [{ source: "flow", target: "flow" }], onExecute: async () => {} }));
|
||||
expect(html).toContain("<ol");
|
||||
expect(html).toContain("aria-current=\"step\"");
|
||||
store.map({ columns: [{ source: "flow", target: "flow", required: true }] });
|
||||
html = renderToString(createElement(ImportWizard, { store, onExecute: async () => {} }));
|
||||
expect(html).toContain("flow");
|
||||
});
|
||||
|
||||
it("failed store renders the failure report with role=alert", () => {
|
||||
const store = makeStore([{ flow: "custom" }]);
|
||||
store.detect();
|
||||
store.fail("Servern avvisade exekveringen");
|
||||
const html = renderToString(createElement(ImportWizard, { store, onExecute: async () => {} }));
|
||||
expect(html).toContain("alert");
|
||||
expect(html).toContain("Servern avvisade exekveringen");
|
||||
});
|
||||
});
|
||||
@@ -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