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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+13
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user