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. + + + Reason * + setReason(e.target.value)} + placeholder="Why are you dropping the manual pin?" + rows={3} + /> + + + + onOpenChange(false)}> + Cancel + + resetMutation.mutate()} + disabled={resetMutation.isPending || reason.trim().length === 0} + > + Reset to automatic + + + + + ); +} + export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) { const [editOpen, setEditOpen] = useState(false); const [statusOpen, setStatusOpen] = useState(false); + const [resetOpen, setResetOpen] = useState(false); + + const isManualPin = berth.statusOverrideMode === 'manual'; + // Divergence the rep should resolve: a berth manually pinned "Available" + // that nevertheless has an active deal on it - the public map shows + // Available even though something is happening. Prompt to resume automatic + // (which derives "Under Offer" from the active specific-interest link). + const pinDivergesFromDeal = + isManualPin && berth.status === 'available' && !!berth.latestInterestStage; return ( <> @@ -292,6 +378,19 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) { > {STATUS_LABELS[berth.status] ?? berth.status} + {isManualPin && ( + + + Manual + + )} @@ -300,6 +399,12 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) { Change Status + {isManualPin && ( + setResetOpen(true)}> + + Reset to automatic + + )} setEditOpen(true)}> Edit @@ -309,6 +414,26 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) { + {pinDivergesFromDeal && ( + + + + This berth is manually pinned Available, but it has an active deal. + The public map will keep showing Available until you reset it. + + setResetOpen(true)} + > + + Reset to automatic + + + + )} + + + > ); } diff --git a/src/lib/dedup/migration-transform.ts b/src/lib/dedup/migration-transform.ts index 9e8baef1..cccca496 100644 --- a/src/lib/dedup/migration-transform.ts +++ b/src/lib/dedup/migration-transform.ts @@ -236,9 +236,14 @@ const DEFAULT_OPTIONS: TransformOptions = { // the pre-(9→7)-refactor vocab (open/details_sent/eoi_sent/…) and would have // written invalid `pipeline_stage` values. Current stages live in // `src/lib/constants.ts` PIPELINE_STAGES. +// §4b SIGNED OFF (2026-06-02): GQI with no qualifying marker (no berth / no +// size) is a plain enquiry; GQI that named a berth/size and SQI are qualified. +// `nurturing` is NOT bulk-assigned from legacy (reserved for actively-worked +// deals). `deposit_paid` is set independently from the Deposit-10% flag below. +// Contract Signed stays in `contract` with outcome OPEN (not won). const STAGE_MAP: Record = { - 'General Qualified Interest': 'qualified', - 'Specific Qualified Interest': 'nurturing', + 'General Qualified Interest': 'enquiry', // refined to 'qualified' below if a berth/size marker exists + 'Specific Qualified Interest': 'qualified', 'EOI and NDA Sent': 'eoi', 'Signed EOI and NDA': 'eoi', 'Made Reservation': 'reservation', @@ -666,6 +671,14 @@ function buildPlannedInterest(row: NocoDbRow, clientTempId: string): PlannedInte const depositReceived = ((row['Deposit 10% Status'] as string | undefined) ?? '').trim() === 'Received'; let mappedStage = STAGE_MAP[stage] ?? 'enquiry'; + // §4b marker rule: a "General Qualified Interest" that named a specific berth + // OR a desired berth size is really a qualified lead; with no such marker it + // stays a plain enquiry. SQI + later stages are unaffected by this. + if (stage === 'General Qualified Interest') { + const hasBerthMarker = !!(row['Berth Number'] as string | undefined)?.trim(); + const hasSizeMarker = !!(row['Berth Size Desired'] as string | undefined)?.trim(); + mappedStage = hasBerthMarker || hasSizeMarker ? 'qualified' : 'enquiry'; + } if (depositReceived && mappedStage !== 'contract') mappedStage = 'deposit_paid'; // Interest-level EOI signing state (for display on the deal). "Signed" diff --git a/src/lib/services/berth-rules-engine.ts b/src/lib/services/berth-rules-engine.ts index 2df0f7d0..1412353a 100644 --- a/src/lib/services/berth-rules-engine.ts +++ b/src/lib/services/berth-rules-engine.ts @@ -243,11 +243,33 @@ async function applyRuleToBerth( // pre-lock snapshot. If the prior contender already moved status to // our target, we're idempotent and bail. const [current] = await tx - .select({ status: berths.status }) + .select({ status: berths.status, statusOverrideMode: berths.statusOverrideMode }) .from(berths) .where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId))); if (!current) return { changed: false as const }; + + // Soft-pin priority: a human-set manual override wins over automatic + // rules EXCEPT a real sale (target 'sold' — deposit_received / + // contract_signed / interest_completed). Soft triggers (eoi_*, + // reservation→under_offer, *→available) must not stomp a manual pin; + // the pin persists until a human changes or resets it. Sale triggers + // still override because a confirmed sale is a fact a stale pin can't + // be allowed to hide. + if (current.statusOverrideMode === 'manual' && rule.targetStatus !== 'sold') { + logger.debug( + { + trigger, + targetBerthId, + portId, + status: current.status, + targetStatus: rule.targetStatus, + }, + 'Berth-rule auto: respecting manual pin, skipping non-sale auto-write', + ); + return { changed: false as const }; + } + if (current.status === rule.targetStatus) { // Idempotent re-fire. We already audited the decision above; nothing // more to do here. diff --git a/src/lib/services/berths.service.ts b/src/lib/services/berths.service.ts index ce00d4dc..56b4181c 100644 --- a/src/lib/services/berths.service.ts +++ b/src/lib/services/berths.service.ts @@ -635,6 +635,76 @@ export async function updateBerthStatus( return updated!; } +// ─── Reset Manual Override ────────────────────────────────────────────────── +// +// "Reset to automatic": clears `statusOverrideMode` so the berth stops being a +// human-pinned status and resumes derived behaviour. The base `status` is left +// as-is (the rep can change it explicitly via updateBerthStatus) - the point of +// the reset is to drop the pin so the public-map derivation (specific-interest +// links, tenancies) and the rules engine govern the berth again. For the common +// case (pinned `available` with an active specific interest) this immediately +// flips the public map to "Under Offer" because `derivePublicStatus` no longer +// sees a manual override to honour. + +export async function resetBerthStatusOverride( + id: string, + portId: string, + reason: string, + meta: AuditMeta, +) { + const existing = await db.query.berths.findFirst({ + where: and(eq(berths.id, id), eq(berths.portId, portId)), + }); + if (!existing) throw new NotFoundError('Berth'); + if (existing.statusOverrideMode !== 'manual') { + throw new ValidationError('Berth is not in a manual-override state'); + } + + const [updated] = await db + .update(berths) + .set({ + statusOverrideMode: null, + statusLastChangedBy: meta.userId, + statusLastChangedReason: reason, + statusLastModified: new Date(), + updatedAt: new Date(), + }) + .where(and(eq(berths.id, id), eq(berths.portId, portId))) + .returning(); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'update', + entityType: 'berth', + entityId: id, + oldValue: { statusOverrideMode: 'manual' }, + newValue: { statusOverrideMode: null, reason }, + metadata: { type: 'reset_manual_override', reason }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + // The displayed (derived) status may change the instant the pin drops, so + // tell the open boards to refetch - same event the status PATCH emits. + emitToRoom(`port:${portId}`, 'berth:statusChanged', { + berthId: id, + oldStatus: existing.status, + newStatus: existing.status, + triggeredBy: meta.userId, + }); + + void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => + dispatchWebhookEvent(portId, 'berth:statusChanged', { + berthId: id, + oldStatus: existing.status, + newStatus: existing.status, + }), + ); + + return updated!; +} + // ─── Reconciliation Queue ───────────────────────────────────────────────────── // // #67 Phase 3: surfaces every berth whose status was set manually (i.e. diff --git a/src/lib/services/public-berths.ts b/src/lib/services/public-berths.ts index 426664a9..9c51188f 100644 --- a/src/lib/services/public-berths.ts +++ b/src/lib/services/public-berths.ts @@ -106,13 +106,31 @@ export function isPermanentTenureType(tenureType: string | null | undefined): bo * active (non-archived, non-closed) interest. Seasonal / fixed-term * active tenancies do not flip the public status — they fall through * to the existing under-offer / available precedence. + * + * Manual-override priority (soft pin): when `overrideMode === 'manual'` a + * human has deliberately pinned this berth's status, so the pinned base + * status wins over the interest-derived signal — a berth a rep pinned + * `available` stays Available even with an active specific-interest link. + * The pin is "soft": a real permanent tenancy (genuine occupancy) and an + * explicit `sold` status still override it, because those represent facts + * a stale pin must not hide. A real deposit-paid sale overrides at WRITE + * time (the rules engine flips the berth to `sold` + `automated`), so by + * the time we read here the manual pin is already gone in that case. + * `overrideMode` defaults to null => pure automatic derivation (unchanged). */ export function derivePublicStatus( internalStatus: string, hasSpecificInterest: boolean, hasActivePermanentTenancy = false, + overrideMode: string | null = null, ): PublicStatus { + // Real permanent occupancy / explicit sold always win, even over a pin. if (internalStatus === 'sold' || hasActivePermanentTenancy) return 'Sold'; + // Soft pin: a manual override wins over the interest-derived signal. + if (overrideMode === 'manual') { + return internalStatus === 'under_offer' ? 'Under Offer' : 'Available'; + } + // Automatic derivation. if (internalStatus === 'under_offer' || hasSpecificInterest) return 'Under Offer'; return 'Available'; } @@ -149,7 +167,12 @@ export function toPublicBerth( 'Side Pontoon': toString(berth.sidePontoon), 'Power Capacity': toNumber(berth.powerCapacity), Voltage: toNumber(berth.voltage), - Status: derivePublicStatus(berth.status, hasSpecificInterest, hasActivePermanentTenancy), + Status: derivePublicStatus( + berth.status, + hasSpecificInterest, + hasActivePermanentTenancy, + berth.statusOverrideMode, + ), Area: toString(berth.area), 'Mooring Type': toString(berth.mooringType), 'Bow Facing': toString(berth.bowFacing), diff --git a/src/lib/services/website-intake-promote.service.ts b/src/lib/services/website-intake-promote.service.ts new file mode 100644 index 00000000..449c5c2e --- /dev/null +++ b/src/lib/services/website-intake-promote.service.ts @@ -0,0 +1,302 @@ +/** + * Auto-promote a captured website berth registration into a prospect. + * + * The marketing website dual-writes every inquiry into `website_submissions` + * (capture-only - see `/api/public/website-inquiries`). By default those sit + * in the triage inbox until a rep promotes them, and promotion is what marks + * the berth "Under Offer" on the public map (Option 1). + * + * When the per-port flag `website_berth_autopromote_enabled` is ON (Option 2), + * a registration for a SPECIFIC, currently-available berth skips the wait: we + * create the prospect immediately (client deduped by email + optional yacht + + * interest) and link the berth with `is_specific_interest=true`, which + * `derivePublicStatus` reads as "Under Offer" - so the public map flips the + * moment the registration lands. The submission is marked `converted` so a rep + * never double-creates it. + * + * Safety posture: + * - Only fires for `berth_inquiry` submissions that name a resolvable berth + * whose base status is exactly `available`. Sold / Under Offer / unknown + * berths are left alone (never stomp a manual or sold state). + * - Skips when the same client already has an open specific-interest link on + * that berth (double-submit guard). + * - Default OFF; intended to be flipped on only post-cutover so it never + * races the NocoDB-primary migration window. + * - Caller invokes this fire-and-forget after the idempotent capture insert, + * so a failure here can never 500 the public capture POST. + */ + +import { and, eq, isNull, or, sql } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { withTransaction } from '@/lib/db/utils'; +import { systemSettings } from '@/lib/db/schema/system'; +import { berths } from '@/lib/db/schema/berths'; +import { interests, interestBerths } from '@/lib/db/schema/interests'; +import { clients, clientContacts } from '@/lib/db/schema/clients'; +import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts'; +import { websiteSubmissions } from '@/lib/db/schema/website-submissions'; +import { upsertInterestBerthTx } from '@/lib/services/interest-berths.service'; +import { extractInquiryFields } from '@/lib/services/website-intake-fields'; + +/** Per-port gate. Default OFF (no row -> disabled). */ +export async function isWebsiteBerthAutopromoteEnabled(portId: string): Promise { + const row = await db + .select({ value: systemSettings.value }) + .from(systemSettings) + .where( + and( + eq(systemSettings.key, 'website_berth_autopromote_enabled'), + or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)), + ), + ) + .limit(1); + return row[0]?.value === true; +} + +/** + * Pure decision helper: does this submission express interest in a SPECIFIC + * berth? Only `berth_inquiry` submissions that carry a non-empty mooring + * number qualify. Kept pure + exported for unit testing. + */ +export function extractPromotionIntent( + kind: string, + payload: Record, +): { hasSpecificBerth: boolean; mooringNumber: string | null } { + if (kind !== 'berth_inquiry') return { hasSpecificBerth: false, mooringNumber: null }; + const fields = extractInquiryFields(payload); + return { + hasSpecificBerth: !!fields.mooringNumber, + mooringNumber: fields.mooringNumber, + }; +} + +/** + * Pure helper: a berth is auto-promotable only when its base status is exactly + * `available`. Anything else (under_offer / sold / unknown) is left untouched. + */ +export function isBerthPromotable(status: string | null | undefined): boolean { + return status === 'available'; +} + +/** Strip the website's appended "ft" unit and return a numeric string, or null. */ +function parseFeet(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const m = String(value).match(/-?\d+(?:\.\d+)?/); + return m ? m[0] : null; +} + +export interface AutoPromoteInput { + portId: string; + submissionId: string; + kind: string; + payload: Record; +} + +export type AutoPromoteResult = + | { promoted: false; reason: string } + | { + promoted: true; + interestId: string; + clientId: string; + berthId: string; + mooringNumber: string; + }; + +/** + * Attempt to auto-promote a captured berth submission. Returns a structured + * result describing what happened (or why it was skipped). Never throws for + * "skip" conditions - only genuinely unexpected DB errors propagate, and the + * caller wraps this in `.catch`. + */ +export async function autoPromoteWebsiteBerthInquiry( + input: AutoPromoteInput, +): Promise { + const { portId, submissionId, kind, payload } = input; + + const intent = extractPromotionIntent(kind, payload); + if (!intent.hasSpecificBerth || !intent.mooringNumber) { + return { promoted: false, reason: 'no-specific-berth' }; + } + const mooringNumber = intent.mooringNumber; + + // Resolve the berth (read-only, outside the tx). + const berth = await db.query.berths.findFirst({ + where: and(eq(berths.mooringNumber, mooringNumber), eq(berths.portId, portId)), + columns: { id: true, status: true }, + }); + if (!berth) { + return { promoted: false, reason: 'berth-not-found' }; + } + if (!isBerthPromotable(berth.status)) { + return { promoted: false, reason: `berth-not-available:${berth.status}` }; + } + const berthId = berth.id; + + const fields = extractInquiryFields(payload); + if (!fields.email) { + // No email -> we can't dedup or contact; leave it in triage for a human. + return { promoted: false, reason: 'no-email' }; + } + const normalizedEmail = fields.email.trim().toLowerCase(); + const fullName = fields.fullName || 'Unknown'; + + const yachtName = + typeof payload.berth_yacht_name === 'string' ? payload.berth_yacht_name.trim() : ''; + const lengthFt = parseFeet(payload.berth_min_length); + const widthFt = parseFeet(payload.berth_min_width); + const draftFt = parseFeet(payload.berth_min_draught); + + const result = await withTransaction(async (tx) => { + // 1. Find-or-create client by lowercased primary email (mirrors the eager + // public-interest dedup so a known registrant doesn't fork a new row). + let clientId: string; + const existingContact = await tx.query.clientContacts.findFirst({ + where: and( + eq(clientContacts.channel, 'email'), + sql`LOWER(${clientContacts.value}) = ${normalizedEmail}`, + ), + }); + if (existingContact) { + const existingClient = await tx.query.clients.findFirst({ + where: eq(clients.id, existingContact.clientId), + }); + if (existingClient && existingClient.portId === portId) { + clientId = existingClient.id; + } else { + clientId = await createPromotedClient(tx, portId, fullName, normalizedEmail, fields.phone); + } + } else { + clientId = await createPromotedClient(tx, portId, fullName, normalizedEmail, fields.phone); + } + + // 2. Double-submit guard: bail if this client already has an OPEN interest + // with a specific-interest link on this berth. + const existingLink = await tx + .select({ id: interests.id }) + .from(interests) + .innerJoin(interestBerths, eq(interestBerths.interestId, interests.id)) + .where( + and( + eq(interests.clientId, clientId), + eq(interests.portId, portId), + isNull(interests.archivedAt), + isNull(interests.outcome), + eq(interestBerths.berthId, berthId), + eq(interestBerths.isSpecificInterest, true), + ), + ) + .limit(1); + if (existingLink[0]) { + return { kind: 'skip' as const, reason: 'duplicate-open-interest' }; + } + + // 3. Optional yacht (only when the registrant named one). Owner = client. + let yachtId: string | null = null; + if (yachtName) { + const [newYacht] = await tx + .insert(yachts) + .values({ + portId, + name: yachtName, + lengthFt, + widthFt, + draftFt, + currentOwnerType: 'client', + currentOwnerId: clientId, + status: 'active', + }) + .returning(); + yachtId = newYacht!.id; + await tx.insert(yachtOwnershipHistory).values({ + yachtId, + ownerType: 'client', + ownerId: clientId, + startDate: new Date(), + endDate: null, + createdBy: 'website-auto-promote', + }); + } + + // 4. Create the interest. + const [newInterest] = await tx + .insert(interests) + .values({ + portId, + clientId, + yachtId, + source: 'website', + pipelineStage: 'enquiry', + }) + .returning(); + const interestId = newInterest!.id; + + // 5. Link the berth via the canonical junction helper. is_specific_interest + // true => the public map derives "Under Offer". isPrimary forces the + // in-bundle invariant (matches the eager public-interest path). + await upsertInterestBerthTx(tx, interestId, berthId, { + isPrimary: true, + isSpecificInterest: true, + addedBy: 'website-auto-promote', + }); + + // 6. Mark the captured submission converted so triage never double-creates. + await tx + .update(websiteSubmissions) + .set({ + triageState: 'converted', + triagedAt: new Date(), + triagedBy: 'system:website-auto-promote', + }) + .where( + and( + eq(websiteSubmissions.submissionId, submissionId), + eq(websiteSubmissions.portId, portId), + ), + ); + + return { kind: 'promoted' as const, interestId, clientId }; + }); + + if (result.kind === 'skip') { + return { promoted: false, reason: result.reason }; + } + return { + promoted: true, + interestId: result.interestId, + clientId: result.clientId, + berthId, + mooringNumber, + }; +} + +async function createPromotedClient( + tx: typeof db, + portId: string, + fullName: string, + normalizedEmail: string, + phone: string, +): Promise { + const [newClient] = await tx + .insert(clients) + .values({ portId, fullName, source: 'website' }) + .returning(); + const clientId = newClient!.id; + + await tx.insert(clientContacts).values({ + clientId, + channel: 'email', + value: normalizedEmail, + isPrimary: true, + }); + if (phone) { + await tx.insert(clientContacts).values({ + clientId, + channel: 'phone', + value: phone, + isPrimary: false, + }); + } + + return clientId; +} diff --git a/src/lib/settings/registry.ts b/src/lib/settings/registry.ts index d277cf43..72e0a361 100644 --- a/src/lib/settings/registry.ts +++ b/src/lib/settings/registry.ts @@ -677,6 +677,27 @@ export const REGISTRY: SettingEntry[] = [ defaultValue: false, }, + // Operations - Website berth auto-promote. Port-scoped gate that decides + // what a website berth registration does to the public map. OFF (default): + // captures land in the triage inbox and a rep promotes them, which marks + // the berth (Option 1, the safe posture - matches the old NocoDB manual + // flow). ON: a berth_inquiry naming a specific, currently-available berth + // is auto-promoted to a prospect (client + optional yacht + interest) with + // the berth linked is_specific_interest=true, immediately flipping the + // public map to "Under Offer" (Option 2, hybrid). General/residence/contact + // submissions stay capture-only either way. Intended to be flipped ON only + // post-cutover so it never races the NocoDB-primary migration window. + { + key: 'website_berth_autopromote_enabled', + section: 'operations.intake', + label: 'Auto-mark berths from website registrations', + description: + 'When enabled, a website registration for a SPECIFIC, currently-available berth is auto-promoted into a prospect (client + interest, with the berth marked "Under Offer" on the public map) instead of waiting in the triage inbox for a rep. Skips berths that are already Sold or Under Offer, and skips general inquiries with no specific berth (those still go to triage). Leave OFF for the safe behavior where a rep reviews and promotes each registration. Recommended to keep OFF until after the website cutover.', + type: 'boolean', + scope: 'port', + defaultValue: false, + }, + // ─── Operations - Residential module ────────────────────────────────────── // Port-scoped gate for the entire Residential surface (sidebar // "Residential" section, /residential/clients + /residential/interests diff --git a/src/lib/validators/berths.ts b/src/lib/validators/berths.ts index 925f555e..4418b4ca 100644 --- a/src/lib/validators/berths.ts +++ b/src/lib/validators/berths.ts @@ -91,6 +91,15 @@ export const updateBerthStatusSchema = z.object({ export type UpdateBerthStatusInput = z.infer; +// ─── Reset Manual Override ────────────────────────────────────────────────── +// Drops the manual pin so the berth resumes derived/automatic status. A reason +// is required so the audit trail records why the rep stopped pinning. +export const resetBerthOverrideSchema = z.object({ + reason: z.string().trim().min(1, 'Reason is required'), +}); + +export type ResetBerthOverrideInput = z.infer; + // ─── Archive Berth ──────────────────────────────────────────────────────────── // Post-audit F5: archive replaces hard-delete. A `reason` is required so diff --git a/tests/unit/dedup/migration-transform.test.ts b/tests/unit/dedup/migration-transform.test.ts index be8bd674..cdd200d0 100644 --- a/tests/unit/dedup/migration-transform.test.ts +++ b/tests/unit/dedup/migration-transform.test.ts @@ -180,15 +180,59 @@ describe('transformSnapshot - fixture regression', () => { expect(sourceIds).toEqual([188, 236, 336, 536, 585, 624, 625, 681, 682, 683, 717, 999]); }); - it('maps the legacy 8-stage enum to new pipeline stages', () => { + it('maps the legacy 8-stage enum to new pipeline stages (§4b signed-off)', () => { const plan = transformSnapshot(FIXTURE); const stagesById = new Map(plan.interests.map((i) => [i.sourceId, i.pipelineStage])); - expect(stagesById.get(681)).toBe('qualified'); // General Qualified Interest - expect(stagesById.get(682)).toBe('nurturing'); // Specific Qualified Interest + // 681 is "General Qualified Interest" with no berth/size marker → enquiry. + expect(stagesById.get(681)).toBe('enquiry'); + // 682 is "Specific Qualified Interest" → qualified. + expect(stagesById.get(682)).toBe('qualified'); expect(stagesById.get(336)).toBe('contract'); // Contract Signed expect(stagesById.get(585)).toBe('eoi'); // Signed EOI and NDA }); + it('§4b marker rule: a GQI that named a berth or size is qualified, not a plain enquiry', () => { + const snap: NocoDbSnapshot = { + fetchedAt: '2026-06-02T00:00:00.000Z', + berths: [], + residentialInterests: [], + websiteInterestSubmissions: [], + websiteContactFormSubmissions: [], + websiteBerthEoiSupplements: [], + interests: [ + row({ + Id: 9001, + 'Full Name': 'Bare Enquiry', + 'Email Address': 'bare@example.com', + 'Phone Number': '+15550000001', + 'Sales Process Level': 'General Qualified Interest', + }), + row({ + Id: 9002, + 'Full Name': 'Named Berth', + 'Email Address': 'berth@example.com', + 'Phone Number': '+15550000002', + 'Sales Process Level': 'General Qualified Interest', + 'Berth Number': 'A1', + }), + row({ + Id: 9003, + 'Full Name': 'Wants Size', + 'Email Address': 'size@example.com', + 'Phone Number': '+15550000003', + 'Sales Process Level': 'General Qualified Interest', + 'Berth Size Desired': '20m', + }), + ], + }; + const stagesById = new Map( + transformSnapshot(snap).interests.map((i) => [i.sourceId, i.pipelineStage]), + ); + expect(stagesById.get(9001)).toBe('enquiry'); // no marker + expect(stagesById.get(9002)).toBe('qualified'); // named a berth + expect(stagesById.get(9003)).toBe('qualified'); // entered a desired size + }); + it('attaches different yachts to one merged Constanzo client', () => { const plan = transformSnapshot(FIXTURE); const constanzoClient = plan.clients.find( diff --git a/tests/unit/services/public-berths.test.ts b/tests/unit/services/public-berths.test.ts index de5f6b23..662fb639 100644 --- a/tests/unit/services/public-berths.test.ts +++ b/tests/unit/services/public-berths.test.ts @@ -117,6 +117,31 @@ describe('derivePublicStatus', () => { expect(derivePublicStatus('available', true)).toBe('Under Offer'); }); }); + + describe('manual override (soft pin)', () => { + it('a manual "available" pin wins over an active specific-interest link', () => { + // The key behaviour: a rep deliberately keeping a berth Available is NOT + // overridden by an interest-derived "Under Offer". + expect(derivePublicStatus('available', true, false, 'manual')).toBe('Available'); + }); + it('a manual "under_offer" pin shows Under Offer', () => { + expect(derivePublicStatus('under_offer', false, false, 'manual')).toBe('Under Offer'); + }); + it('a manual "sold" pin shows Sold', () => { + expect(derivePublicStatus('sold', false, false, 'manual')).toBe('Sold'); + }); + it('a real permanent tenancy still overrides a manual "available" pin (soft pin)', () => { + expect(derivePublicStatus('available', false, true, 'manual')).toBe('Sold'); + expect(derivePublicStatus('available', true, true, 'manual')).toBe('Sold'); + }); + it('"automated" override mode falls through to normal derivation (interest promotes)', () => { + expect(derivePublicStatus('available', true, false, 'automated')).toBe('Under Offer'); + }); + it('null/omitted override mode preserves pre-pin behaviour', () => { + expect(derivePublicStatus('available', true, false, null)).toBe('Under Offer'); + expect(derivePublicStatus('available', true, false)).toBe('Under Offer'); + }); + }); }); describe('isPermanentTenureType', () => { diff --git a/tests/unit/website-intake-promote.test.ts b/tests/unit/website-intake-promote.test.ts new file mode 100644 index 00000000..ad0ae264 --- /dev/null +++ b/tests/unit/website-intake-promote.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; + +import { + extractPromotionIntent, + isBerthPromotable, +} from '@/lib/services/website-intake-promote.service'; + +describe('extractPromotionIntent', () => { + it('detects a specific berth on a berth_inquiry', () => { + expect(extractPromotionIntent('berth_inquiry', { first_name: 'Jane', berth: 'A1' })).toEqual({ + hasSpecificBerth: true, + mooringNumber: 'A1', + }); + }); + + it('returns no berth for a berth_inquiry with no mooring', () => { + expect(extractPromotionIntent('berth_inquiry', { first_name: 'Jane' })).toEqual({ + hasSpecificBerth: false, + mooringNumber: null, + }); + }); + + it('ignores residence / contact kinds entirely', () => { + expect(extractPromotionIntent('residence_inquiry', { berth: 'A1' })).toEqual({ + hasSpecificBerth: false, + mooringNumber: null, + }); + expect(extractPromotionIntent('contact_form', { berth: 'A1' })).toEqual({ + hasSpecificBerth: false, + mooringNumber: null, + }); + }); +}); + +describe('isBerthPromotable', () => { + it('only an exactly-available berth is promotable', () => { + expect(isBerthPromotable('available')).toBe(true); + }); + it('under_offer / sold / unknown / null are NOT promotable (never stomp)', () => { + expect(isBerthPromotable('under_offer')).toBe(false); + expect(isBerthPromotable('sold')).toBe(false); + expect(isBerthPromotable('something')).toBe(false); + expect(isBerthPromotable(null)).toBe(false); + expect(isBerthPromotable(undefined)).toBe(false); + }); +});
+ 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. +