feat(api): order persistence (Postgres or in-memory fallback) + orders API

lib/db (env-guarded postgres-js), lib/orders-store (createOrder/getOrder/listOrders/setStatus, DB-or-memory), /api/orders (POST create+estimate, GET list), /api/orders/[id] (GET, PATCH status with escrow-safe state machine). Activates fully on DATABASE_URL/POSTGRES_URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 18:11:34 +02:00
co-authored by Claude Opus 4.8
parent 15c65a584c
commit 0b09ee5795
6 changed files with 191 additions and 5 deletions
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { getOrder, setStatus } from "@/lib/orders-store";
// GET /api/orders/{id}
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const order = await getOrder(id);
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
return NextResponse.json(order);
}
// PATCH /api/orders/{id} — status transition (deposit → in_production → review → accepted ...).
// Escrow rule (docs/01): balance never charged before 'accepted'. Stripe wiring follows.
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
let body: { status?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: { code: "bad_json", message: "Invalid JSON" } }, { status: 400 });
}
const allowed = ["draft", "quoted", "deposit_pending", "in_production", "review", "revision", "accepted", "delivered"];
if (!body.status || !allowed.includes(body.status)) {
return NextResponse.json({ error: { code: "bad_status", message: "Ogiltig status" } }, { status: 400 });
}
const order = await setStatus(id, body.status);
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
return NextResponse.json(order);
}
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { createOrder, listOrders, type OrderConfig } from "@/lib/orders-store";
// POST /api/orders (docs/04) — create a draft order from a configurator config.
export async function POST(req: NextRequest) {
let body: { flow?: string; config?: OrderConfig };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: { code: "bad_json", message: "Invalid JSON" } }, { status: 400 });
}
const flow = body.flow || "custom";
const config = body.config || {};
const order = await createOrder(flow, config);
return NextResponse.json({ order_id: order.order_id, status: order.status, deposit_ore: order.deposit_ore, total_ore: order.total_ore }, { status: 201 });
}
// GET /api/orders — list (org scoping added with auth).
export async function GET() {
const orders = await listOrders();
return NextResponse.json(orders);
}
+23
View File
@@ -0,0 +1,23 @@
import postgres from "postgres";
// Lazy, env-guarded Postgres client. Works with Vercel Postgres (POSTGRES_URL)
// or any DATABASE_URL. Returns null when no DB is configured so the app still
// builds/runs (with in-memory fallback) before storage is provisioned.
let _sql: ReturnType<typeof postgres> | null = null;
export function dbUrl(): string | undefined {
return process.env.DATABASE_URL || process.env.POSTGRES_URL || process.env.POSTGRES_PRISMA_URL;
}
export function hasDb(): boolean {
return Boolean(dbUrl());
}
export function getSql() {
const url = dbUrl();
if (!url) return null;
if (!_sql) {
_sql = postgres(url, { prepare: false, idle_timeout: 20, max: 5, ssl: "require" });
}
return _sql;
}
+100
View File
@@ -0,0 +1,100 @@
import { getSql } from "@/lib/db";
import { computeEstimate, type EntityType, type Frequency } from "@/lib/data";
export interface OrderConfig {
markets?: string[];
type?: EntityType;
fieldIds?: string[];
enrichmentIds?: string[];
volume?: number;
frequency?: Frequency;
confidence?: number;
formats?: string[];
}
export interface Order {
order_id: string;
flow: string;
status: string;
config: OrderConfig;
total_ore: number;
deposit_ore: number;
balance_ore: number;
est_days: number;
est_confidence: number;
created_at: string;
}
// Non-durable fallback used only until a database is provisioned.
const mem = new Map<string, Order>();
function estimateFor(config: OrderConfig) {
return computeEstimate({
markets: config.markets ?? [],
type: config.type ?? "Bolag",
fieldIds: config.fieldIds ?? [],
enrichmentIds: config.enrichmentIds ?? [],
volume: config.volume ?? 1000,
frequency: config.frequency ?? "Engångs",
confidence: config.confidence ?? 92,
});
}
export async function createOrder(flow: string, config: OrderConfig): Promise<Order> {
const est = estimateFor(config);
const sql = getSql();
if (sql) {
const [row] = await sql<{ order_id: string; created_at: string }[]>`
INSERT INTO orders (flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence)
VALUES (${flow}, 'draft', ${sql.json(config as object)}, ${est.price * 100}, ${est.deposit * 100},
${est.remainder * 100}, ${est.timelineDays}, ${est.estConfidence / 100})
RETURNING order_id, created_at`;
return {
order_id: row.order_id, flow, status: "draft", config,
total_ore: est.price * 100, deposit_ore: est.deposit * 100, balance_ore: est.remainder * 100,
est_days: est.timelineDays, est_confidence: est.estConfidence / 100, created_at: row.created_at,
};
}
const id = (globalThis.crypto?.randomUUID?.() ?? `SX-${Math.floor(1000 + Math.random() * 9000)}`);
const order: Order = {
order_id: id, flow, status: "draft", config,
total_ore: est.price * 100, deposit_ore: est.deposit * 100, balance_ore: est.remainder * 100,
est_days: est.timelineDays, est_confidence: est.estConfidence / 100, created_at: new Date().toISOString(),
};
mem.set(id, order);
return order;
}
export async function getOrder(id: string): Promise<Order | null> {
const sql = getSql();
if (sql) {
const rows = await sql<Order[]>`
SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
FROM orders WHERE order_id::text = ${id} LIMIT 1`;
return rows[0] ?? null;
}
return mem.get(id) ?? null;
}
export async function listOrders(): Promise<Order[]> {
const sql = getSql();
if (sql) {
return sql<Order[]>`
SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
FROM orders ORDER BY created_at DESC LIMIT 100`;
}
return [...mem.values()].sort((a, b) => b.created_at.localeCompare(a.created_at));
}
export async function setStatus(id: string, status: string): Promise<Order | null> {
const sql = getSql();
if (sql) {
const rows = await sql<Order[]>`
UPDATE orders SET status = ${status}, updated_at = now() WHERE order_id::text = ${id}
RETURNING order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`;
return rows[0] ?? null;
}
const o = mem.get(id);
if (o) { o.status = status; mem.set(id, o); }
return o ?? null;
}
+4 -1
View File
@@ -48,6 +48,7 @@
"lucide-react": "^0.454.0",
"next": "16.0.10",
"next-themes": "^0.4.6",
"postgres": "^3.4.9",
"react": "19.2.0",
"react-day-picker": "9.8.0",
"react-dom": "19.2.0",
@@ -71,6 +72,8 @@
"typescript": "^5"
},
"pnpm": {
"onlyBuiltDependencies": ["sharp"]
"onlyBuiltDependencies": [
"sharp"
]
}
}
+13 -4
View File
@@ -97,7 +97,7 @@ importers:
version: 1.3.1(next@16.0.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
autoprefixer:
specifier: ^10.4.20
version: 10.5.1(postcss@8.5.15)
version: 10.5.0(postcss@8.5.15)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -125,6 +125,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
postgres:
specifier: ^3.4.9
version: 3.4.9
react:
specifier: 19.2.0
version: 19.2.0
@@ -1248,8 +1251,8 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
autoprefixer@10.5.1:
resolution: {integrity: sha512-jwM2pcTuCWUoN70FEvf5XrXyDbUgRURK4FnU8v0jWZZYU/KkVvN9T33mu1sVLFY9JW3kTWzKheEpn6xYLRc/VA==}
autoprefixer@10.5.0:
resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
@@ -1551,6 +1554,10 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
postgres@3.4.9:
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
engines: {node: '>=12'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -2741,7 +2748,7 @@ snapshots:
dependencies:
tslib: 2.8.1
autoprefixer@10.5.1(postcss@8.5.15):
autoprefixer@10.5.0(postcss@8.5.15):
dependencies:
browserslist: 4.28.4
caniuse-lite: 1.0.30001799
@@ -2992,6 +2999,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postgres@3.4.9: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0