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

@@ -236,9 +236,14 @@ const DEFAULT_OPTIONS: TransformOptions = {
// the pre-(9→7)-refactor vocab (open/details_sent/eoi_sent/…) and would have
// written invalid `pipeline_stage` values. Current stages live in
// `src/lib/constants.ts` PIPELINE_STAGES.
// §4b SIGNED OFF (2026-06-02): GQI with no qualifying marker (no berth / no
// size) is a plain enquiry; GQI that named a berth/size and SQI are qualified.
// `nurturing` is NOT bulk-assigned from legacy (reserved for actively-worked
// deals). `deposit_paid` is set independently from the Deposit-10% flag below.
// Contract Signed stays in `contract` with outcome OPEN (not won).
const STAGE_MAP: Record<string, string> = {
'General Qualified Interest': 'qualified',
'Specific Qualified Interest': 'nurturing',
'General Qualified Interest': 'enquiry', // refined to 'qualified' below if a berth/size marker exists
'Specific Qualified Interest': 'qualified',
'EOI and NDA Sent': 'eoi',
'Signed EOI and NDA': 'eoi',
'Made Reservation': 'reservation',
@@ -666,6 +671,14 @@ function buildPlannedInterest(row: NocoDbRow, clientTempId: string): PlannedInte
const depositReceived =
((row['Deposit 10% Status'] as string | undefined) ?? '').trim() === 'Received';
let mappedStage = STAGE_MAP[stage] ?? 'enquiry';
// §4b marker rule: a "General Qualified Interest" that named a specific berth
// OR a desired berth size is really a qualified lead; with no such marker it
// stays a plain enquiry. SQI + later stages are unaffected by this.
if (stage === 'General Qualified Interest') {
const hasBerthMarker = !!(row['Berth Number'] as string | undefined)?.trim();
const hasSizeMarker = !!(row['Berth Size Desired'] as string | undefined)?.trim();
mappedStage = hasBerthMarker || hasSizeMarker ? 'qualified' : 'enquiry';
}
if (depositReceived && mappedStage !== 'contract') mappedStage = 'deposit_paid';
// Interest-level EOI signing state (for display on the deal). "Signed"

View File

@@ -243,11 +243,33 @@ async function applyRuleToBerth(
// pre-lock snapshot. If the prior contender already moved status to
// our target, we're idempotent and bail.
const [current] = await tx
.select({ status: berths.status })
.select({ status: berths.status, statusOverrideMode: berths.statusOverrideMode })
.from(berths)
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
if (!current) return { changed: false as const };
// Soft-pin priority: a human-set manual override wins over automatic
// rules EXCEPT a real sale (target 'sold' — deposit_received /
// contract_signed / interest_completed). Soft triggers (eoi_*,
// reservation→under_offer, *→available) must not stomp a manual pin;
// the pin persists until a human changes or resets it. Sale triggers
// still override because a confirmed sale is a fact a stale pin can't
// be allowed to hide.
if (current.statusOverrideMode === 'manual' && rule.targetStatus !== 'sold') {
logger.debug(
{
trigger,
targetBerthId,
portId,
status: current.status,
targetStatus: rule.targetStatus,
},
'Berth-rule auto: respecting manual pin, skipping non-sale auto-write',
);
return { changed: false as const };
}
if (current.status === rule.targetStatus) {
// Idempotent re-fire. We already audited the decision above; nothing
// more to do here.

View File

@@ -635,6 +635,76 @@ export async function updateBerthStatus(
return updated!;
}
// ─── Reset Manual Override ──────────────────────────────────────────────────
//
// "Reset to automatic": clears `statusOverrideMode` so the berth stops being a
// human-pinned status and resumes derived behaviour. The base `status` is left
// as-is (the rep can change it explicitly via updateBerthStatus) - the point of
// the reset is to drop the pin so the public-map derivation (specific-interest
// links, tenancies) and the rules engine govern the berth again. For the common
// case (pinned `available` with an active specific interest) this immediately
// flips the public map to "Under Offer" because `derivePublicStatus` no longer
// sees a manual override to honour.
export async function resetBerthStatusOverride(
id: string,
portId: string,
reason: string,
meta: AuditMeta,
) {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
if (!existing) throw new NotFoundError('Berth');
if (existing.statusOverrideMode !== 'manual') {
throw new ValidationError('Berth is not in a manual-override state');
}
const [updated] = await db
.update(berths)
.set({
statusOverrideMode: null,
statusLastChangedBy: meta.userId,
statusLastChangedReason: reason,
statusLastModified: new Date(),
updatedAt: new Date(),
})
.where(and(eq(berths.id, id), eq(berths.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
entityId: id,
oldValue: { statusOverrideMode: 'manual' },
newValue: { statusOverrideMode: null, reason },
metadata: { type: 'reset_manual_override', reason },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
// The displayed (derived) status may change the instant the pin drops, so
// tell the open boards to refetch - same event the status PATCH emits.
emitToRoom(`port:${portId}`, 'berth:statusChanged', {
berthId: id,
oldStatus: existing.status,
newStatus: existing.status,
triggeredBy: meta.userId,
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'berth:statusChanged', {
berthId: id,
oldStatus: existing.status,
newStatus: existing.status,
}),
);
return updated!;
}
// ─── Reconciliation Queue ─────────────────────────────────────────────────────
//
// #67 Phase 3: surfaces every berth whose status was set manually (i.e.

View File

@@ -106,13 +106,31 @@ export function isPermanentTenureType(tenureType: string | null | undefined): bo
* active (non-archived, non-closed) interest. Seasonal / fixed-term
* active tenancies do not flip the public status — they fall through
* to the existing under-offer / available precedence.
*
* Manual-override priority (soft pin): when `overrideMode === 'manual'` a
* human has deliberately pinned this berth's status, so the pinned base
* status wins over the interest-derived signal — a berth a rep pinned
* `available` stays Available even with an active specific-interest link.
* The pin is "soft": a real permanent tenancy (genuine occupancy) and an
* explicit `sold` status still override it, because those represent facts
* a stale pin must not hide. A real deposit-paid sale overrides at WRITE
* time (the rules engine flips the berth to `sold` + `automated`), so by
* the time we read here the manual pin is already gone in that case.
* `overrideMode` defaults to null => pure automatic derivation (unchanged).
*/
export function derivePublicStatus(
internalStatus: string,
hasSpecificInterest: boolean,
hasActivePermanentTenancy = false,
overrideMode: string | null = null,
): PublicStatus {
// Real permanent occupancy / explicit sold always win, even over a pin.
if (internalStatus === 'sold' || hasActivePermanentTenancy) return 'Sold';
// Soft pin: a manual override wins over the interest-derived signal.
if (overrideMode === 'manual') {
return internalStatus === 'under_offer' ? 'Under Offer' : 'Available';
}
// Automatic derivation.
if (internalStatus === 'under_offer' || hasSpecificInterest) return 'Under Offer';
return 'Available';
}
@@ -149,7 +167,12 @@ export function toPublicBerth(
'Side Pontoon': toString(berth.sidePontoon),
'Power Capacity': toNumber(berth.powerCapacity),
Voltage: toNumber(berth.voltage),
Status: derivePublicStatus(berth.status, hasSpecificInterest, hasActivePermanentTenancy),
Status: derivePublicStatus(
berth.status,
hasSpecificInterest,
hasActivePermanentTenancy,
berth.statusOverrideMode,
),
Area: toString(berth.area),
'Mooring Type': toString(berth.mooringType),
'Bow Facing': toString(berth.bowFacing),

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,
},
// Operations - Website berth auto-promote. Port-scoped gate that decides
// what a website berth registration does to the public map. OFF (default):
// captures land in the triage inbox and a rep promotes them, which marks
// the berth (Option 1, the safe posture - matches the old NocoDB manual
// flow). ON: a berth_inquiry naming a specific, currently-available berth
// is auto-promoted to a prospect (client + optional yacht + interest) with
// the berth linked is_specific_interest=true, immediately flipping the
// public map to "Under Offer" (Option 2, hybrid). General/residence/contact
// submissions stay capture-only either way. Intended to be flipped ON only
// post-cutover so it never races the NocoDB-primary migration window.
{
key: 'website_berth_autopromote_enabled',
section: 'operations.intake',
label: 'Auto-mark berths from website registrations',
description:
'When enabled, a website registration for a SPECIFIC, currently-available berth is auto-promoted into a prospect (client + interest, with the berth marked "Under Offer" on the public map) instead of waiting in the triage inbox for a rep. Skips berths that are already Sold or Under Offer, and skips general inquiries with no specific berth (those still go to triage). Leave OFF for the safe behavior where a rep reviews and promotes each registration. Recommended to keep OFF until after the website cutover.',
type: 'boolean',
scope: 'port',
defaultValue: false,
},
// ─── Operations - Residential module ──────────────────────────────────────
// Port-scoped gate for the entire Residential surface (sidebar
// "Residential" section, /residential/clients + /residential/interests

View File

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