From 9147f2857e4b0f7c14dbc3750758d3eb3099a185 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 19 Jun 2026 10:35:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(berths):=20CM-2=20=E2=80=94=20price-reconc?= =?UTF-8?q?ile=20API=20(list=20+=20bulk=20apply)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../berths/price-reconcile/apply/handlers.ts | 36 +++++++++++++++ .../v1/berths/price-reconcile/apply/route.ts | 5 +++ .../api/v1/berths/price-reconcile/handlers.ts | 21 +++++++++ .../api/v1/berths/price-reconcile/route.ts | 5 +++ .../integration/berth-price-reconcile.test.ts | 44 ++++++++++++++++++- 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/app/api/v1/berths/price-reconcile/apply/handlers.ts create mode 100644 src/app/api/v1/berths/price-reconcile/apply/route.ts create mode 100644 src/app/api/v1/berths/price-reconcile/handlers.ts create mode 100644 src/app/api/v1/berths/price-reconcile/route.ts diff --git a/src/app/api/v1/berths/price-reconcile/apply/handlers.ts b/src/app/api/v1/berths/price-reconcile/apply/handlers.ts new file mode 100644 index 00000000..4f98ce22 --- /dev/null +++ b/src/app/api/v1/berths/price-reconcile/apply/handlers.ts @@ -0,0 +1,36 @@ +/** + * Route handler for `/api/v1/berths/price-reconcile/apply` (CM-2 Part A). + * + * Writes a rep-approved slice of parsed prices to the berths. In handlers.ts so + * integration tests can call it directly. + */ + +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { type RouteHandler } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { applyBulkBerthPrices } from '@/lib/services/berth-price-reconcile.service'; + +const bodySchema = z.object({ + approvals: z + .array( + z.object({ + berthId: z.string().min(1), + price: z.number().nonnegative(), + currency: z.string().min(1).max(8), + }), + ) + .min(1), +}); + +export const postHandler: RouteHandler = async (req, ctx) => { + try { + const body = await parseBody(req, bodySchema); + const result = await applyBulkBerthPrices(ctx.portId, body.approvals, ctx.userId); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } +}; diff --git a/src/app/api/v1/berths/price-reconcile/apply/route.ts b/src/app/api/v1/berths/price-reconcile/apply/route.ts new file mode 100644 index 00000000..8978cea9 --- /dev/null +++ b/src/app/api/v1/berths/price-reconcile/apply/route.ts @@ -0,0 +1,5 @@ +import { withAuth, withPermission } from '@/lib/api/helpers'; + +import { postHandler } from './handlers'; + +export const POST = withAuth(withPermission('berths', 'edit', postHandler)); diff --git a/src/app/api/v1/berths/price-reconcile/handlers.ts b/src/app/api/v1/berths/price-reconcile/handlers.ts new file mode 100644 index 00000000..c9c6536c --- /dev/null +++ b/src/app/api/v1/berths/price-reconcile/handlers.ts @@ -0,0 +1,21 @@ +/** + * Route handlers for `/api/v1/berths/price-reconcile` (CM-2 Part A). + * + * In handlers.ts so integration tests can call them directly, bypassing the + * auth/permission middleware (per CLAUDE.md "Route handler exports"). + */ + +import { NextResponse } from 'next/server'; + +import { type RouteHandler } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { listPriceReconciliation } from '@/lib/services/berth-price-reconcile.service'; + +export const getHandler: RouteHandler = async (_req, ctx) => { + try { + const data = await listPriceReconciliation(ctx.portId); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } +}; diff --git a/src/app/api/v1/berths/price-reconcile/route.ts b/src/app/api/v1/berths/price-reconcile/route.ts new file mode 100644 index 00000000..d0f32cc2 --- /dev/null +++ b/src/app/api/v1/berths/price-reconcile/route.ts @@ -0,0 +1,5 @@ +import { withAuth, withPermission } from '@/lib/api/helpers'; + +import { getHandler } from './handlers'; + +export const GET = withAuth(withPermission('berths', 'edit', getHandler)); diff --git a/tests/integration/berth-price-reconcile.test.ts b/tests/integration/berth-price-reconcile.test.ts index afdaace1..7dbae468 100644 --- a/tests/integration/berth-price-reconcile.test.ts +++ b/tests/integration/berth-price-reconcile.test.ts @@ -21,7 +21,8 @@ import { db } from '@/lib/db'; import { berths } from '@/lib/db/schema/berths'; import { systemSettings } from '@/lib/db/schema/system'; -import { makeBerth, makePort } from '../helpers/factories'; +import { makeBerth, makeFullPermissions, makePort } from '../helpers/factories'; +import { makeMockCtx, makeMockRequest } from '../helpers/route-tester'; const A1_PDF = readFileSync(path.join(process.cwd(), 'berth_pdf_example/Berth_Spec_Sheet_A1.pdf')); @@ -84,3 +85,44 @@ describe('applyBulkBerthPrices', () => { expect(b!.price).toBeNull(); // untouched }); }); + +describe('price-reconcile route handlers', () => { + it('GET lists rows and POST apply writes the approved price', async () => { + const { getHandler } = await import('@/app/api/v1/berths/price-reconcile/handlers'); + const { postHandler } = await import('@/app/api/v1/berths/price-reconcile/apply/handlers'); + + const port = await makePort(); + const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'A1' } }); + await uploadBerthPdf({ + berthId: berth.id, + portId: port.id, + buffer: A1_PDF, + fileName: 'Berth_Spec_Sheet_A1.pdf', + uploadedBy: 'test-user', + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + + const listRes = await getHandler( + makeMockRequest('GET', 'http://t/api/v1/berths/price-reconcile'), + ctx, + {}, + ); + const listJson = (await listRes.json()) as { + data: Array<{ mooringNumber: string; parsedPrice: number | null }>; + }; + expect(listJson.data.find((r) => r.mooringNumber === 'A1')?.parsedPrice).toBe(3880800); + + const applyRes = await postHandler( + makeMockRequest('POST', 'http://t/api/v1/berths/price-reconcile/apply', { + body: { approvals: [{ berthId: berth.id, price: 3880800, currency: 'USD' }] }, + }), + ctx, + {}, + ); + const applyJson = (await applyRes.json()) as { data: { updated: number } }; + expect(applyJson.data.updated).toBe(1); + + const [b] = await db.select().from(berths).where(eq(berths.id, berth.id)); + expect(Number(b!.price)).toBe(3880800); + }); +});