feat(berths): website auto-promote toggle + manual-override soft-pin priority
- 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:
@@ -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.
|
||||||
|
|||||||
26
src/app/api/v1/berths/[id]/status/reset/route.ts
Normal file
26
src/app/api/v1/berths/[id]/status/reset/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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"
|
||||||
@@ -165,6 +167,16 @@ function ManualBadge() {
|
|||||||
</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 } }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'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' : ''}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
302
src/lib/services/website-intake-promote.service.ts
Normal file
302
src/lib/services/website-intake-promote.service.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
46
tests/unit/website-intake-promote.test.ts
Normal file
46
tests/unit/website-intake-promote.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user