Manual stage override
Sales reps need to skip canTransitionStage rules when the data was
entered out of order — e.g. recording a contract_signed deal whose
earlier stages were never tracked in the system.
- New permission flag interests.override_stage in RolePermissions.
Plumbed through the schema TS type, the role-editor UI, the seed
file's pre-built roles (super_admin/director/sales_manager get it,
sales_agent + viewer don't), and the test factories.
- changeStageSchema gains an optional `override` boolean and the
service checks it before evaluating canTransitionStage. When
override=true the reason field becomes required (min 5 chars) and
is recorded in the audit log.
- The route handler gates `override` on the new permission so a
sales_agent without it can't pass override=true and bypass.
- InterestStagePicker auto-detects when the requested transition is
blocked by the table and switches into "override mode" — shows an
amber warning, requires the reason, button label flips to
"Override stage". When the operator lacks the permission, the
warning is red and the button is disabled.
Residential Partner role
Per the smart-archive scoping conversation: external partners who
handle residential inquiries shouldn't see marina clients, yachts,
berths, or financials. The two residential_* permission groups
already exist; this commit just seeds a pre-built system role
("residential_partner") with those flags + minimal own-reminders, so
admins can invite a partner today via /admin/users without manually
building the permission set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1032 lines
34 KiB
TypeScript
1032 lines
34 KiB
TypeScript
import { and, desc, eq, exists, inArray, isNull, sql } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
|
|
import { reminders } from '@/lib/db/schema/operations';
|
|
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
|
|
import { berths } from '@/lib/db/schema/berths';
|
|
import { yachts } from '@/lib/db/schema/yachts';
|
|
import { companyMemberships } from '@/lib/db/schema/companies';
|
|
import { tags } from '@/lib/db/schema/system';
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
|
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
|
|
import { emitToRoom } from '@/lib/socket/server';
|
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
|
import {
|
|
getPrimaryBerth,
|
|
getPrimaryBerthsForInterests,
|
|
removeInterestBerth,
|
|
upsertInterestBerth,
|
|
upsertInterestBerthTx,
|
|
} from '@/lib/services/interest-berths.service';
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
|
import { diffEntity } from '@/lib/entity-diff';
|
|
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
|
|
import { PIPELINE_STAGES, canTransitionStage, type PipelineStage } from '@/lib/constants';
|
|
import type {
|
|
CreateInterestInput,
|
|
UpdateInterestInput,
|
|
ChangeStageInput,
|
|
ListInterestsInput,
|
|
SetOutcomeInput,
|
|
ClearOutcomeInput,
|
|
} from '@/lib/validators/interests';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
// ─── Port-scope FK validator ─────────────────────────────────────────────────
|
|
|
|
// Tenant scope: every FK referenced from an interest body - clientId, berthId,
|
|
// and yachtId - must belong to the caller's port. Without this, a body-supplied
|
|
// foreign-port id would create an interest that joins through these FKs and
|
|
// surfaces foreign-tenant data on subsequent reads (clientName, berth mooring
|
|
// number, yacht ownership). assertYachtBelongsToClient still runs separately to
|
|
// enforce the additional ownership invariant.
|
|
async function assertInterestFksInPort(
|
|
portId: string,
|
|
fks: { clientId?: string | null; berthId?: string | null; yachtId?: string | null },
|
|
): Promise<void> {
|
|
const checks: Array<Promise<void>> = [];
|
|
if (fks.clientId) {
|
|
checks.push(
|
|
db.query.clients
|
|
.findFirst({ where: and(eq(clients.id, fks.clientId), eq(clients.portId, portId)) })
|
|
.then((row) => {
|
|
if (!row) throw new ValidationError('clientId not found in this port');
|
|
}),
|
|
);
|
|
}
|
|
if (fks.berthId) {
|
|
checks.push(
|
|
db.query.berths
|
|
.findFirst({ where: and(eq(berths.id, fks.berthId), eq(berths.portId, portId)) })
|
|
.then((row) => {
|
|
if (!row) throw new ValidationError('berthId not found in this port');
|
|
}),
|
|
);
|
|
}
|
|
if (fks.yachtId) {
|
|
checks.push(
|
|
db.query.yachts
|
|
.findFirst({ where: and(eq(yachts.id, fks.yachtId), eq(yachts.portId, portId)) })
|
|
.then((row) => {
|
|
if (!row) throw new ValidationError('yachtId not found in this port');
|
|
}),
|
|
);
|
|
}
|
|
await Promise.all(checks);
|
|
}
|
|
|
|
// ─── Yacht ownership validator ───────────────────────────────────────────────
|
|
|
|
async function assertYachtBelongsToClient(
|
|
portId: string,
|
|
yachtId: string,
|
|
clientId: string,
|
|
): Promise<void> {
|
|
const yacht = await db.query.yachts.findFirst({
|
|
where: and(eq(yachts.id, yachtId), eq(yachts.portId, portId)),
|
|
});
|
|
if (!yacht) throw new ValidationError('yacht not found');
|
|
|
|
// Direct ownership by client
|
|
if (yacht.currentOwnerType === 'client' && yacht.currentOwnerId === clientId) {
|
|
return;
|
|
}
|
|
|
|
// Company-represented: client has active membership in the owning company
|
|
if (yacht.currentOwnerType === 'company') {
|
|
const membership = await db.query.companyMemberships.findFirst({
|
|
where: and(
|
|
eq(companyMemberships.companyId, yacht.currentOwnerId),
|
|
eq(companyMemberships.clientId, clientId),
|
|
isNull(companyMemberships.endDate),
|
|
),
|
|
});
|
|
if (membership) return;
|
|
}
|
|
|
|
throw new ValidationError('yacht does not belong to this client');
|
|
}
|
|
|
|
// ─── BR-011: Auto-promote leadCategory ───────────────────────────────────────
|
|
|
|
async function resolveLeadCategory(
|
|
clientId: string,
|
|
leadCategory: string | undefined | null,
|
|
yachtId?: string | null,
|
|
): Promise<string | undefined> {
|
|
if (leadCategory && leadCategory !== 'general_interest') {
|
|
return leadCategory;
|
|
}
|
|
|
|
if (yachtId) {
|
|
const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) });
|
|
if (yacht && (yacht.lengthFt || yacht.lengthM)) {
|
|
return 'specific_qualified';
|
|
}
|
|
}
|
|
|
|
return leadCategory ?? undefined;
|
|
}
|
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
export async function listInterests(portId: string, query: ListInterestsInput) {
|
|
const {
|
|
page,
|
|
limit,
|
|
sort,
|
|
order,
|
|
search,
|
|
includeArchived,
|
|
clientId,
|
|
yachtId,
|
|
berthId,
|
|
pipelineStage,
|
|
leadCategory,
|
|
eoiStatus,
|
|
tagIds,
|
|
} = query;
|
|
|
|
const filters = [];
|
|
|
|
if (clientId) {
|
|
filters.push(eq(interests.clientId, clientId));
|
|
}
|
|
if (yachtId) {
|
|
filters.push(eq(interests.yachtId, yachtId));
|
|
}
|
|
if (berthId) {
|
|
// EXISTS subquery against the junction: matches whether or not the
|
|
// berth is the interest's primary, mirroring "this berth is linked
|
|
// to this interest in any role" semantics from plan §3.4.
|
|
filters.push(
|
|
exists(
|
|
db
|
|
.select({ one: sql`1` })
|
|
.from(interestBerths)
|
|
.where(
|
|
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.berthId, berthId)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (pipelineStage && pipelineStage.length > 0) {
|
|
filters.push(inArray(interests.pipelineStage, pipelineStage));
|
|
}
|
|
if (leadCategory) {
|
|
filters.push(eq(interests.leadCategory, leadCategory));
|
|
}
|
|
if (eoiStatus) {
|
|
filters.push(eq(interests.eoiStatus, eoiStatus));
|
|
}
|
|
if (tagIds && tagIds.length > 0) {
|
|
const interestsWithTags = await db
|
|
.selectDistinct({ interestId: interestTags.interestId })
|
|
.from(interestTags)
|
|
.where(inArray(interestTags.tagId, tagIds));
|
|
const matchingIds = interestsWithTags.map((r) => r.interestId);
|
|
if (matchingIds.length > 0) {
|
|
filters.push(inArray(interests.id, matchingIds));
|
|
} else {
|
|
return { data: [], total: 0 };
|
|
}
|
|
}
|
|
|
|
const sortColumn = (() => {
|
|
switch (sort) {
|
|
case 'pipelineStage':
|
|
return interests.pipelineStage;
|
|
case 'leadCategory':
|
|
return interests.leadCategory;
|
|
case 'createdAt':
|
|
return interests.createdAt;
|
|
case 'dateLastContact':
|
|
// Postgres sorts NULLs last on DESC by default, which is the right
|
|
// behaviour for triage (recently-contacted first, never-contacted
|
|
// at the bottom).
|
|
return interests.dateLastContact;
|
|
default:
|
|
return interests.updatedAt;
|
|
}
|
|
})();
|
|
|
|
const result = await buildListQuery({
|
|
table: interests,
|
|
portIdColumn: interests.portId,
|
|
portId,
|
|
idColumn: interests.id,
|
|
updatedAtColumn: interests.updatedAt,
|
|
filters,
|
|
sort: { column: sortColumn, direction: order },
|
|
page,
|
|
pageSize: limit,
|
|
searchColumns: [],
|
|
searchTerm: search,
|
|
includeArchived,
|
|
archivedAtColumn: interests.archivedAt,
|
|
});
|
|
|
|
// Join client names, primary-berth mooring numbers, and yacht names.
|
|
const interestIds = (result.data as Array<{ id: string; clientId: string }>).map((i) => i.id);
|
|
const clientIds = [
|
|
...new Set((result.data as Array<{ clientId: string }>).map((i) => i.clientId)),
|
|
];
|
|
const yachtIds = [
|
|
...new Set(
|
|
(result.data as Array<{ yachtId: string | null }>)
|
|
.map((i) => i.yachtId)
|
|
.filter(Boolean) as string[],
|
|
),
|
|
];
|
|
|
|
let clientsMap: Record<string, string> = {};
|
|
let yachtsMap: Record<string, string> = {};
|
|
const tagsByInterestId: Record<string, Array<{ id: string; name: string; color: string }>> = {};
|
|
const notesCountByInterestId: Record<string, number> = {};
|
|
|
|
if (clientIds.length > 0) {
|
|
const clientRows = await db
|
|
.select({ id: clients.id, fullName: clients.fullName })
|
|
.from(clients)
|
|
.where(inArray(clients.id, clientIds));
|
|
clientsMap = Object.fromEntries(clientRows.map((c) => [c.id, c.fullName]));
|
|
}
|
|
|
|
// Primary-berth lookup via the interest_berths junction. Single round-trip
|
|
// by interestId list - see plan §3.4: every "the berth for this interest"
|
|
// surface resolves through getPrimaryBerth(...) rather than a column read.
|
|
const primaryBerthMap = await getPrimaryBerthsForInterests(interestIds);
|
|
|
|
if (yachtIds.length > 0) {
|
|
const yachtRows = await db
|
|
.select({ id: yachts.id, name: yachts.name })
|
|
.from(yachts)
|
|
.where(inArray(yachts.id, yachtIds));
|
|
yachtsMap = Object.fromEntries(yachtRows.map((y) => [y.id, y.name]));
|
|
}
|
|
|
|
if (interestIds.length > 0) {
|
|
const tagRows = await db
|
|
.select({
|
|
interestId: interestTags.interestId,
|
|
id: tags.id,
|
|
name: tags.name,
|
|
color: tags.color,
|
|
})
|
|
.from(interestTags)
|
|
.innerJoin(tags, eq(interestTags.tagId, tags.id))
|
|
.where(inArray(interestTags.interestId, interestIds));
|
|
|
|
for (const row of tagRows) {
|
|
if (!tagsByInterestId[row.interestId]) tagsByInterestId[row.interestId] = [];
|
|
tagsByInterestId[row.interestId]!.push({ id: row.id, name: row.name, color: row.color });
|
|
}
|
|
|
|
// Note counts per interest, for the comment-icon row affordance.
|
|
const noteCountRows = await db
|
|
.select({
|
|
interestId: interestNotes.interestId,
|
|
count: sql<number>`count(*)::int`,
|
|
})
|
|
.from(interestNotes)
|
|
.where(inArray(interestNotes.interestId, interestIds))
|
|
.groupBy(interestNotes.interestId);
|
|
for (const row of noteCountRows) {
|
|
notesCountByInterestId[row.interestId] = row.count;
|
|
}
|
|
}
|
|
|
|
const data = (result.data as Array<Record<string, unknown>>).map((i) => {
|
|
const primary = primaryBerthMap.get(i.id as string) ?? null;
|
|
return {
|
|
...i,
|
|
clientName: clientsMap[i.clientId as string] ?? null,
|
|
berthId: primary?.berthId ?? null,
|
|
berthMooringNumber: primary?.mooringNumber ?? null,
|
|
yachtName: i.yachtId ? (yachtsMap[i.yachtId as string] ?? null) : null,
|
|
tags: tagsByInterestId[i.id as string] ?? [],
|
|
notesCount: notesCountByInterestId[i.id as string] ?? 0,
|
|
};
|
|
});
|
|
|
|
return { data, total: result.total };
|
|
}
|
|
|
|
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
|
|
|
export async function getInterestById(id: string, portId: string) {
|
|
const interest = await db.query.interests.findFirst({
|
|
where: eq(interests.id, id),
|
|
});
|
|
|
|
if (!interest || interest.portId !== portId) {
|
|
throw new NotFoundError('Interest');
|
|
}
|
|
|
|
const [clientRow] = await db
|
|
.select({ fullName: clients.fullName })
|
|
.from(clients)
|
|
.where(eq(clients.id, interest.clientId));
|
|
|
|
// EOI prerequisites + interest-detail header contact actions: surface the
|
|
// linked client's primary email/phone (and the canonical E.164 form for
|
|
// wa.me) so the header can render Email / Call / WhatsApp buttons without
|
|
// a second fetch, and the Documents tab can show the EOI prereq checklist.
|
|
const [emailContact] = await db
|
|
.select({ value: clientContacts.value })
|
|
.from(clientContacts)
|
|
.where(and(eq(clientContacts.clientId, interest.clientId), eq(clientContacts.channel, 'email')))
|
|
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
|
|
.limit(1);
|
|
|
|
const [phoneContact] = await db
|
|
.select({ value: clientContacts.value, valueE164: clientContacts.valueE164 })
|
|
.from(clientContacts)
|
|
.where(
|
|
and(
|
|
eq(clientContacts.clientId, interest.clientId),
|
|
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
|
),
|
|
)
|
|
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
|
|
.limit(1);
|
|
|
|
const [addressRow] = await db
|
|
.select({ id: clientAddresses.id })
|
|
.from(clientAddresses)
|
|
.where(
|
|
and(eq(clientAddresses.clientId, interest.clientId), eq(clientAddresses.isPrimary, true)),
|
|
)
|
|
.limit(1);
|
|
|
|
// Primary berth comes from the interest_berths junction (plan §3.4).
|
|
const primaryBerth = await getPrimaryBerth(interest.id);
|
|
const berthId = primaryBerth?.berthId ?? null;
|
|
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
|
|
|
|
const tagRows = await db
|
|
.select({ id: tags.id, name: tags.name, color: tags.color })
|
|
.from(interestTags)
|
|
.innerJoin(tags, eq(interestTags.tagId, tags.id))
|
|
.where(eq(interestTags.interestId, id));
|
|
|
|
// Most-recent note preview for the Overview tab (the "do you have anything
|
|
// outstanding on this lead?" peek). Returns the latest note's truncated
|
|
// content + author/timestamp so the UI can render a one-line teaser.
|
|
const [recentNote] = await db
|
|
.select({
|
|
id: interestNotes.id,
|
|
content: interestNotes.content,
|
|
authorId: interestNotes.authorId,
|
|
createdAt: interestNotes.createdAt,
|
|
})
|
|
.from(interestNotes)
|
|
.where(eq(interestNotes.interestId, id))
|
|
.orderBy(desc(interestNotes.createdAt))
|
|
.limit(1);
|
|
|
|
const [{ count: notesCount } = { count: 0 }] = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(interestNotes)
|
|
.where(eq(interestNotes.interestId, id));
|
|
|
|
// Active reminder count for the interest's bell badge. Counts reminders
|
|
// directly linked via interestId - `pending` and `snoozed` only;
|
|
// completed/dismissed don't surface.
|
|
const [{ count: activeReminderCount } = { count: 0 }] = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(reminders)
|
|
.where(and(eq(reminders.interestId, id), inArray(reminders.status, ['pending', 'snoozed'])));
|
|
|
|
return {
|
|
...interest,
|
|
clientName: clientRow?.fullName ?? null,
|
|
clientPrimaryEmail: emailContact?.value ?? null,
|
|
clientPrimaryPhone: phoneContact?.value ?? null,
|
|
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
|
|
clientHasAddress: !!addressRow,
|
|
berthId,
|
|
berthMooringNumber,
|
|
tags: tagRows,
|
|
notesCount,
|
|
recentNote: recentNote ?? null,
|
|
activeReminderCount,
|
|
};
|
|
}
|
|
|
|
// ─── Create ───────────────────────────────────────────────────────────────────
|
|
|
|
export async function createInterest(portId: string, data: CreateInterestInput, meta: AuditMeta) {
|
|
await assertInterestFksInPort(portId, {
|
|
clientId: data.clientId,
|
|
berthId: data.berthId,
|
|
yachtId: data.yachtId,
|
|
});
|
|
|
|
if (data.yachtId) {
|
|
await assertYachtBelongsToClient(portId, data.yachtId, data.clientId);
|
|
}
|
|
|
|
const { tagIds, berthId: inputBerthId, ...interestData } = data;
|
|
|
|
// BR-011: auto-promote leadCategory
|
|
const resolvedLeadCategory = await resolveLeadCategory(
|
|
data.clientId,
|
|
data.leadCategory,
|
|
data.yachtId,
|
|
);
|
|
|
|
const result = await withTransaction(async (tx) => {
|
|
const [interest] = await tx
|
|
.insert(interests)
|
|
.values({
|
|
portId,
|
|
...interestData,
|
|
leadCategory: resolvedLeadCategory,
|
|
})
|
|
.returning();
|
|
|
|
if (tagIds && tagIds.length > 0) {
|
|
await tx
|
|
.insert(interestTags)
|
|
.values(tagIds.map((tagId) => ({ interestId: interest!.id, tagId })));
|
|
}
|
|
|
|
// Plan §3.4: when berthId is provided we materialise it as a junction
|
|
// row inside the same transaction so an interest is never created
|
|
// without its primary-berth link surviving rollback.
|
|
if (inputBerthId) {
|
|
await upsertInterestBerthTx(tx, interest!.id, inputBerthId, {
|
|
isPrimary: true,
|
|
isSpecificInterest: true,
|
|
isInEoiBundle: false,
|
|
addedBy: meta.userId,
|
|
});
|
|
}
|
|
|
|
return interest!;
|
|
});
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'create',
|
|
entityType: 'interest',
|
|
entityId: result.id,
|
|
newValue: { clientId: result.clientId, pipelineStage: result.pipelineStage },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'interest:created', {
|
|
interestId: result.id,
|
|
clientId: result.clientId,
|
|
berthId: inputBerthId ?? null,
|
|
source: result.source ?? '',
|
|
});
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
dispatchWebhookEvent(portId, 'interest:created', {
|
|
interestId: result.id,
|
|
clientId: result.clientId,
|
|
}),
|
|
);
|
|
|
|
return result;
|
|
}
|
|
|
|
// ─── Update ───────────────────────────────────────────────────────────────────
|
|
|
|
export async function updateInterest(
|
|
id: string,
|
|
portId: string,
|
|
data: UpdateInterestInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
const existing = await db.query.interests.findFirst({
|
|
where: eq(interests.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Interest');
|
|
}
|
|
|
|
// berthId no longer lives on the interests row - resolve current primary
|
|
// via the junction so we know whether the caller is asking for a change.
|
|
const currentPrimary = await getPrimaryBerth(id);
|
|
const currentBerthId = currentPrimary?.berthId ?? null;
|
|
|
|
await assertInterestFksInPort(portId, {
|
|
berthId: data.berthId && data.berthId !== currentBerthId ? data.berthId : null,
|
|
yachtId: data.yachtId && data.yachtId !== existing.yachtId ? data.yachtId : null,
|
|
});
|
|
|
|
if (data.yachtId && data.yachtId !== existing.yachtId) {
|
|
await assertYachtBelongsToClient(portId, data.yachtId, existing.clientId);
|
|
}
|
|
|
|
// BR-011: auto-promote leadCategory if provided
|
|
let resolvedLeadCategory = data.leadCategory;
|
|
if ('leadCategory' in data) {
|
|
resolvedLeadCategory = (await resolveLeadCategory(
|
|
existing.clientId,
|
|
data.leadCategory,
|
|
data.yachtId ?? existing.yachtId,
|
|
)) as typeof data.leadCategory;
|
|
}
|
|
|
|
// Strip berthId out of the row write - the column was removed by the
|
|
// junction-migration. We keep the value for diff/audit purposes and
|
|
// dispatch the junction write separately.
|
|
const { berthId: incomingBerthId, ...rowData } = data;
|
|
const updateData = { ...rowData, leadCategory: resolvedLeadCategory };
|
|
const { diff } = diffEntity(
|
|
{ ...(existing as Record<string, unknown>), berthId: currentBerthId },
|
|
{ ...(updateData as Record<string, unknown>), berthId: incomingBerthId ?? currentBerthId },
|
|
);
|
|
|
|
const [updated] = await db
|
|
.update(interests)
|
|
.set({ ...updateData, updatedAt: new Date() })
|
|
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
|
|
.returning();
|
|
|
|
// Apply primary-berth change through the junction so the unique
|
|
// partial index is respected and the previous primary is demoted.
|
|
if ('berthId' in data && incomingBerthId !== currentBerthId) {
|
|
if (incomingBerthId) {
|
|
await upsertInterestBerth(id, incomingBerthId, {
|
|
isPrimary: true,
|
|
isSpecificInterest: true,
|
|
addedBy: meta.userId,
|
|
});
|
|
} else if (currentBerthId) {
|
|
await removeInterestBerth(id, currentBerthId, portId);
|
|
}
|
|
}
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'interest',
|
|
entityId: id,
|
|
oldValue: diff as Record<string, unknown>,
|
|
newValue: updateData as Record<string, unknown>,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'interest:updated', {
|
|
interestId: id,
|
|
changedFields: Object.keys(diff),
|
|
});
|
|
|
|
return updated!;
|
|
}
|
|
|
|
// ─── Change Stage ─────────────────────────────────────────────────────────────
|
|
|
|
export async function changeInterestStage(
|
|
id: string,
|
|
portId: string,
|
|
data: ChangeStageInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
const existing = await db.query.interests.findFirst({
|
|
where: eq(interests.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Interest');
|
|
}
|
|
|
|
// Plan: yachtId required to leave stage=open
|
|
if (existing.pipelineStage === 'open' && data.pipelineStage !== 'open' && !existing.yachtId) {
|
|
throw new ValidationError('yachtId is required before leaving stage=open');
|
|
}
|
|
|
|
// Block egregious skips. The transition table allows reasonable forward
|
|
// jumps (e.g. open → eoi_sent) while rejecting things like completed → open
|
|
// or open → contract_signed. Same-stage no-ops are allowed.
|
|
// Override (sales-rep manual fix) bypasses the table — the route handler
|
|
// gates this on the `interests.override_stage` permission and requires
|
|
// a reason, recorded in the audit log below.
|
|
if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
|
|
throw new ValidationError(
|
|
`Cannot move interest from "${existing.pipelineStage}" directly to "${data.pipelineStage}". Use the override option if you need to skip stages — requires a reason.`,
|
|
);
|
|
}
|
|
if (data.override && (!data.reason || data.reason.trim().length < 5)) {
|
|
throw new ValidationError(
|
|
'Override requires a reason (min 5 chars) explaining the manual stage change.',
|
|
);
|
|
}
|
|
|
|
const oldStage = existing.pipelineStage;
|
|
|
|
const [updated] = await db
|
|
.update(interests)
|
|
.set({ pipelineStage: data.pipelineStage, updatedAt: new Date() })
|
|
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
|
|
.returning();
|
|
|
|
// BR-133: Auto-populate milestones based on stage
|
|
const milestoneUpdates: Record<string, unknown> = {};
|
|
if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = new Date();
|
|
if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = new Date();
|
|
if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = new Date();
|
|
if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = new Date();
|
|
if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = new Date();
|
|
if (Object.keys(milestoneUpdates).length > 0) {
|
|
await db
|
|
.update(interests)
|
|
.set({ ...milestoneUpdates, updatedAt: new Date() })
|
|
.where(eq(interests.id, id));
|
|
}
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'interest',
|
|
entityId: id,
|
|
oldValue: { pipelineStage: oldStage },
|
|
newValue: { pipelineStage: data.pipelineStage, reason: data.reason },
|
|
metadata: { type: 'stage_change', reason: data.reason },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'interest:stageChanged', {
|
|
interestId: id,
|
|
oldStage: oldStage ?? '',
|
|
newStage: data.pipelineStage,
|
|
clientName: '',
|
|
berthNumber: '',
|
|
});
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
dispatchWebhookEvent(portId, 'interest:stageChanged', {
|
|
interestId: id,
|
|
oldStage: oldStage ?? null,
|
|
newStage: data.pipelineStage,
|
|
}),
|
|
);
|
|
|
|
// Fire-and-forget notification to the acting user
|
|
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
|
|
createNotification({
|
|
portId,
|
|
userId: meta.userId,
|
|
type: 'interest_stage_changed',
|
|
title: `Interest moved to ${data.pipelineStage}`,
|
|
description: `Interest ${id} stage changed from ${oldStage ?? 'unknown'} to ${data.pipelineStage}`,
|
|
link: `/interests/${id}`,
|
|
entityType: 'interest',
|
|
entityId: id,
|
|
dedupeKey: `interest:${id}:stage:${data.pipelineStage}`,
|
|
cooldownMs: 300_000,
|
|
}),
|
|
);
|
|
|
|
return updated!;
|
|
}
|
|
|
|
// ─── Advance Stage If Behind ─────────────────────────────────────────────────
|
|
//
|
|
// Moves an interest forward to `target` if (and only if) it is currently behind
|
|
// it in the pipeline order. Used by lifecycle events (EOI sent, EOI signed,
|
|
// deposit recorded, contract signed) so the user-visible stage tracks reality
|
|
// without overwriting a more advanced state - e.g. a late-arriving signed-EOI
|
|
// webhook on an interest that has already moved on to `contract_sent` is a
|
|
// no-op rather than a regression.
|
|
//
|
|
// Returns true when the stage was changed.
|
|
export async function advanceStageIfBehind(
|
|
interestId: string,
|
|
portId: string,
|
|
target: PipelineStage,
|
|
meta: AuditMeta,
|
|
reason?: string,
|
|
): Promise<boolean> {
|
|
const existing = await db.query.interests.findFirst({
|
|
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
|
});
|
|
if (!existing) return false;
|
|
|
|
const currentIdx = PIPELINE_STAGES.indexOf(existing.pipelineStage as PipelineStage);
|
|
const targetIdx = PIPELINE_STAGES.indexOf(target);
|
|
if (currentIdx === -1 || targetIdx === -1 || currentIdx >= targetIdx) {
|
|
return false;
|
|
}
|
|
|
|
// yachtId gate: changeInterestStage requires a yacht before leaving `open`.
|
|
// EOI events imply a yacht is in the picture, but if the data is missing we
|
|
// bail rather than throw - the EOI itself shouldn't fail because of this.
|
|
if (existing.pipelineStage === 'open' && !existing.yachtId) {
|
|
return false;
|
|
}
|
|
|
|
await changeInterestStage(interestId, portId, { pipelineStage: target, reason }, meta);
|
|
return true;
|
|
}
|
|
|
|
// ─── Set Outcome (Won / Lost) ────────────────────────────────────────────────
|
|
//
|
|
// Records a terminal outcome for the interest and moves the pipelineStage to
|
|
// `completed` so the funnel/kanban reflect the final state. The outcome
|
|
// distinguishes won deals (they made it through) from lost variants - funnel
|
|
// math and reports key off the `outcome` column to compute true conversion.
|
|
//
|
|
// Both the stage advance and the outcome write happen in one transaction so
|
|
// the timeline doesn't end up showing one without the other.
|
|
export async function setInterestOutcome(
|
|
id: string,
|
|
portId: string,
|
|
data: SetOutcomeInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
const existing = await db.query.interests.findFirst({
|
|
where: and(eq(interests.id, id), eq(interests.portId, portId)),
|
|
});
|
|
if (!existing) throw new NotFoundError('Interest');
|
|
|
|
const oldOutcome = existing.outcome;
|
|
const oldStage = existing.pipelineStage;
|
|
|
|
const now = new Date();
|
|
await db
|
|
.update(interests)
|
|
.set({
|
|
outcome: data.outcome,
|
|
outcomeReason: data.reason ?? null,
|
|
outcomeAt: now,
|
|
pipelineStage: 'completed',
|
|
updatedAt: now,
|
|
})
|
|
.where(and(eq(interests.id, id), eq(interests.portId, portId)));
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'interest',
|
|
entityId: id,
|
|
oldValue: { outcome: oldOutcome, pipelineStage: oldStage },
|
|
newValue: { outcome: data.outcome, pipelineStage: 'completed', reason: data.reason },
|
|
metadata: { type: 'outcome_set' },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'interest:outcomeSet', {
|
|
interestId: id,
|
|
outcome: data.outcome,
|
|
oldStage,
|
|
});
|
|
|
|
return { ok: true as const };
|
|
}
|
|
|
|
// Clears a terminal outcome and reopens the interest. Used when an outcome
|
|
// was set in error or a "lost" deal comes back to life.
|
|
export async function clearInterestOutcome(
|
|
id: string,
|
|
portId: string,
|
|
data: ClearOutcomeInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
const existing = await db.query.interests.findFirst({
|
|
where: and(eq(interests.id, id), eq(interests.portId, portId)),
|
|
});
|
|
if (!existing) throw new NotFoundError('Interest');
|
|
if (!existing.outcome) {
|
|
throw new ValidationError('Interest has no outcome to clear');
|
|
}
|
|
|
|
const reopenStage = data.reopenStage ?? 'in_communication';
|
|
const now = new Date();
|
|
await db
|
|
.update(interests)
|
|
.set({
|
|
outcome: null,
|
|
outcomeReason: null,
|
|
outcomeAt: null,
|
|
pipelineStage: reopenStage,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(eq(interests.id, id), eq(interests.portId, portId)));
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'interest',
|
|
entityId: id,
|
|
oldValue: { outcome: existing.outcome, pipelineStage: existing.pipelineStage },
|
|
newValue: { outcome: null, pipelineStage: reopenStage },
|
|
metadata: { type: 'outcome_cleared' },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'interest:outcomeCleared', { interestId: id });
|
|
|
|
return { ok: true as const };
|
|
}
|
|
|
|
// ─── Archive / Restore ────────────────────────────────────────────────────────
|
|
|
|
export async function archiveInterest(id: string, portId: string, meta: AuditMeta) {
|
|
const existing = await db.query.interests.findFirst({
|
|
where: eq(interests.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Interest');
|
|
}
|
|
|
|
// BR-014: Block archive if pending EOI/contract
|
|
if (existing.eoiStatus === 'waiting_for_signatures' || existing.contractStatus === 'pending') {
|
|
throw new ConflictError(
|
|
'Cannot archive interest with pending documents. Cancel documents first.',
|
|
);
|
|
}
|
|
|
|
await softDelete(interests, interests.id, id);
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'archive',
|
|
entityType: 'interest',
|
|
entityId: id,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'interest:archived', { interestId: id });
|
|
}
|
|
|
|
export async function restoreInterest(id: string, portId: string, meta: AuditMeta) {
|
|
const existing = await db.query.interests.findFirst({
|
|
where: eq(interests.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Interest');
|
|
}
|
|
|
|
await restore(interests, interests.id, id);
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'restore',
|
|
entityType: 'interest',
|
|
entityId: id,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'interest:updated', { interestId: id, changedFields: [] });
|
|
}
|
|
|
|
// ─── Set Tags ─────────────────────────────────────────────────────────────────
|
|
|
|
export async function setInterestTags(
|
|
id: string,
|
|
portId: string,
|
|
tagIds: string[],
|
|
meta: AuditMeta,
|
|
) {
|
|
const existing = await db.query.interests.findFirst({
|
|
where: eq(interests.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Interest');
|
|
}
|
|
|
|
const result = await setEntityTags({
|
|
joinTable: interestTags,
|
|
entityColumn: interestTags.interestId,
|
|
tagColumn: interestTags.tagId,
|
|
entityId: id,
|
|
portId,
|
|
tagIds,
|
|
meta,
|
|
entityType: 'interest',
|
|
});
|
|
|
|
return { interestId: result.entityId, tagIds: result.tagIds };
|
|
}
|
|
|
|
// ─── Link / Unlink Berth ──────────────────────────────────────────────────────
|
|
|
|
export async function linkBerth(id: string, portId: string, berthId: string, meta: AuditMeta) {
|
|
const existing = await db.query.interests.findFirst({
|
|
where: eq(interests.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Interest');
|
|
}
|
|
|
|
await assertInterestFksInPort(portId, { berthId });
|
|
|
|
const previousPrimary = await getPrimaryBerth(id);
|
|
const oldBerthId = previousPrimary?.berthId ?? null;
|
|
|
|
await upsertInterestBerth(id, berthId, {
|
|
isPrimary: true,
|
|
isSpecificInterest: true,
|
|
addedBy: meta.userId,
|
|
});
|
|
|
|
// Touch updatedAt so list/sort surfaces still reflect the change.
|
|
const [updated] = await db
|
|
.update(interests)
|
|
.set({ updatedAt: new Date() })
|
|
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
|
|
.returning();
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'interest',
|
|
entityId: id,
|
|
oldValue: { berthId: oldBerthId },
|
|
newValue: { berthId },
|
|
metadata: { type: 'berth_linked' },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'interest:berthLinked', { interestId: id, berthId });
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
dispatchWebhookEvent(portId, 'interest:berthLinked', { interestId: id, berthId }),
|
|
);
|
|
|
|
return updated!;
|
|
}
|
|
|
|
export async function unlinkBerth(id: string, portId: string, meta: AuditMeta) {
|
|
const existing = await db.query.interests.findFirst({
|
|
where: eq(interests.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Interest');
|
|
}
|
|
|
|
const previousPrimary = await getPrimaryBerth(id);
|
|
const oldBerthId = previousPrimary?.berthId ?? null;
|
|
|
|
if (oldBerthId) {
|
|
await removeInterestBerth(id, oldBerthId, portId);
|
|
}
|
|
|
|
const [updated] = await db
|
|
.update(interests)
|
|
.set({ updatedAt: new Date() })
|
|
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
|
|
.returning();
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'interest',
|
|
entityId: id,
|
|
oldValue: { berthId: oldBerthId },
|
|
newValue: { berthId: null },
|
|
metadata: { type: 'berth_unlinked' },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'interest:berthUnlinked', {
|
|
interestId: id,
|
|
berthId: oldBerthId ?? '',
|
|
});
|
|
|
|
return updated!;
|
|
}
|
|
|
|
// ─── Stage Counts (for board) ────────────────────────────────────────────────
|
|
|
|
export async function getInterestStageCounts(portId: string) {
|
|
const rows = await db
|
|
.select({ stage: interests.pipelineStage, count: sql<number>`count(*)::int` })
|
|
.from(interests)
|
|
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
|
|
.groupBy(interests.pipelineStage);
|
|
return Object.fromEntries(rows.map((r) => [r.stage, r.count]));
|
|
}
|