diff --git a/app/api/orders/[id]/route.ts b/app/api/orders/[id]/route.ts new file mode 100644 index 0000000..b59c063 --- /dev/null +++ b/app/api/orders/[id]/route.ts @@ -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); +} diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts new file mode 100644 index 0000000..c0c1183 --- /dev/null +++ b/app/api/orders/route.ts @@ -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); +} diff --git a/lib/db.ts b/lib/db.ts new file mode 100644 index 0000000..161f276 --- /dev/null +++ b/lib/db.ts @@ -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 | 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; +} diff --git a/lib/orders-store.ts b/lib/orders-store.ts new file mode 100644 index 0000000..5b3d962 --- /dev/null +++ b/lib/orders-store.ts @@ -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(); + +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 { + 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 { + const sql = getSql(); + if (sql) { + const rows = await sql` + 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 { + const sql = getSql(); + if (sql) { + return sql` + 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 { + const sql = getSql(); + if (sql) { + const rows = await sql` + 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; +} diff --git a/package.json b/package.json index 77aa307..724c3ec 100644 --- a/package.json +++ b/package.json @@ -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" + ] } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f55b04..4d98f7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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