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:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
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,
|
||||
},
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user