From c3582838aaccaa2923dcc6bc9fc4a038434bb9fc Mon Sep 17 00:00:00 2001 From: sax3l Date: Tue, 29 Sep 2026 21:15:35 +0200 Subject: [PATCH] feat(orders): kanonisk statusmaskin + guardad DB-constraint (ADR-0002, C3) --- app/admin/page.tsx | 8 +-- docs/adr/ADR-0002-kanonisk-orderstatus.md | 61 +++++++++++++++++++++ lib/order-flow.ts | 64 +++++++++++++++++++---- sql/015_order_status_check.sql | 64 +++++++++++++++++++++++ tests/order-flow.test.ts | 60 ++++++++++++++++++++- 5 files changed, 241 insertions(+), 16 deletions(-) create mode 100644 docs/adr/ADR-0002-kanonisk-orderstatus.md create mode 100644 sql/015_order_status_check.sql diff --git a/app/admin/page.tsx b/app/admin/page.tsx index e82ae33..2797996 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,7 +1,7 @@ "use client"; import { ActionGate, InboxList } from "@siax/ui"; -import { NEXT_STATUS } from "@/lib/order-flow"; +import { nextOrderStatus } from "@/lib/order-flow"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; @@ -54,7 +54,7 @@ export default function AdminPage() { useEffect(() => { void load(); }, [load]); async function advance(id: string, status: string) { - const next = NEXT_STATUS[status]; + const next = nextOrderStatus(status); if (!next) return; try { const r = await fetch(`/api/orders/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: next }) }); @@ -104,7 +104,7 @@ export default function AdminPage() { }} onResolve={(item) => { const order = orders.find((o) => `order:${o.order_id}` === item.id); - if (order?.advance_action?.state === "available" && NEXT_STATUS[order.status]) { + if (order?.advance_action?.state === "available" && nextOrderStatus(order.status)) { advance(order.order_id, order.status); } }} @@ -139,7 +139,7 @@ export default function AdminPage() { onClick={() => advance(o.order_id, o.status)} className="border border-foreground px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.08em] transition-colors hover:bg-foreground hover:text-background" > - → {NEXT_STATUS[o.status] ?? t({ en: "done", sv: "klar" })} + → {nextOrderStatus(o.status) ?? t({ en: "done", sv: "klar" })} diff --git a/docs/adr/ADR-0002-kanonisk-orderstatus.md b/docs/adr/ADR-0002-kanonisk-orderstatus.md new file mode 100644 index 0000000..057064d --- /dev/null +++ b/docs/adr/ADR-0002-kanonisk-orderstatus.md @@ -0,0 +1,61 @@ +# ADR-0002 — Kanonisk orderstatus: kodnivå-union + guardad DB-constraint + +**Status:** Accepted +**Date:** 2026-09-29 +**Owners:** 0pt (simon@siax.io) +**Related requirements:** C3 (kanonisera orderstatus), DoD §17 (backup-städ), §33 (migreringsordning), §35 (restore-drill) +**Related artifacts:** `lib/order-flow.ts` (`ORDER_STATUSES`, `OrderStatus`, `isKnownOrderStatus`, `nextOrderStatus`), `sql/015_order_status_check.sql` + +## Context + +`orders.status` är fri text (`sql/001_init.sql` line ~92: `status text NOT NULL DEFAULT 'draft'`). +Flödet draft→quoted→in_production→review→accepted→delivered är känt i kod, men +DB:n nekar inga andra värden — fri text har redan orsakat minst en missvisande +"klar"-visning i ordkön (okänd status renderades som "done/klar" utan att +ordern faktiskt levererats). + +**Prod-harvest ej körbar i session** (2026-09-29): ingen `DATABASE_URL`/`POSTGRES_URL` +för 0pt finns i Infisical (varken default-env eller `/0pt`-sökvägar — körningen +injicerade 246 secrets utan order-DB-URL). Statusfördelningen i prod är därför +okänd här; migreringen är guardad och manuell. + +## Existing implementations + +- `lib/order-flow.ts` — NEXT_STATUS-flödet, `resolveAdvanceAction`, `buildOrderInboxItem`. +- `app/admin/page.tsx` + `app/api/orders/route.ts` konsumerar flödet. +- `tests/order-flow.test.ts` behandlar `cancelled` som terminal read_only-status. + +## Decision + +1. **Kodnivå-union är SSOT:** `type OrderStatus` = de sex flödesstatusarna + + `cancelled` (används i tests som terminal status — därmed med, trots utebliven + harvest). `ORDER_STATUSES` är den utömmande listan; `isKnownOrderStatus()` + klassar fri text fail-closed. NEXT_STATUS/resolveAdvanceAction/ + buildOrderInboxItem konsumerar unionen via `nextOrderStatus()` — okänd + status ger alltid read_only / ingen inbox-post, aldrig påhittat nästa steg. +2. **DB guardas — inte typ-migreras:** `sql/015_order_status_check.sql` lägger + `CHECK (status IN (...)) NOT VALID` (fas A, kort lås, nekar vid dirt-data via + DO-preflight) och `VALIDATE CONSTRAINT` i separat session (fas B, SHARE UPDATE + EXCLUSIVE + `lock_timeout='5s'`). KÖRS EJ mot prod i sessionen — Simon kör + efter harvest-rapport och backup (`pg_dump -t orders` före fas A). + +## Alternativ avvisade + +- **Fortsatt fri text** — redan orsakat missvisande "klar"; ingen möjlighet att + fela tidigt vid skrivning. +- **Omedelbar NOT VALID utan harvest** — risk för prod-block: NOT VALID validerar + inte befintliga rader, men VALIDATE-fasen (och varje ny UPDATE) krockar med + okända statusar; utan känd statusfördelning är fas B en lotteri. +- **Postgres `enum`-typ** — kräver typmigrering av kolumnen (ALTER TYPE-lås, + ADD VALUE kan ej ske i transaktion i äldre PG), tyngre drift än en CHECK som + kan utökas med en rad kod + en rullande migrering. + +## Consequences + +- Ny status = en rad i `ORDER_STATUSES`/`NEXT_STATUS` + en uppföljningsmigrering + som lägger till värdet i CHECK:en (drop+add NOT VALID, validate igen). +- Rader med okänd status (om harvest hittar sådana) mappas i en EGEN, granskad + migrering före fas A — preflighten nekar annars körning (fail-closed). +- SQL-listan och kodlistan är dubbelbokning; testen i + `tests/order-flow.test.ts` låser att alla `ORDER_STATUSES` har definierat + beteende (nästa steg eller read_only). diff --git a/lib/order-flow.ts b/lib/order-flow.ts index 77df17b..4ef45a2 100644 --- a/lib/order-flow.ts +++ b/lib/order-flow.ts @@ -1,8 +1,43 @@ import { createInboxItem, type InboxItem } from "@siax/contracts/inbox"; import { resolveAction, type ResolvedAction } from "@siax/contracts/authz-ux"; -/** Kanonisk orderflöde — servern äger resolutionen (ADR-0001), UI renderar. */ -export const NEXT_STATUS: Record = { +/** + * Kanonisk orderstatus (ADR-0002, C3 2026-09-29): kodnivå-union som är den + * ENDA sanningskällan för vilka statusar som existerar. DB-kolumnen är fortfarande + * `text` (sql/001_init.sql line ~92) men guardas av sql/015_order_status_check.sql + * (CHECK ... NOT VALID + VALIDATE i två steg, körs EJ automatiskt mot prod). + * 'cancelled' ingår: tests/order-flow.test.ts behandlar den som terminal status. + */ +export type OrderStatus = + | "draft" + | "quoted" + | "in_production" + | "review" + | "accepted" + | "delivered" + | "cancelled"; + +/** Uttömmande, kanonisk lista — SQL-constrainten i sql/015 MÅSTE spegla denna. */ +export const ORDER_STATUSES = [ + "draft", + "quoted", + "in_production", + "review", + "accepted", + "delivered", + "cancelled", +] as const satisfies readonly OrderStatus[]; + +/** Typ-vakt: fri text ur DB/payload klassas mot unionen (fail-closed mot read_only). */ +export function isKnownOrderStatus(status: string): status is OrderStatus { + return (ORDER_STATUSES as readonly string[]).includes(status); +} + +/** Statusar som kan flyttas fram; delivered/cancelled är terminala. */ +type AdvanceableStatus = Exclude; + +/** Kanoniskt flöde — servern äger resolutionen (ADR-0001), UI renderar. */ +export const NEXT_STATUS: Record = { draft: "quoted", quoted: "in_production", in_production: "review", @@ -10,21 +45,28 @@ export const NEXT_STATUS: Record = { accepted: "delivered", }; +/** Guardad nästa-status: okänd/terminal status → null (aldrig påhittat steg). */ +export function nextOrderStatus(status: string): OrderStatus | null { + if (!isKnownOrderStatus(status)) return null; + return NEXT_STATUS[status as AdvanceableStatus] ?? null; +} + /** * Server-renderad action-resolution för advance-steget (ADR-0001): * ägarskapenforces redan av requireOrderOwner på GET/PATCH; tillståndet * avgör read_only. Innan Session/roller finns i 0pt är permission alltid * granted för autentiserade ägare — hårdkoda aldrig denna resolve i klienten. + * Okänd status (fri text ur DB innan sql/015-valideringen körts) → read_only. */ export function resolveAdvanceAction(status: string): ResolvedAction { - if (NEXT_STATUS[status]) { + if (nextOrderStatus(status)) { return resolveAction({ permission: "granted", entitlement: "active", approval: "not_required", readOnly: false }); } return resolveAction({ permission: "granted", entitlement: "active", approval: "not_required", readOnly: true }); } /** Orderstatus som utgör en inkorgspost (våg 3.3: orders kö = inbox). */ -const INBOX_ELIGIBLE: Record = { +const INBOX_ELIGIBLE: Partial> = { draft: { kind: "task", priority: "normal" }, quoted: { kind: "task", priority: "normal" }, in_production: { kind: "task", priority: "normal" }, @@ -34,7 +76,8 @@ const INBOX_ELIGIBLE: Record 0 THEN + RAISE EXCEPTION + 'order_status_check preflight: % rader med status utanför den kanoniska unionen — kör harvest (SELECT status, COUNT(*) FROM orders GROUP BY status), mappa okända statusar, KÖR EJ fas A', unknown_rows; + END IF; +END +$preflight$; + +-- Rapportera (loggar) okända statusar om preflighten ändå passerar tom: +SELECT status, COUNT(*) AS n +FROM orders +WHERE status NOT IN ('draft', 'quoted', 'in_production', 'review', 'accepted', 'delivered', 'cancelled') +GROUP BY status; +-- (0 rader = grönt; >0 rader = ovanstående DO-block har redan avbrutit.) + +ALTER TABLE orders + ADD CONSTRAINT order_status_check + CHECK (status IN ('draft', 'quoted', 'in_production', 'review', 'accepted', 'delivered', 'cancelled')) + NOT VALID; + +COMMIT; + +-- FAS B körs i EGEN session (se körordning steg 4) — kommenterad här för att +-- den ALDRIG ska ligga i samma transaktion som fas A: +-- BEGIN; SET lock_timeout = '5s'; +-- ALTER TABLE orders VALIDATE CONSTRAINT order_status_check; +-- COMMIT; diff --git a/tests/order-flow.test.ts b/tests/order-flow.test.ts index 6ec9a53..2279006 100644 --- a/tests/order-flow.test.ts +++ b/tests/order-flow.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { NEXT_STATUS, resolveAdvanceAction, buildOrderInboxItem } from "@/lib/order-flow"; +import { + ORDER_STATUSES, + NEXT_STATUS, + isKnownOrderStatus, + nextOrderStatus, + resolveAdvanceAction, + buildOrderInboxItem, +} from "@/lib/order-flow"; describe("order-flow — server-renderad advance-resolution (ADR-0001)", () => { it("flödets fem steg har nästa status", () => { @@ -18,6 +25,57 @@ describe("order-flow — server-renderad advance-resolution (ADR-0001)", () => { }); }); +describe("order-flow — kanonisk statusmaskin (ADR-0002, C3)", () => { + const TERMINAL: ReadonlySet = new Set(["delivered", "cancelled"]); + const EXPECTED_NEXT: Record = { + draft: "quoted", + quoted: "in_production", + in_production: "review", + review: "accepted", + accepted: "delivered", + delivered: null, + cancelled: null, + }; + + it("ORDER_STATUSES är utömmande: varje känd status → korrekt nästa/read_only", () => { + expect(ORDER_STATUSES).toHaveLength(7); + for (const status of ORDER_STATUSES) { + expect(isKnownOrderStatus(status), `känd status: ${status}`).toBe(true); + if (TERMINAL.has(status)) { + expect(nextOrderStatus(status), `${status} är terminal`).toBeNull(); + expect(resolveAdvanceAction(status)).toMatchObject({ state: "read_only", reason: "read_only_resource" }); + expect(buildOrderInboxItem({ order_id: "abcd1234-5678", status, flow: "custom" })).toBeNull(); + } else { + expect(nextOrderStatus(status)).toBe(EXPECTED_NEXT[status]); + expect(resolveAdvanceAction(status)).toMatchObject({ state: "available" }); + } + } + }); + + it("flödet är en linjedraft→…→delivered-kedja utan genvägar", () => { + let cursor: string | null = "draft"; + const walked: string[] = []; + while (cursor) { + walked.push(cursor); + cursor = nextOrderStatus(cursor); + } + expect(walked).toEqual(["draft", "quoted", "in_production", "review", "accepted", "delivered"]); + }); + + it("okänd status (fri text ur DB) → read_only, ingen nästa, ingen inbox-post (guardad)", () => { + for (const status of ["klar", "Klar", "done", "", "in prod", "cancelled "]) { + expect(isKnownOrderStatus(status)).toBe(false); + expect(nextOrderStatus(status)).toBeNull(); + expect(resolveAdvanceAction(status)).toMatchObject({ state: "read_only", reason: "read_only_resource" }); + expect(buildOrderInboxItem({ order_id: "abcd1234-5678", status, flow: "custom" })).toBeNull(); + } + }); + + it("cancelled ingår i unionen (används som terminal status i tests/produkten)", () => { + expect(ORDER_STATUSES).toContain("cancelled"); + }); +}); + describe("order-flow — inbox-projektion (våg 3.3)", () => { const order = (over: Partial<{ order_id: string; status: string; flow: string; total_ore: number }> = {}) => ({ order_id: "abcd1234-5678", -- 2.54.0