From 15a139e86f07dc4c35f81998b0ee32df56ef0022 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 2 Jun 2026 20:10:04 +0200 Subject: [PATCH] feat(berths): website auto-promote toggle + manual-override soft-pin priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - website_berth_autopromote_enabled (default OFF): a website registration for a specific, currently-available berth auto-creates a prospect (client + optional yacht + interest) and links the berth is_specific_interest=true, flipping the public map to Under Offer; general/residence/contact submissions stay capture-only. Marks the submission converted so a rep never double-creates it. - derivePublicStatus now honours a manual pin (soft pin): a manually-set status wins over the interest-derived Under Offer, but a real permanent tenancy or an explicit sold still override it. - berth rules engine respects a manual pin EXCEPT for sale triggers (-> sold), so a confirmed sale still wins but soft auto-changes never stomp a pin. - Reset-to-automatic action (service + API POST /berths/[id]/status/reset + UI) to drop a manual pin; lock badge on every manual override (list + detail); divergence banner prompting reset when a pinned-Available berth has a deal. - migration stage map updated to the §4b signed-off mapping: GQI -> enquiry unless it named a berth/size marker (-> qualified); SQI -> qualified. Tests: +public-berths soft-pin cases, +website-intake-promote helpers, +migration GQI marker rule. 1582 unit/integration green; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/public/website-inquiries/route.ts | 37 +++ .../api/v1/berths/[id]/status/reset/route.ts | 26 ++ src/components/berths/berth-columns.tsx | 33 +- src/components/berths/berth-detail-header.tsx | 136 +++++++- src/lib/dedup/migration-transform.ts | 17 +- src/lib/services/berth-rules-engine.ts | 24 +- src/lib/services/berths.service.ts | 70 ++++ src/lib/services/public-berths.ts | 25 +- .../website-intake-promote.service.ts | 302 ++++++++++++++++++ src/lib/settings/registry.ts | 21 ++ src/lib/validators/berths.ts | 9 + tests/unit/dedup/migration-transform.test.ts | 50 ++- tests/unit/services/public-berths.test.ts | 25 ++ tests/unit/website-intake-promote.test.ts | 46 +++ 14 files changed, 802 insertions(+), 19 deletions(-) create mode 100644 src/app/api/v1/berths/[id]/status/reset/route.ts create mode 100644 src/lib/services/website-intake-promote.service.ts create mode 100644 tests/unit/website-intake-promote.test.ts diff --git a/src/app/api/public/website-inquiries/route.ts b/src/app/api/public/website-inquiries/route.ts index d94fe0a4..30430a9b 100644 --- a/src/app/api/public/website-inquiries/route.ts +++ b/src/app/api/public/website-inquiries/route.ts @@ -21,6 +21,10 @@ import { notifyWebsiteSubmissionInApp, sendWebsiteSubmissionEmails, } from '@/lib/services/website-intake-email.service'; +import { + autoPromoteWebsiteBerthInquiry, + isWebsiteBerthAutopromoteEnabled, +} from '@/lib/services/website-intake-promote.service'; /** * POST /api/public/website-inquiries @@ -190,6 +194,39 @@ export async function POST(req: NextRequest) { ), ); + // Flag-gated berth auto-promote (Option 2). On a fresh capture, a + // registration for a specific currently-available berth becomes a prospect + // immediately and flips the public map to "Under Offer". Default OFF, so + // by default captures wait in triage for a rep (Option 1). Fire-and-forget + // after the insert: a promote failure must not 500 the capture POST. + if (await isWebsiteBerthAutopromoteEnabled(port.id)) { + void autoPromoteWebsiteBerthInquiry({ + portId: port.id, + submissionId: parsed.submission_id, + kind: parsed.kind, + payload: parsed.payload, + }) + .then((r) => { + if (r.promoted) { + logger.info( + { submissionId: parsed.submission_id, interestId: r.interestId, berthId: r.berthId }, + 'website inquiry auto-promoted to interest (berth marked under offer)', + ); + } else { + logger.info( + { submissionId: parsed.submission_id, reason: r.reason }, + 'website inquiry auto-promote skipped', + ); + } + }) + .catch((err) => + logger.error( + { err, submissionId: parsed.submission_id }, + 'Failed to auto-promote website inquiry', + ), + ); + } + // Flag-gated CRM-owned emails (registrant confirmation + staff alert). // Fire only on this fresh-insert branch so a redelivery never re-sends. // Inline fire-and-forget: a send failure must not 500 the capture POST. diff --git a/src/app/api/v1/berths/[id]/status/reset/route.ts b/src/app/api/v1/berths/[id]/status/reset/route.ts new file mode 100644 index 00000000..78680563 --- /dev/null +++ b/src/app/api/v1/berths/[id]/status/reset/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { resetBerthOverrideSchema } from '@/lib/validators/berths'; +import { resetBerthStatusOverride } from '@/lib/services/berths.service'; +import { errorResponse } from '@/lib/errors'; + +// POST /api/v1/berths/[id]/status/reset +// Clears a manual status pin so the berth resumes derived/automatic status. +export const POST = withAuth( + withPermission('berths', 'edit', async (req, ctx, params) => { + try { + const { reason } = await parseBody(req, resetBerthOverrideSchema); + const updated = await resetBerthStatusOverride(params.id!, ctx.portId, reason, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: updated }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/berths/berth-columns.tsx b/src/components/berths/berth-columns.tsx index 9d767cd4..3826ce82 100644 --- a/src/components/berths/berth-columns.tsx +++ b/src/components/berths/berth-columns.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { type ColumnDef } from '@tanstack/react-table'; -import { MoreHorizontal, Pencil, Activity, RefreshCw } from 'lucide-react'; +import { MoreHorizontal, Pencil, Activity, RefreshCw, Lock } from 'lucide-react'; import { useRouter, useParams } from 'next/navigation'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -150,17 +150,29 @@ function StatusBadge({ status }: { status: string }) { } /** - * #67 Phase 2: small amber chip beside the status pill flagging rows - * whose status was set manually and has no backing interest. These are - * the candidates for the catch-up wizard - the rep flipped a berth to - * "Under Offer" or "Sold" without ever creating the matching deal. + * Chip beside the status pill flagging a manually-pinned status (wins over + * automatic derivation). Two variants: + * - 'catchup' (amber): manual AND no backing interest - a candidate for the + * catch-up wizard (rep flipped to Under Offer/Sold without a matching deal). + * - 'pinned' (slate + lock): manual WITH a backing deal - a deliberate pin. */ -function ManualBadge() { +function ManualBadge({ variant }: { variant: 'catchup' | 'pinned' }) { + if (variant === 'catchup') { + return ( + + Manual + + ); + } return ( + Manual ); @@ -326,11 +338,12 @@ export const berthColumns: ColumnDef[] = [ header: 'Status', cell: ({ row }) => { const r = row.original; - const isManualUnreconciled = r.statusOverrideMode === 'manual' && !r.latestInterestStage; + const isManual = r.statusOverrideMode === 'manual'; + const isManualUnreconciled = isManual && !r.latestInterestStage; return (
- {isManualUnreconciled ? : null} + {isManual ? : null}
); }, diff --git a/src/components/berths/berth-detail-header.tsx b/src/components/berths/berth-detail-header.tsx index b044cdac..b8ea2672 100644 --- a/src/components/berths/berth-detail-header.tsx +++ b/src/components/berths/berth-detail-header.tsx @@ -1,8 +1,8 @@ 'use client'; import { useState } from 'react'; -import { Check, ChevronsUpDown, Pencil, RefreshCw } from 'lucide-react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Check, ChevronsUpDown, Lock, Pencil, RefreshCw, RotateCcw } from 'lucide-react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -84,6 +84,12 @@ export type BerthDetailData = { berthApproved: boolean | null; statusLastChangedReason: string | null; statusLastModified: string | null; + /** 'manual' = a human pinned this status (wins over derived signals); + * 'automated' = set by the rules engine; null = pure derived. */ + statusOverrideMode: string | null; + /** Pipeline stage of the most recent active interest on this berth, if any. + * Used to flag a manual pin that diverges from an active deal. */ + latestInterestStage: string | null; tags: Array<{ id: string; name: string; color: string }>; }; @@ -258,9 +264,89 @@ function StatusChangeDialog({ ); } +/** + * Confirm dialog for "Reset to automatic" - drops the manual pin so the berth + * resumes derived status. A reason is required (audit trail). An optional + * `defaultReason` pre-fills the box for the one-click banner path. + */ +function ResetOverrideDialog({ + berthId, + open, + onOpenChange, + defaultReason = '', +}: { + berthId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + defaultReason?: string; +}) { + const queryClient = useQueryClient(); + const [reason, setReason] = useState(defaultReason); + + const resetMutation = useMutation({ + mutationFn: () => + apiFetch(`/api/v1/berths/${berthId}/status/reset`, { + method: 'POST', + body: { reason: reason.trim() || 'Resumed automatic status' }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['berths'] }); + queryClient.invalidateQueries({ queryKey: ['berth', berthId] }); + toast.success('Berth reset to automatic status'); + onOpenChange(false); + }, + onError: (err) => toastError(err), + }); + + return ( + + + + Reset to automatic status + +
+

+ This removes the manual pin so the berth's public status is governed by its + interests and tenancies again. The displayed status may change immediately. +

+
+ +