From b4776b4c3c0402de1c2a11778d02663b7a528398 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Tue, 5 May 2026 04:01:56 +0200 Subject: [PATCH] feat(interests): linked berths list with role-flag toggles + EOI bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements plan §5.5: a per-interest "Linked berths" panel mounted above the recommender on the interest detail Overview tab. Each junction row exposes the role-flag controls reps need to manage the M:M `interest_berths` link without the legacy single-berth flow. UI (`src/components/interests/linked-berths-list.tsx`) * Rows ordered with primary first; mooring number links to /berths/[id], with area + a status pill (available/under_offer/sold) and a "Primary" chip. * "Specifically pitching" Switch (writes `is_specific_interest`) with the consequence text from §1: "This berth will appear as under interest on the public map" / "This berth is hidden from the public map". * "Mark in EOI bundle" Switch (writes `is_in_eoi_bundle`). * "Set as primary" button when the row isn't primary - the existing `upsertInterestBerth` helper demotes the prior primary in the same tx. * "Bypass EOI for this berth" with reason textarea, ONLY rendered when the parent interest's `eoiStatus === 'signed'`. Writes the bypass triple (`eoi_bypass_reason`, `eoi_bypassed_by` = caller, `eoi_bypassed_at` = now); also supports clearing. * Remove-from-interest action gated by a confirmation dialog. API (`src/app/api/v1/interests/[id]/berths/...`) * `GET /` - list endpoint returning `listBerthsForInterest` plus the parent interest's `eoiStatus` in `meta.eoiStatus` so the UI can decide whether to show the bypass control. * `PATCH /[berthId]` - partial update of the junction row's flags + bypass fields. Server-side guard: rejects bypass writes when `eoiStatus !== 'signed'` (defence in depth - never trust the UI to gate this). * `DELETE /[berthId]` - calls `removeInterestBerth`. * The existing POST stays unchanged. All routes wrapped with `withAuth(withPermission('interests', view|edit, ...))`. portId from ctx; cross-port reads/writes return 404 for enumeration prevention (§14.10). Service changes (`src/lib/services/interest-berths.service.ts`) * `upsertInterestBerth` now accepts `eoiBypassReason` (tri-state: omit = no change, non-empty = record, null = clear) and `eoiBypassedBy`. The bypass triple moves as a unit, with `eoi_bypassed_at` stamped server-side. * `listBerthsForInterest` now returns berth detail (area, status, dimensions) alongside the junction row, typed as `InterestBerthWithDetails`. Socket: added `interest:berthLinkUpdated` event for live UI refreshes. Tests: 18 new integration tests in `tests/integration/api/interest-berths.test.ts` covering happy paths, primary-demotion in same tx, bypass write/clear, the "requires signed EOI" guard, cross-port 404s, missing-link 404s, empty-body 400, and viewer 403 through the permission gate. --- .../[id]/berths/[berthId]/handlers.ts | 144 ++++++ .../interests/[id]/berths/[berthId]/route.ts | 6 + .../api/v1/interests/[id]/berths/handlers.ts | 100 ++++ src/app/api/v1/interests/[id]/berths/route.ts | 72 +-- src/components/interests/interest-tabs.tsx | 7 + .../interests/linked-berths-list.tsx | 478 ++++++++++++++++++ src/lib/services/interest-berths.service.ts | 42 +- src/lib/socket/events.ts | 1 + tests/integration/api/interest-berths.test.ts | 427 ++++++++++++++++ 9 files changed, 1207 insertions(+), 70 deletions(-) create mode 100644 src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts create mode 100644 src/app/api/v1/interests/[id]/berths/[berthId]/route.ts create mode 100644 src/app/api/v1/interests/[id]/berths/handlers.ts create mode 100644 src/components/interests/linked-berths-list.tsx create mode 100644 tests/integration/api/interest-berths.test.ts diff --git a/src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts b/src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts new file mode 100644 index 0000000..6325bbe --- /dev/null +++ b/src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts @@ -0,0 +1,144 @@ +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, interestBerths } from '@/lib/db/schema/interests'; +import { berths } from '@/lib/db/schema/berths'; +import { removeInterestBerth, upsertInterestBerth } from '@/lib/services/interest-berths.service'; +import { createAuditLog } from '@/lib/audit'; +import { emitToRoom } from '@/lib/socket/server'; + +// ─── Schemas ──────────────────────────────────────────────────────────────── + +/** + * Partial update of a junction row's role flags + EOI bypass fields. Every + * field is optional; passing only the ones the rep wants to change. + * + * `eoiBypassReason` is a tri-state: + * - omitted → no change + * - non-empty → record bypass (server stamps `eoiBypassedAt = now()` and + * `eoiBypassedBy = caller`) + * - null → clear bypass (also clears `eoiBypassedBy` / `eoiBypassedAt`) + */ +const patchBerthSchema = z + .object({ + isPrimary: z.boolean().optional(), + isSpecificInterest: z.boolean().optional(), + isInEoiBundle: z.boolean().optional(), + eoiBypassReason: z.string().max(2000).nullable().optional(), + }) + .refine((v) => Object.values(v).some((x) => x !== undefined), { + message: 'At least one field must be provided.', + }); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +async function loadScopedRow(interestId: string, berthId: string, portId: string) { + // Verify interest port-scope first so unrelated 404s look identical to a + // truly-missing row (enumeration prevention — plan §14.10). + const interest = await db.query.interests.findFirst({ + where: eq(interests.id, interestId), + }); + if (!interest || interest.portId !== portId) { + throw new NotFoundError('Interest'); + } + const link = await db.query.interestBerths.findFirst({ + where: and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)), + }); + if (!link) { + throw new NotFoundError('Berth link'); + } + // Also confirm the berth itself is in-port; defensive against a junction row + // pointing at a foreign berth (shouldn't happen, but cheap to check). + const berth = await db.query.berths.findFirst({ + where: and(eq(berths.id, berthId), eq(berths.portId, portId)), + }); + if (!berth) { + throw new NotFoundError('Berth'); + } + return { interest, link, berth }; +} + +// ─── PATCH /api/v1/interests/[id]/berths/[berthId] ────────────────────────── +export const patchHandler: RouteHandler = async (req, ctx, params) => { + try { + const interestId = params.id!; + const berthId = params.berthId!; + const body = await parseBody(req, patchBerthSchema); + + const { interest } = await loadScopedRow(interestId, berthId, ctx.portId); + + // Plan §5.5: the bypass control is only available once the interest's + // primary EOI is signed. Defend the API too — never trust the UI to + // gate this. + if (body.eoiBypassReason !== undefined && interest.eoiStatus !== 'signed') { + throw new ValidationError('EOI bypass requires a signed primary EOI on the interest'); + } + + const updated = await upsertInterestBerth(interestId, berthId, { + isPrimary: body.isPrimary, + isSpecificInterest: body.isSpecificInterest, + isInEoiBundle: body.isInEoiBundle, + eoiBypassReason: body.eoiBypassReason, + eoiBypassedBy: body.eoiBypassReason ? ctx.userId : null, + }); + + void createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'update', + entityType: 'interest', + entityId: interestId, + newValue: { berthId, ...body }, + metadata: { type: 'berth_link_updated' }, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + + emitToRoom(`port:${ctx.portId}`, 'interest:berthLinkUpdated', { + interestId, + berthId, + }); + + return NextResponse.json({ data: updated }); + } catch (error) { + return errorResponse(error); + } +}; + +// ─── DELETE /api/v1/interests/[id]/berths/[berthId] ───────────────────────── +export const deleteHandler: RouteHandler = async (_req, ctx, params) => { + try { + const interestId = params.id!; + const berthId = params.berthId!; + + await loadScopedRow(interestId, berthId, ctx.portId); + + await removeInterestBerth(interestId, berthId); + + void createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'update', + entityType: 'interest', + entityId: interestId, + oldValue: { berthId }, + metadata: { type: 'berth_removed_from_interest' }, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + + emitToRoom(`port:${ctx.portId}`, 'interest:berthUnlinked', { + interestId, + berthId, + }); + + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } +}; diff --git a/src/app/api/v1/interests/[id]/berths/[berthId]/route.ts b/src/app/api/v1/interests/[id]/berths/[berthId]/route.ts new file mode 100644 index 0000000..2ec4ab3 --- /dev/null +++ b/src/app/api/v1/interests/[id]/berths/[berthId]/route.ts @@ -0,0 +1,6 @@ +import { withAuth, withPermission } from '@/lib/api/helpers'; + +import { deleteHandler, patchHandler } from './handlers'; + +export const PATCH = withAuth(withPermission('interests', 'edit', patchHandler)); +export const DELETE = withAuth(withPermission('interests', 'edit', deleteHandler)); diff --git a/src/app/api/v1/interests/[id]/berths/handlers.ts b/src/app/api/v1/interests/[id]/berths/handlers.ts new file mode 100644 index 0000000..35c640d --- /dev/null +++ b/src/app/api/v1/interests/[id]/berths/handlers.ts @@ -0,0 +1,100 @@ +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, + }); + + return NextResponse.json({ data: link }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } +}; diff --git a/src/app/api/v1/interests/[id]/berths/route.ts b/src/app/api/v1/interests/[id]/berths/route.ts index 3357a0f..70d47f6 100644 --- a/src/app/api/v1/interests/[id]/berths/route.ts +++ b/src/app/api/v1/interests/[id]/berths/route.ts @@ -1,72 +1,6 @@ -import { NextResponse } from 'next/server'; -import { and, eq } from 'drizzle-orm'; -import { z } from 'zod'; - import { withAuth, withPermission } 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 { upsertInterestBerth } from '@/lib/services/interest-berths.service'; -import { createAuditLog } from '@/lib/audit'; -import { emitToRoom } from '@/lib/socket/server'; -const addBerthSchema = z.object({ - berthId: z.string().min(1), - /** Drives the public-map "Under Offer" sub-status. See plan §5.4. */ - isSpecificInterest: z.boolean(), -}); +import { addHandler, listHandler } from './handlers'; -// POST /api/v1/interests/[id]/berths — link a berth (non-primary) to an interest. -export const POST = withAuth( - withPermission('interests', 'edit', async (req, ctx, params) => { - try { - const body = await parseBody(req, addBerthSchema); - const interestId = params.id!; - - // Tenant scope: interest must belong to this port. - 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, - }); - - return NextResponse.json({ data: link }, { status: 201 }); - } catch (error) { - return errorResponse(error); - } - }), -); +export const GET = withAuth(withPermission('interests', 'view', listHandler)); +export const POST = withAuth(withPermission('interests', 'edit', addHandler)); diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index 61b8fff..87bd75a 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -13,6 +13,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { RecommendationList } from '@/components/interests/recommendation-list'; import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel'; +import { LinkedBerthsList } from '@/components/interests/linked-berths-list'; import { InterestTimeline } from '@/components/interests/interest-timeline'; import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab'; import { InterestFilesTab } from '@/components/interests/interest-files-tab'; @@ -510,6 +511,12 @@ function OverviewTab({ + {/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see + what's already linked before browsing more options. Each row exposes + per-berth role-flag toggles and the EOI bypass control (only visible + once the parent interest's primary EOI is signed). */} + + {/* Berth recommender (plan §5.3) - always-mounted card driven by the interest's desired dimensions. Renders an inline guidance message when dimensions aren't set yet. */} diff --git a/src/components/interests/linked-berths-list.tsx b/src/components/interests/linked-berths-list.tsx new file mode 100644 index 0000000..38e98ff --- /dev/null +++ b/src/components/interests/linked-berths-list.tsx @@ -0,0 +1,478 @@ +'use client'; + +/** + * Linked-berths list — plan §5.5. + * + * Shows every berth currently linked to the interest with per-row controls: + * - "Specifically pitching" toggle (`is_specific_interest`) — drives the + * public-map "Under Offer" sub-status. Each state surfaces its consequence + * in plain text below the toggle. + * - "Mark in EOI bundle" toggle (`is_in_eoi_bundle`). + * - "Set as primary" button when this row isn't already primary. The + * service helper handles the demote-prior-primary case in a single tx. + * - "Bypass EOI for this berth" with a reason textarea. Only rendered when + * the parent interest's `eoiStatus === 'signed'`. Writes + * `eoi_bypass_reason`, `eoi_bypassed_by`, `eoi_bypassed_at`. + * - "Remove" — calls `removeInterestBerth`. + */ + +import { useState } from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Anchor, Loader2, Star, Trash2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { apiFetch } from '@/lib/api/client'; +import { cn } from '@/lib/utils'; + +// ─── Types (mirror the API GET shape — see interest-berths.service.ts) ───── + +export interface LinkedBerthRow { + id: string; + interestId: string; + berthId: string; + isPrimary: boolean; + isSpecificInterest: boolean; + isInEoiBundle: boolean; + eoiBypassReason: string | null; + eoiBypassedBy: string | null; + eoiBypassedAt: string | null; + addedBy: string | null; + addedAt: string; + notes: string | null; + mooringNumber: string | null; + area: string | null; + status: string; + lengthFt: string | null; + widthFt: string | null; + draftFt: string | null; +} + +interface LinkedBerthsResponse { + data: LinkedBerthRow[]; + meta: { eoiStatus: string | null }; +} + +interface LinkedBerthsListProps { + interestId: string; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function statusToPill(status: string): StatusPillStatus { + switch (status) { + case 'available': + return 'active'; + case 'under_offer': + return 'sent'; + case 'sold': + return 'completed'; + case 'reserved': + return 'partial'; + default: + return 'pending'; + } +} + +function formatStatus(status: string): string { + return status.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()); +} + +function formatDimensions( + length: string | null, + width: string | null, + draft: string | null, +): string | null { + const parts: string[] = []; + const toNum = (v: string | null) => { + if (v === null) return null; + const n = parseFloat(v); + return Number.isFinite(n) ? n : null; + }; + const l = toNum(length); + const w = toNum(width); + const d = toNum(draft); + if (l !== null) parts.push(`${l.toFixed(1)}ft L`); + if (w !== null) parts.push(`${w.toFixed(1)}ft W`); + if (d !== null) parts.push(`${d.toFixed(1)}ft D`); + return parts.length > 0 ? parts.join(' · ') : null; +} + +const SPECIFIC_CONSEQUENCE_ON = 'This berth will appear as under interest on the public map.'; +const SPECIFIC_CONSEQUENCE_OFF = 'This berth is hidden from the public map.'; + +// ─── Hooks ────────────────────────────────────────────────────────────────── + +function useLinkedBerths(interestId: string) { + return useQuery({ + queryKey: ['interest-berths', interestId] as const, + queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`), + staleTime: 30_000, + }); +} + +interface PatchPayload { + isPrimary?: boolean; + isSpecificInterest?: boolean; + isInEoiBundle?: boolean; + eoiBypassReason?: string | null; +} + +function useUpdateLink(interestId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { berthId: string; patch: PatchPayload }) => + apiFetch(`/api/v1/interests/${interestId}/berths/${args.berthId}`, { + method: 'PATCH', + body: args.patch, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['interest-berths', interestId] }); + qc.invalidateQueries({ queryKey: ['interests', interestId] }); + }, + }); +} + +function useRemoveLink(interestId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (berthId: string) => + apiFetch(`/api/v1/interests/${interestId}/berths/${berthId}`, { method: 'DELETE' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['interest-berths', interestId] }); + qc.invalidateQueries({ queryKey: ['interests', interestId] }); + qc.invalidateQueries({ queryKey: ['berth-recommendations', interestId] }); + }, + }); +} + +// ─── Bypass dialog ────────────────────────────────────────────────────────── + +interface BypassDialogProps { + row: LinkedBerthRow; + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (reason: string | null) => void; + isPending: boolean; +} + +function BypassDialog({ row, open, onOpenChange, onSubmit, isPending }: BypassDialogProps) { + const [reason, setReason] = useState(row.eoiBypassReason ?? ''); + return ( + + + + Bypass EOI for berth {row.mooringNumber} + + Record why this berth's individual EOI is being waived. The interest's primary + EOI signature will cover it instead. + + +
+ +