feat(berths): website auto-promote toggle + manual-override soft-pin priority
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m47s
Build & Push Docker Images / build-and-push (push) Successful in 6m49s

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 20:10:04 +02:00
parent 04ddd59662
commit 15a139e86f
14 changed files with 802 additions and 19 deletions

View File

@@ -21,6 +21,10 @@ import {
notifyWebsiteSubmissionInApp, notifyWebsiteSubmissionInApp,
sendWebsiteSubmissionEmails, sendWebsiteSubmissionEmails,
} from '@/lib/services/website-intake-email.service'; } from '@/lib/services/website-intake-email.service';
import {
autoPromoteWebsiteBerthInquiry,
isWebsiteBerthAutopromoteEnabled,
} from '@/lib/services/website-intake-promote.service';
/** /**
* POST /api/public/website-inquiries * 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). // Flag-gated CRM-owned emails (registrant confirmation + staff alert).
// Fire only on this fresh-insert branch so a redelivery never re-sends. // 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. // Inline fire-and-forget: a send failure must not 500 the capture POST.

View File

@@ -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);
}
}),
);

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table'; 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 { useRouter, useParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
@@ -150,12 +150,14 @@ function StatusBadge({ status }: { status: string }) {
} }
/** /**
* #67 Phase 2: small amber chip beside the status pill flagging rows * Chip beside the status pill flagging a manually-pinned status (wins over
* whose status was set manually and has no backing interest. These are * automatic derivation). Two variants:
* the candidates for the catch-up wizard - the rep flipped a berth to * - 'catchup' (amber): manual AND no backing interest - a candidate for the
* "Under Offer" or "Sold" without ever creating the matching deal. * 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 ( return (
<span <span
className="inline-flex items-center rounded-full border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-xs font-medium uppercase tracking-wide text-amber-800" className="inline-flex items-center rounded-full border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-xs font-medium uppercase tracking-wide text-amber-800"
@@ -164,6 +166,16 @@ function ManualBadge() {
Manual Manual
</span> </span>
); );
}
return (
<span
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-700"
title="Status pinned manually - wins over automatic derivation"
>
<Lock className="h-3 w-3" aria-hidden />
Manual
</span>
);
} }
function ActionsCell({ row }: { row: { original: BerthRow } }) { function ActionsCell({ row }: { row: { original: BerthRow } }) {
@@ -326,11 +338,12 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
header: 'Status', header: 'Status',
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
const isManualUnreconciled = r.statusOverrideMode === 'manual' && !r.latestInterestStage; const isManual = r.statusOverrideMode === 'manual';
const isManualUnreconciled = isManual && !r.latestInterestStage;
return ( return (
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
<StatusBadge status={r.status} /> <StatusBadge status={r.status} />
{isManualUnreconciled ? <ManualBadge /> : null} {isManual ? <ManualBadge variant={isManualUnreconciled ? 'catchup' : 'pinned'} /> : null}
</div> </div>
); );
}, },

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Check, ChevronsUpDown, Pencil, RefreshCw } from 'lucide-react'; import { Check, ChevronsUpDown, Lock, Pencil, RefreshCw, RotateCcw } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -84,6 +84,12 @@ export type BerthDetailData = {
berthApproved: boolean | null; berthApproved: boolean | null;
statusLastChangedReason: string | null; statusLastChangedReason: string | null;
statusLastModified: 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 }>; 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reset to automatic status</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
This removes the manual pin so the berth&apos;s public status is governed by its
interests and tenancies again. The displayed status may change immediately.
</p>
<div className="space-y-2">
<Label>Reason *</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Why are you dropping the manual pin?"
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending || reason.trim().length === 0}
>
Reset to automatic
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) { export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [statusOpen, setStatusOpen] = 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 ( return (
<> <>
@@ -292,6 +378,19 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
> >
{STATUS_LABELS[berth.status] ?? berth.status} {STATUS_LABELS[berth.status] ?? berth.status}
</StatusPill> </StatusPill>
{isManualPin && (
<span
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700"
title={
berth.statusLastChangedReason
? `Manually set status (wins over automatic). Reason: ${berth.statusLastChangedReason}`
: 'Manually set status - wins over automatic derivation'
}
>
<Lock className="h-3 w-3" aria-hidden />
Manual
</span>
)}
</div> </div>
<div className="flex flex-wrap items-center gap-2 sm:shrink-0"> <div className="flex flex-wrap items-center gap-2 sm:shrink-0">
@@ -300,6 +399,12 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden /> <RefreshCw className="mr-1.5 h-4 w-4" aria-hidden />
Change Status Change Status
</Button> </Button>
{isManualPin && (
<Button variant="outline" size="sm" onClick={() => setResetOpen(true)}>
<RotateCcw className="mr-1.5 h-4 w-4" aria-hidden />
Reset to automatic
</Button>
)}
<Button size="sm" onClick={() => setEditOpen(true)}> <Button size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-4 w-4" aria-hidden /> <Pencil className="mr-1.5 h-4 w-4" aria-hidden />
Edit Edit
@@ -309,6 +414,26 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
</div> </div>
</DetailHeaderStrip> </DetailHeaderStrip>
{pinDivergesFromDeal && (
<PermissionGate resource="berths" action="edit">
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 sm:flex-row sm:items-center sm:justify-between">
<span>
This berth is manually pinned <strong>Available</strong>, but it has an active deal.
The public map will keep showing Available until you reset it.
</span>
<Button
size="sm"
variant="outline"
className="shrink-0 border-amber-400 bg-white hover:bg-amber-100"
onClick={() => setResetOpen(true)}
>
<RotateCcw className="mr-1.5 h-4 w-4" aria-hidden />
Reset to automatic
</Button>
</div>
</PermissionGate>
)}
<BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} />
<StatusChangeDialog <StatusChangeDialog
@@ -317,6 +442,13 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
open={statusOpen} open={statusOpen}
onOpenChange={setStatusOpen} onOpenChange={setStatusOpen}
/> />
<ResetOverrideDialog
berthId={berth.id}
open={resetOpen}
onOpenChange={setResetOpen}
defaultReason={pinDivergesFromDeal ? 'Resumed automatic status - active deal present' : ''}
/>
</> </>
); );
} }

View File

@@ -236,9 +236,14 @@ const DEFAULT_OPTIONS: TransformOptions = {
// the pre-(9→7)-refactor vocab (open/details_sent/eoi_sent/…) and would have // the pre-(9→7)-refactor vocab (open/details_sent/eoi_sent/…) and would have
// written invalid `pipeline_stage` values. Current stages live in // written invalid `pipeline_stage` values. Current stages live in
// `src/lib/constants.ts` PIPELINE_STAGES. // `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<string, string> = { const STAGE_MAP: Record<string, string> = {
'General Qualified Interest': 'qualified', 'General Qualified Interest': 'enquiry', // refined to 'qualified' below if a berth/size marker exists
'Specific Qualified Interest': 'nurturing', 'Specific Qualified Interest': 'qualified',
'EOI and NDA Sent': 'eoi', 'EOI and NDA Sent': 'eoi',
'Signed EOI and NDA': 'eoi', 'Signed EOI and NDA': 'eoi',
'Made Reservation': 'reservation', 'Made Reservation': 'reservation',
@@ -666,6 +671,14 @@ function buildPlannedInterest(row: NocoDbRow, clientTempId: string): PlannedInte
const depositReceived = const depositReceived =
((row['Deposit 10% Status'] as string | undefined) ?? '').trim() === 'Received'; ((row['Deposit 10% Status'] as string | undefined) ?? '').trim() === 'Received';
let mappedStage = STAGE_MAP[stage] ?? 'enquiry'; 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'; if (depositReceived && mappedStage !== 'contract') mappedStage = 'deposit_paid';
// Interest-level EOI signing state (for display on the deal). "Signed" // Interest-level EOI signing state (for display on the deal). "Signed"

View File

@@ -243,11 +243,33 @@ async function applyRuleToBerth(
// pre-lock snapshot. If the prior contender already moved status to // pre-lock snapshot. If the prior contender already moved status to
// our target, we're idempotent and bail. // our target, we're idempotent and bail.
const [current] = await tx const [current] = await tx
.select({ status: berths.status }) .select({ status: berths.status, statusOverrideMode: berths.statusOverrideMode })
.from(berths) .from(berths)
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId))); .where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
if (!current) return { changed: false as const }; 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) { if (current.status === rule.targetStatus) {
// Idempotent re-fire. We already audited the decision above; nothing // Idempotent re-fire. We already audited the decision above; nothing
// more to do here. // more to do here.

View File

@@ -635,6 +635,76 @@ export async function updateBerthStatus(
return updated!; 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 ───────────────────────────────────────────────────── // ─── Reconciliation Queue ─────────────────────────────────────────────────────
// //
// #67 Phase 3: surfaces every berth whose status was set manually (i.e. // #67 Phase 3: surfaces every berth whose status was set manually (i.e.

View File

@@ -106,13 +106,31 @@ export function isPermanentTenureType(tenureType: string | null | undefined): bo
* active (non-archived, non-closed) interest. Seasonal / fixed-term * active (non-archived, non-closed) interest. Seasonal / fixed-term
* active tenancies do not flip the public status — they fall through * active tenancies do not flip the public status — they fall through
* to the existing under-offer / available precedence. * 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( export function derivePublicStatus(
internalStatus: string, internalStatus: string,
hasSpecificInterest: boolean, hasSpecificInterest: boolean,
hasActivePermanentTenancy = false, hasActivePermanentTenancy = false,
overrideMode: string | null = null,
): PublicStatus { ): PublicStatus {
// Real permanent occupancy / explicit sold always win, even over a pin.
if (internalStatus === 'sold' || hasActivePermanentTenancy) return 'Sold'; 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'; if (internalStatus === 'under_offer' || hasSpecificInterest) return 'Under Offer';
return 'Available'; return 'Available';
} }
@@ -149,7 +167,12 @@ export function toPublicBerth(
'Side Pontoon': toString(berth.sidePontoon), 'Side Pontoon': toString(berth.sidePontoon),
'Power Capacity': toNumber(berth.powerCapacity), 'Power Capacity': toNumber(berth.powerCapacity),
Voltage: toNumber(berth.voltage), Voltage: toNumber(berth.voltage),
Status: derivePublicStatus(berth.status, hasSpecificInterest, hasActivePermanentTenancy), Status: derivePublicStatus(
berth.status,
hasSpecificInterest,
hasActivePermanentTenancy,
berth.statusOverrideMode,
),
Area: toString(berth.area), Area: toString(berth.area),
'Mooring Type': toString(berth.mooringType), 'Mooring Type': toString(berth.mooringType),
'Bow Facing': toString(berth.bowFacing), 'Bow Facing': toString(berth.bowFacing),

View File

@@ -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<boolean> {
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<string, unknown>,
): { 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<string, unknown>;
}
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<AutoPromoteResult> {
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<string> {
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;
}

View File

@@ -677,6 +677,27 @@ export const REGISTRY: SettingEntry[] = [
defaultValue: false, 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 ────────────────────────────────────── // ─── Operations - Residential module ──────────────────────────────────────
// Port-scoped gate for the entire Residential surface (sidebar // Port-scoped gate for the entire Residential surface (sidebar
// "Residential" section, /residential/clients + /residential/interests // "Residential" section, /residential/clients + /residential/interests

View File

@@ -91,6 +91,15 @@ export const updateBerthStatusSchema = z.object({
export type UpdateBerthStatusInput = z.infer<typeof updateBerthStatusSchema>; export type UpdateBerthStatusInput = z.infer<typeof updateBerthStatusSchema>;
// ─── 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<typeof resetBerthOverrideSchema>;
// ─── Archive Berth ──────────────────────────────────────────────────────────── // ─── Archive Berth ────────────────────────────────────────────────────────────
// Post-audit F5: archive replaces hard-delete. A `reason` is required so // Post-audit F5: archive replaces hard-delete. A `reason` is required so

View File

@@ -180,15 +180,59 @@ describe('transformSnapshot - fixture regression', () => {
expect(sourceIds).toEqual([188, 236, 336, 536, 585, 624, 625, 681, 682, 683, 717, 999]); 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 plan = transformSnapshot(FIXTURE);
const stagesById = new Map(plan.interests.map((i) => [i.sourceId, i.pipelineStage])); const stagesById = new Map(plan.interests.map((i) => [i.sourceId, i.pipelineStage]));
expect(stagesById.get(681)).toBe('qualified'); // General Qualified Interest // 681 is "General Qualified Interest" with no berth/size marker → enquiry.
expect(stagesById.get(682)).toBe('nurturing'); // Specific Qualified Interest 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(336)).toBe('contract'); // Contract Signed
expect(stagesById.get(585)).toBe('eoi'); // Signed EOI and NDA 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', () => { it('attaches different yachts to one merged Constanzo client', () => {
const plan = transformSnapshot(FIXTURE); const plan = transformSnapshot(FIXTURE);
const constanzoClient = plan.clients.find( const constanzoClient = plan.clients.find(

View File

@@ -117,6 +117,31 @@ describe('derivePublicStatus', () => {
expect(derivePublicStatus('available', true)).toBe('Under Offer'); 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', () => { describe('isPermanentTenureType', () => {

View File

@@ -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);
});
});