feat(orders): POST /api/v1/orders/[id]/review-save endpoint
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
cfc974c080
commit
3d8d39af2a
2 changed files with 262 additions and 0 deletions
154
src/app/api/v1/orders/[id]/review-save/route.test.ts
Normal file
154
src/app/api/v1/orders/[id]/review-save/route.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const updateOrderReviewFields = vi.fn();
|
||||
const updateOrderItemsPrices = vi.fn();
|
||||
const collisionError: Error & { collidingOrderId?: string; attemptedNumber?: string } =
|
||||
new Error("collision");
|
||||
collisionError.name = "OrderNumberCollisionError";
|
||||
collisionError.collidingOrderId = "order-collide";
|
||||
collisionError.attemptedNumber = "ORD-2026-07-014";
|
||||
|
||||
vi.mock("@/lib/server/orders-service", () => ({
|
||||
updateOrderReviewFields: (...args: unknown[]) =>
|
||||
updateOrderReviewFields(...args),
|
||||
updateOrderItemsPrices: (...args: unknown[]) =>
|
||||
updateOrderItemsPrices(...args),
|
||||
OrderNumberCollisionError: class extends Error {
|
||||
collidingOrderId: string;
|
||||
attemptedNumber: string;
|
||||
constructor(id: string, num: string) {
|
||||
super(`Order number "${num}" is already in use by another order.`);
|
||||
this.name = "OrderNumberCollisionError";
|
||||
this.collidingOrderId = id;
|
||||
this.attemptedNumber = num;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
function postJSON(body: unknown) {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/v1/orders/order-1/review-save",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("POST /api/v1/orders/[id]/review-save", () => {
|
||||
beforeEach(() => {
|
||||
updateOrderReviewFields.mockReset();
|
||||
updateOrderItemsPrices.mockReset();
|
||||
});
|
||||
|
||||
it("400s when body is not JSON", async () => {
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/v1/orders/order-1/review-save",
|
||||
{ method: "POST", body: "not json" },
|
||||
);
|
||||
const res = await POST(req, { params: Promise.resolve({ id: "order-1" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("400s when body is null", async () => {
|
||||
const res = await POST(postJSON(null), {
|
||||
params: Promise.resolve({ id: "order-1" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("200s with empty body (no-op)", async () => {
|
||||
updateOrderReviewFields.mockResolvedValueOnce({
|
||||
id: "order-1",
|
||||
order_number: "ORD-2026-07-001",
|
||||
customer: "cust-1",
|
||||
});
|
||||
const res = await POST(postJSON({}), {
|
||||
params: Promise.resolve({ id: "order-1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateOrderReviewFields).toHaveBeenCalledWith("order-1", {});
|
||||
const body = await res.json();
|
||||
expect(body.order.id).toBe("order-1");
|
||||
expect(body.items.updated).toBe(0);
|
||||
});
|
||||
|
||||
it("calls updateOrderReviewFields with order_number when supplied", async () => {
|
||||
updateOrderReviewFields.mockResolvedValueOnce({
|
||||
id: "order-1",
|
||||
order_number: "ORD-2026-07-014",
|
||||
customer: "cust-1",
|
||||
});
|
||||
updateOrderItemsPrices.mockResolvedValueOnce({ updated: 0, failed: [] });
|
||||
|
||||
const res = await POST(
|
||||
postJSON({ order_number: "ORD-2026-07-014" }),
|
||||
{ params: Promise.resolve({ id: "order-1" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateOrderReviewFields).toHaveBeenCalledWith("order-1", {
|
||||
order_number: "ORD-2026-07-014",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 409 on OrderNumberCollisionError with collidingOrderId", async () => {
|
||||
updateOrderReviewFields.mockRejectedValueOnce(collisionError);
|
||||
|
||||
const res = await POST(
|
||||
postJSON({ order_number: "ORD-2026-07-014" }),
|
||||
{ params: Promise.resolve({ id: "order-1" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("order_number_collision");
|
||||
expect(body.collidingOrderId).toBe("order-collide");
|
||||
expect(body.attemptedNumber).toBe("ORD-2026-07-014");
|
||||
});
|
||||
|
||||
it("calls updateOrderItemsPrices when items are supplied", async () => {
|
||||
updateOrderReviewFields.mockResolvedValueOnce({
|
||||
id: "order-1",
|
||||
order_number: "ORD-2026-07-014",
|
||||
customer: "cust-1",
|
||||
});
|
||||
updateOrderItemsPrices.mockResolvedValueOnce({
|
||||
updated: 2,
|
||||
failed: [],
|
||||
});
|
||||
|
||||
const res = await POST(
|
||||
postJSON({
|
||||
order_number: "ORD-2026-07-014",
|
||||
items: [
|
||||
{ id: "item-1", quantity: 5 },
|
||||
{ id: "item-2", selling_price: 99 },
|
||||
],
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "order-1" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateOrderItemsPrices).toHaveBeenCalledWith("order-1", [
|
||||
{ id: "item-1", quantity: 5 },
|
||||
{ id: "item-2", selling_price: 99 },
|
||||
]);
|
||||
const body = await res.json();
|
||||
expect(body.items.updated).toBe(2);
|
||||
expect(body.items.failed).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns 500 on unexpected service error", async () => {
|
||||
updateOrderReviewFields.mockRejectedValueOnce(new Error("boom"));
|
||||
const res = await POST(postJSON({ order_number: "X" }), {
|
||||
params: Promise.resolve({ id: "order-1" }),
|
||||
});
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/boom/);
|
||||
});
|
||||
});
|
||||
108
src/app/api/v1/orders/[id]/review-save/route.ts
Normal file
108
src/app/api/v1/orders/[id]/review-save/route.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
OrderNumberCollisionError,
|
||||
updateOrderItemsPrices,
|
||||
updateOrderReviewFields,
|
||||
} from "@/lib/server/orders-service";
|
||||
|
||||
/**
|
||||
* POST /api/v1/orders/[id]/review-save
|
||||
*
|
||||
* Atomically commits the operator's review-time edits to the order
|
||||
* header (order_number, customer) and any line-item price/qty
|
||||
* patches. The "atomic" guarantee is partial: order_number collision
|
||||
* is checked up-front so a 409 aborts before any field is touched,
|
||||
* but item updates are best-effort per-row (per the
|
||||
* `updateOrderItemsPrices` contract).
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* order_number?: string;
|
||||
* customer?: string;
|
||||
* items?: Array<{
|
||||
* id: string;
|
||||
* quantity?: number | null;
|
||||
* selling_price?: number | null;
|
||||
* cost_price?: number | null;
|
||||
* }>;
|
||||
* }
|
||||
*
|
||||
* Responses:
|
||||
* 200 — saved. Body: { order: Order, items: { updated, failed } }
|
||||
* 400 — body is missing or not JSON
|
||||
* 404 — order not found (PB throws; caught and mapped)
|
||||
* 409 — order_number collision. Body: { error, collidingOrderId, attemptedNumber }
|
||||
* 500 — anything else
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id: orderId } = await ctx.params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
|
||||
}
|
||||
if (body === null || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Body must be an object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { order_number, customer, items } = body as {
|
||||
order_number?: string;
|
||||
customer?: string;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
quantity?: number | null;
|
||||
selling_price?: number | null;
|
||||
cost_price?: number | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
try {
|
||||
// Header patch — order_number + customer are bundled because the
|
||||
// collision check needs to fire BEFORE any items[] work runs.
|
||||
// We always go through `updateOrderReviewFields` — even when the
|
||||
// patch is empty — so the no-op path returns the current row and
|
||||
// the route doesn't need a second PB import branch.
|
||||
const headerPatch: { order_number?: string; customer?: string } = {};
|
||||
if (typeof order_number === "string") headerPatch.order_number = order_number;
|
||||
if (typeof customer === "string") headerPatch.customer = customer;
|
||||
|
||||
const order = await updateOrderReviewFields(orderId, headerPatch);
|
||||
|
||||
const itemResult =
|
||||
Array.isArray(items) && items.length > 0
|
||||
? await updateOrderItemsPrices(orderId, items)
|
||||
: { updated: 0, failed: [] };
|
||||
|
||||
return NextResponse.json({ order, items: itemResult });
|
||||
} catch (err) {
|
||||
// Detect collision by instanceof OR by name — the name check
|
||||
// keeps the route working with mocks that throw a plain Error
|
||||
// stamped with `.name = "OrderNumberCollisionError"`.
|
||||
if (
|
||||
err instanceof OrderNumberCollisionError ||
|
||||
(err instanceof Error && err.name === "OrderNumberCollisionError")
|
||||
) {
|
||||
const collidingOrderId =
|
||||
(err as Error & { collidingOrderId?: string }).collidingOrderId ?? "";
|
||||
const attemptedNumber =
|
||||
(err as Error & { attemptedNumber?: string }).attemptedNumber ?? "";
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "order_number_collision",
|
||||
collidingOrderId,
|
||||
attemptedNumber,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Save failed";
|
||||
const status =
|
||||
(err as Error & { status?: number }).status === 404 ? 404 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue