feat(orders): kanonisk statusmaskin + guardad DB-constraint (ADR-0002, C3) #132
+4
-4
@@ -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" })}
|
||||
</button>
|
||||
</ActionGate>
|
||||
</span>
|
||||
|
||||
@@ -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).
|
||||
+53
-11
@@ -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<string, string> = {
|
||||
/**
|
||||
* 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<OrderStatus, "delivered" | "cancelled">;
|
||||
|
||||
/** Kanoniskt flöde — servern äger resolutionen (ADR-0001), UI renderar. */
|
||||
export const NEXT_STATUS: Record<AdvanceableStatus, OrderStatus> = {
|
||||
draft: "quoted",
|
||||
quoted: "in_production",
|
||||
in_production: "review",
|
||||
@@ -10,21 +45,28 @@ export const NEXT_STATUS: Record<string, string> = {
|
||||
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<string, { kind: "task" | "approval"; priority: "normal" | "high" }> = {
|
||||
const INBOX_ELIGIBLE: Partial<Record<OrderStatus, { kind: "task" | "approval"; priority: "normal" | "high" }>> = {
|
||||
draft: { kind: "task", priority: "normal" },
|
||||
quoted: { kind: "task", priority: "normal" },
|
||||
in_production: { kind: "task", priority: "normal" },
|
||||
@@ -34,7 +76,8 @@ const INBOX_ELIGIBLE: Record<string, { kind: "task" | "approval"; priority: "nor
|
||||
/**
|
||||
* Server-renderad inkorgspost (ADR-0001-mönstret): en order som väntar på
|
||||
* nästa steg projiceras på universal-inbox-konvolverna. Terminala statusar
|
||||
* (delivered m.fl.) ger ingen post — inboxen är öppna arbetsobjekt.
|
||||
* (delivered/cancelled) och okända statusar ger ingen post — inboxen är
|
||||
* öppna arbetsobjekt.
|
||||
*/
|
||||
export function buildOrderInboxItem(order: {
|
||||
order_id: string;
|
||||
@@ -42,19 +85,18 @@ export function buildOrderInboxItem(order: {
|
||||
flow: string;
|
||||
total_ore?: number | null;
|
||||
}): InboxItem | null {
|
||||
const eligible = INBOX_ELIGIBLE[order.status];
|
||||
if (!eligible) return null;
|
||||
const next = nextOrderStatus(order.status);
|
||||
const eligible = isKnownOrderStatus(order.status) ? INBOX_ELIGIBLE[order.status] : undefined;
|
||||
if (!eligible || !next) return null;
|
||||
return createInboxItem({
|
||||
id: `order:${order.order_id}`,
|
||||
kind: eligible.kind,
|
||||
tenantId: "0pt",
|
||||
subject: `Order ${order.order_id.slice(0, 8)} — ${order.status} → ${NEXT_STATUS[order.status] ?? "nästa steg"}`,
|
||||
subject: `Order ${order.order_id.slice(0, 8)} — ${order.status} → ${next}`,
|
||||
priority: eligible.priority,
|
||||
createdAt: new Date().toISOString(),
|
||||
source: { system: "0pt", channel: "order-queue" },
|
||||
entity: { type: "order", id: order.order_id },
|
||||
actions: NEXT_STATUS[order.status]
|
||||
? [{ id: "advance", label: `→ ${NEXT_STATUS[order.status]}` }]
|
||||
: undefined,
|
||||
actions: [{ id: "advance", label: `→ ${next}` }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-- 015_order_status_check.sql — Kanonisk orderstatus (ADR-0002, C3 2026-09-29)
|
||||
--
|
||||
-- Guardar `orders.status` (fri text sedan sql/001_init.sql line ~92) mot den
|
||||
-- kanoniska unionen i lib/order-flow.ts (ORDER_STATUSES). Denna fil KÖRS EJ
|
||||
-- automatiskt av deploy — Simon kör den manuellt mot prod efter harvest.
|
||||
--
|
||||
-- KÖRORDNING (DoD §17 backup-städ, §33 migreringsordning, §35 restore-drill):
|
||||
-- 1. BACKUP FÖRST: pg_dump "$DATABASE_URL" -t orders -Fc -f orders-pre-015.dump
|
||||
-- (och verifiera att restore-drillen har körts nyligen — §35).
|
||||
-- 2. HARVEST-PREFLIGHT (läs-only, körs före fas A):
|
||||
-- SELECT status, COUNT(*) FROM orders GROUP BY status ORDER BY 2 DESC;
|
||||
-- Alla rader MÅSTE ligga i listan nedan. Finns okända statusar:
|
||||
-- AVBRYT — mappa dem först (UPDATE ... WHERE status = '<okänd>') i en
|
||||
-- egen, granskad migrering; kör aldrig fas A med okända rader kvar.
|
||||
-- 3. FAS A ( TRANSACTIONEN NEDAN — ADD CONSTRAINT ... NOT VALID ):
|
||||
-- tar bara ett kort ACCESS EXCLUSIVE-lås vid själva katalogändringen,
|
||||
-- validerar INTE befintliga rader, blockerar inte skrivningar längre tid.
|
||||
-- 4. FAS B (SEPARAT session/transaktion — VALIDATE CONSTRAINT):
|
||||
-- BEGIN; SET lock_timeout = '5s';
|
||||
-- ALTER TABLE orders VALIDATE CONSTRAINT order_status_check;
|
||||
-- COMMIT;
|
||||
-- VALIDATE tar SHARE UPDATE EXCLUSIVE (skrivningar tillåtna); med
|
||||
-- lock_timeout stänger vi ute långa låsköer. Misslyckas fas B: retry.
|
||||
--
|
||||
-- Listan är kod-SSOT: håll synkroniserad med ORDER_STATUSES i lib/order-flow.ts.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Preflight-guard: fas A nekar om okända statusar finns (fail-closed).
|
||||
-- (SELECT ... FOR UPDATE-stil låsning behövs ej — NOT VALID-constrainten
|
||||
-- läser inga rader; vi vill bara neka körning vid dirt-data.)
|
||||
DO $preflight$
|
||||
DECLARE
|
||||
unknown_rows bigint;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO unknown_rows
|
||||
FROM orders
|
||||
WHERE status NOT IN ('draft', 'quoted', 'in_production', 'review', 'accepted', 'delivered', 'cancelled');
|
||||
IF unknown_rows > 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;
|
||||
@@ -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<string> = new Set(["delivered", "cancelled"]);
|
||||
const EXPECTED_NEXT: Record<string, string | null> = {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user