import { NextResponse } from 'next/server'; import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { type RouteHandler } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; import { berths } from '@/lib/db/schema/berths'; import { listBerthsForInterest, upsertInterestBerth } from '@/lib/services/interest-berths.service'; import { createAuditLog } from '@/lib/audit'; import { emitToRoom } from '@/lib/socket/server'; // ─── Schemas ──────────────────────────────────────────────────────────────── const addBerthSchema = z.object({ berthId: z.string().min(1), /** Drives the public-map "Under Offer" sub-status. See plan §5.4. */ isSpecificInterest: z.boolean(), }); // ─── GET /api/v1/interests/[id]/berths ────────────────────────────────────── // // Returns the linked-berths list (plan §5.5) along with the parent interest's // `eoiStatus` so the UI can decide whether to show the EOI-bypass control. // Tenant-scoped: 404 when the interest doesn't belong to the caller's port, // matching the recommender route's enumeration-prevention behaviour. export const listHandler: RouteHandler = async (_req, ctx, params) => { try { const interestId = params.id!; const interest = await db.query.interests.findFirst({ where: eq(interests.id, interestId), }); if (!interest || interest.portId !== ctx.portId) { throw new NotFoundError('Interest'); } const links = await listBerthsForInterest(interestId); return NextResponse.json({ data: links, meta: { eoiStatus: interest.eoiStatus }, }); } catch (error) { return errorResponse(error); } }; // ─── POST /api/v1/interests/[id]/berths ───────────────────────────────────── // // Add a (non-primary) berth link to the interest. Defaults to // `isInEoiBundle=false`, `isPrimary=false`; the rep can flip these later via // the linked-berths list (PATCH route below). export const addHandler: RouteHandler = async (req, ctx, params) => { try { const body = await parseBody(req, addBerthSchema); const interestId = params.id!; const interest = await db.query.interests.findFirst({ where: eq(interests.id, interestId), }); if (!interest || interest.portId !== ctx.portId) { throw new NotFoundError('Interest'); } // Tenant scope: berth must belong to this port (never trust a client- // supplied id to cross port boundaries — plan §14.10). const berth = await db.query.berths.findFirst({ where: and(eq(berths.id, body.berthId), eq(berths.portId, ctx.portId)), }); if (!berth) { throw new ValidationError('berthId not found in this port'); } const link = await upsertInterestBerth(interestId, body.berthId, { isSpecificInterest: body.isSpecificInterest, addedBy: ctx.userId, }); void createAuditLog({ userId: ctx.userId, portId: ctx.portId, action: 'update', entityType: 'interest', entityId: interestId, newValue: { berthId: body.berthId, isSpecificInterest: body.isSpecificInterest }, metadata: { type: 'berth_added_to_interest' }, ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }); emitToRoom(`port:${ctx.portId}`, 'interest:berthLinked', { interestId, berthId: body.berthId, }); // Outbound webhook: the legacy /link-berth path dispatched // `interest.berth_linked` and external integrations subscribe to it. // The new junction-add path must keep that contract. void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => dispatchWebhookEvent(ctx.portId, 'interest:berthLinked', { interestId, berthId: body.berthId, }), ); return NextResponse.json({ data: link }, { status: 201 }); } catch (error) { return errorResponse(error); } };