WFDASH-WEBAPP/src/app/api/v1/orders/create/route.ts
Dave 5eed862007 feat(api): enhance order creation with optional email metadata fields
- Added `email_sender`, `email_subject`, and `email_body` fields to the order creation input schema for improved traceability of inbound emails.
- Updated logic to include these fields in the order creation process, allowing operators to track the source of orders without needing follow-up updates.
- Enhanced documentation to reflect the new optional fields and their purpose in the order management workflow.

This update improves the audit trail for orders and enhances user experience by linking orders directly to their source emails.
2026-08-28 13:00:31 +02:00

147 lines
6.6 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import {
createOrder,
OrderNumberCollisionError,
type CreateOrderInput,
type CreateOrderItemDraft,
} from "@/lib/server/orders-service";
/**
* POST /api/v1/orders/create
*
* Create a standalone order without going through a quote. Use for
* counter sales, walk-ins, internal transfers, or any flow where the
* originating RFQ never went through the quote pipeline.
*
* Body:
* {
* customer_id: string, // required (PB 15-char)
* company_id: string, // required (PB 15-char)
* notes?: string,
* contact_name?: string,
* description?: string,
* email_message_id?: string, // dedup key — returns existing order if one already exists
* order_number?: string, // optional custom order number; empty/whitespace falls back to auto-gen
* email_sender?: string, // From: address of the inbound email (audit trail)
* email_subject?: string, // Subject line of the inbound email (audit trail)
* email_body?: string, // Plain-text body of the inbound email (audit trail)
* rep?: string,
* items: [
* {
* description: string, // required
* quantity?: number | null,
* cost_price?: number,
* markup?: number,
* selling_price?: number,
* sku?: string,
* unit?: string,
* supplier_id?: string,
* notes?: string,
* },
* ...
* ]
* }
*
* Returns:
* 201 — { order, order_items_collection, items_created, items_failed }
* 200 — { ...same shape..., duplicate: true } dedup hit (idempotent)
* 400 — { error: <message> } validation failure (missing fields, empty items)
* 404 — { error: <message> } customer_id / company_id doesn't resolve
* 409 — { error: "order_number_collision", collidingOrderId, attemptedNumber }
* 500 — { error: <message> } PB write failure
*
* `runtime = "nodejs"` because PocketBase admin auth requires Node.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
try {
assertAgentAuth(req);
const body = (await req.json()) as Partial<CreateOrderInput> | null;
const customerId = typeof body?.customer_id === "string" ? body.customer_id.trim() : "";
const companyId = typeof body?.company_id === "string" ? body.company_id.trim() : "";
if (!customerId) {
return NextResponse.json({ error: "customer_id is required" }, { status: 400 });
}
if (!companyId) {
return NextResponse.json({ error: "company_id is required" }, { status: 400 });
}
if (!Array.isArray(body?.items) || body.items.length === 0) {
return NextResponse.json({ error: "items must be a non-empty array" }, { status: 400 });
}
const items: CreateOrderItemDraft[] = body.items.map((it) => {
const description = typeof it?.description === "string" ? it.description.trim() : "";
return {
description,
quantity: typeof it?.quantity === "number" ? it.quantity : null,
cost_price: typeof it?.cost_price === "number" ? it.cost_price : undefined,
markup: typeof it?.markup === "number" ? it.markup : undefined,
selling_price: typeof it?.selling_price === "number" ? it.selling_price : undefined,
sku: typeof it?.sku === "string" ? it.sku : undefined,
unit: typeof it?.unit === "string" ? it.unit : undefined,
supplier_id: typeof it?.supplier_id === "string" ? it.supplier_id : undefined,
notes: typeof it?.notes === "string" ? it.notes : undefined,
};
});
const result = await createOrder({
customer_id: customerId,
company_id: companyId,
notes: typeof body?.notes === "string" ? body.notes : undefined,
contact_name: typeof body?.contact_name === "string" ? body.contact_name : undefined,
email_message_id: typeof body?.email_message_id === "string" ? body.email_message_id : undefined,
// Empty / whitespace order_number is treated as "not provided"
// so the service layer falls back to auto-generation — same shape
// as the workflow sending `order_number: ""` when the AI didn't
// find a PO number on the inbound attachment.
order_number: typeof body?.order_number === "string" ? body.order_number : undefined,
// Inbound email metadata — optional audit-trail fields stamped
// by the n8n intake pipeline so the operator can trace the
// order back to its source email without needing the original
// attachment.
email_sender: typeof body?.email_sender === "string" ? body.email_sender : undefined,
email_subject: typeof body?.email_subject === "string" ? body.email_subject : undefined,
email_body: typeof body?.email_body === "string" ? body.email_body : undefined,
description: typeof body?.description === "string" ? body.description : undefined,
rep: typeof body?.rep === "string" ? body.rep : undefined,
items,
});
// 200 for dedup (idempotent — same input, same output), 201 for new
return NextResponse.json(result, { status: result.duplicate ? 200 : 201 });
} catch (error) {
// Structured 409 for custom order_number collisions — mirrors the
// shape /api/v1/orders/[id]/review-save returns so callers (the
// n8n intake workflow, agent runs, UI modals) can branch on the
// same error code regardless of which endpoint raised it.
if (error instanceof OrderNumberCollisionError) {
return NextResponse.json(
{
error: "order_number_collision",
collidingOrderId: error.collidingOrderId,
attemptedNumber: error.attemptedNumber,
},
{ status: 409 },
);
}
const message = error instanceof Error ? error.message : "Failed to create order";
const status = (error as Error & { status?: number }).status || 500;
// 404 from getOne propagates with status === 404 if PB throws it.
// Otherwise we bucket by message: customer-not-found / company-not-found
// surface as 404; everything else is 500.
const lower = message.toLowerCase();
if (status === 404 || lower.includes("not found")) {
return NextResponse.json({ error: message }, { status: 404 });
}
if (status === 400 || lower.includes("required") || lower.includes("must be")) {
return NextResponse.json({ error: message }, { status: 400 });
}
return NextResponse.json({ error: message }, { status: 500 });
}
}