import { NextRequest, NextResponse } from "next/server"; import { createOrder, listOwnedOrders, IdempotencyKeyConflictError, type OrderConfig } from "@/lib/orders-store"; import { requireOrderOwner } from "@/src/auth/require-order-owner"; import { resolveAdvanceAction, buildOrderInboxItem } from "@/lib/order-flow"; import { isSiaxError, siaxErrorResponseForLegacy } from "../siax-error-mapping"; // POST /api/orders (docs/04) — create a draft order from a configurator config. // A payable order is created only for a verified Zitadel tenant/buyer. // Anonymous prospects may obtain estimates but cannot mint payment orders. // // P014: honors an optional `Idempotency-Key` header (the convention this // skill's own docs already name -- "Idempotency-Key on order/payment POSTs") // so a retried submission (double-click, dropped response, proxy replay) // returns the ORIGINAL order (200) instead of creating a duplicate (was: // no idempotency mechanism at all -- every retry created a new row, P014 // recon "Duplicate delivery idempotent: NOT MET"). Omitting the header keeps // the pre-P014 behavior exactly: every call creates a fresh order. export async function POST(req: NextRequest) { const auth = await requireOrderOwner(req); if (auth.response) return auth.response; let body: { flow?: string; config?: OrderConfig }; try { body = await req.json(); } catch { return NextResponse.json({ error: { code: "bad_json", message: "Invalid JSON" } }, { status: 400 }); } if (!body || typeof body !== "object") { return NextResponse.json({ error: { code: "bad_json", message: "Invalid JSON body" } }, { status: 400 }); } const flow = body.flow || "custom"; const config = body.config || {}; const idempotencyKey = req.headers.get("idempotency-key") ?? undefined; try { const { order, replayed } = await createOrder(flow, config, idempotencyKey, auth.owner); return NextResponse.json( { order_id: order.order_id, status: order.status, deposit_ore: order.deposit_ore, total_ore: order.total_ore }, { status: replayed ? 200 : 201 }, ); } catch (err: unknown) { if (err instanceof IdempotencyKeyConflictError) { return NextResponse.json({ error: { code: "idempotency_key_conflict", message: err.message } }, { status: 409 }); } // 0pt#53: getSqlForDurableStore throws SiaxError(DEPENDENCY_UNAVAILABLE, 503) when // production has no DATABASE_URL — surface it as documented, not opaque 500. if (isSiaxError(err)) return siaxErrorResponseForLegacy(err); throw err; } } // GET /api/orders — list all orders (queue + financial aggregates). This is // outside middleware.ts's matcher (/api/0pt/:path* only), so auth is enforced // here directly via the same fail-closed Zitadel verifier — see // src/auth/require-route-auth.ts for why the request is re-verified rather // than trusting a forwarded header. export async function GET(req: NextRequest) { const auth = await requireOrderOwner(req); if (auth.response) return auth.response; try { const orders = await listOwnedOrders(auth.owner.tenantId); // ADR-0001: servern resolvér advance-aktionen per order — klienten // renderar bara (ActionGate). Policy ägs här, inte i UI-lagret. const withActions = orders.map((o) => ({ ...o, advance_action: resolveAdvanceAction(o.status), inbox_item: buildOrderInboxItem(o) })); return NextResponse.json(withActions); } catch (err: unknown) { if (isSiaxError(err)) return siaxErrorResponseForLegacy(err); throw err; } }