From c0e5af8b92d0b1361b00c6a89706f444fec7fc24 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 11 May 2026 13:53:10 +0200 Subject: [PATCH] fix(sales): wire missing berth-rule triggers + portal company-billed invoices - G-C4: deposit_received in invoices.ts - G-C4 + G-I2: interest_archived + notifyNextInLine in archiveInterest - G-C4: interest_completed in setInterestOutcome - G-C4: berth_unlinked in removeInterestBerth - G-I5: portal invoices include billingEntityType='company' when client is the director Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[id]/berths/[berthId]/handlers.ts | 7 +- src/lib/services/interest-berths.service.ts | 12 +++ src/lib/services/interests.service.ts | 78 ++++++++++++++++++- src/lib/services/invoices.ts | 7 ++ src/lib/services/portal.service.ts | 62 +++++++++++---- 5 files changed, 149 insertions(+), 17 deletions(-) diff --git a/src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts b/src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts index 59715e06..40cf34d1 100644 --- a/src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts +++ b/src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts @@ -124,7 +124,12 @@ export const deleteHandler: RouteHandler = async (_req, ctx, params) => { await loadScopedRow(interestId, berthId, ctx.portId); - await removeInterestBerth(interestId, berthId, ctx.portId); + await removeInterestBerth(interestId, berthId, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); void createAuditLog({ userId: ctx.userId, diff --git a/src/lib/services/interest-berths.service.ts b/src/lib/services/interest-berths.service.ts index 8c596989..e6ec290f 100644 --- a/src/lib/services/interest-berths.service.ts +++ b/src/lib/services/interest-berths.service.ts @@ -22,6 +22,7 @@ import { db } from '@/lib/db'; import { interestBerths, interests, type InterestBerth } from '@/lib/db/schema/interests'; import { berths } from '@/lib/db/schema/berths'; import { CodedError, NotFoundError } from '@/lib/errors'; +import type { AuditMeta } from '@/lib/audit'; type DbOrTx = typeof db | Parameters[0]>[0]; @@ -287,6 +288,7 @@ export async function removeInterestBerth( interestId: string, berthId: string, portId: string, + meta?: AuditMeta, ): Promise { // Verify both the interest and the berth belong to the caller's // port before issuing the delete. A tenant boundary breach would @@ -305,4 +307,14 @@ export async function removeInterestBerth( await db .delete(interestBerths) .where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId))); + + // G-C4: fire the berth_unlinked berth-rule. Default mode is 'off' so this + // is a silent no-op unless an admin opted in via system_settings.berth_rules. + // Dynamic import avoids a static cycle: berth-rules-engine imports this file + // (getPrimaryBerth). meta is optional so older callers that haven't been + // threaded through can still call this without triggering the rule. + if (meta) { + const { evaluateRule } = await import('@/lib/services/berth-rules-engine'); + void evaluateRule('berth_unlinked', interestId, portId, meta); + } } diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index ee5f06cb..60ad642c 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, exists, inArray, isNull, sql } from 'drizzle-orm'; +import { and, desc, eq, exists, inArray, isNull, ne, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests'; @@ -13,6 +13,9 @@ import { getPortReminderConfig } from '@/lib/services/port-config'; import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { setEntityTags } from '@/lib/services/entity-tags.helper'; +import { evaluateRule } from '@/lib/services/berth-rules-engine'; +import { notifyNextInLine } from '@/lib/services/next-in-line-notify.service'; +import { logger } from '@/lib/logger'; import { getPrimaryBerth, getPrimaryBerthsForInterests, @@ -705,7 +708,7 @@ export async function updateInterest( addedBy: meta.userId, }); } else if (currentBerthId) { - await removeInterestBerth(id, currentBerthId, portId); + await removeInterestBerth(id, currentBerthId, portId, meta); } } @@ -930,6 +933,12 @@ export async function setInterestOutcome( oldStage, }); + // G-C4: fire interest_completed berth-rule for any non-null outcome + // (won / lost / cancelled all qualify). Default rule mode is 'auto' → + // berth status flips to 'sold' for won, but admins can scope per outcome + // via system_settings.berth_rules. + void evaluateRule('interest_completed', id, portId, meta); + return { ok: true as const }; } @@ -998,6 +1007,12 @@ export async function archiveInterest(id: string, portId: string, meta: AuditMet ); } + // Resolve the primary berth BEFORE the soft-delete so the berth-rule and + // next-in-line lookups still see the interest's junction rows. softDelete + // toggles archivedAt; the junction isn't archived alongside it, but the + // rule reads the primary via the junction which is unaffected. + const primaryBerth = await getPrimaryBerth(id); + await softDelete(interests, interests.id, id); void createAuditLog({ @@ -1011,6 +1026,63 @@ export async function archiveInterest(id: string, portId: string, meta: AuditMet }); emitToRoom(`port:${portId}`, 'interest:archived', { interestId: id }); + + // G-C4: fire the berth-rule (default mode 'suggest' for interest_archived). + // G-I2: notify sales of the next-in-line interests on the released berth so + // they can follow up — mirrors the client-archive flow but scoped to a + // single interest's primary berth. + if (primaryBerth) { + void evaluateRule('interest_archived', id, portId, meta); + + // Build the next-in-line dossier: any other active interest linked to + // the same berth that doesn't belong to this archived interest. + void (async () => { + try { + const others = await db + .select({ + interestId: interests.id, + clientId: interests.clientId, + clientName: clients.fullName, + pipelineStage: interests.pipelineStage, + }) + .from(interestBerths) + .innerJoin(interests, eq(interestBerths.interestId, interests.id)) + .leftJoin(clients, eq(interests.clientId, clients.id)) + .where( + and( + eq(interestBerths.berthId, primaryBerth.berthId), + eq(interests.portId, portId), + ne(interests.id, id), + isNull(interests.archivedAt), + isNull(interests.outcome), + ), + ) + .orderBy(desc(interests.updatedAt)) + .limit(10); + + const archivedClient = existing.clientId + ? await db.query.clients.findFirst({ where: eq(clients.id, existing.clientId) }) + : null; + + await notifyNextInLine({ + portId, + berthId: primaryBerth.berthId, + mooringNumber: primaryBerth.mooringNumber ?? '', + archivedClientName: archivedClient?.fullName ?? '(unknown client)', + nextInLineInterests: others.map((o) => ({ + interestId: o.interestId, + clientName: o.clientName, + pipelineStage: o.pipelineStage, + })), + }); + } catch (err) { + logger.error( + { err, interestId: id, berthId: primaryBerth.berthId }, + 'Failed to fire next-in-line notification on interest archive', + ); + } + })(); + } } export async function restoreInterest(id: string, portId: string, meta: AuditMeta) { @@ -1131,7 +1203,7 @@ export async function unlinkBerth(id: string, portId: string, meta: AuditMeta) { const oldBerthId = previousPrimary?.berthId ?? null; if (oldBerthId) { - await removeInterestBerth(id, oldBerthId, portId); + await removeInterestBerth(id, oldBerthId, portId, meta); } const [updated] = await db diff --git a/src/lib/services/invoices.ts b/src/lib/services/invoices.ts index 4987703a..918bbcc9 100644 --- a/src/lib/services/invoices.ts +++ b/src/lib/services/invoices.ts @@ -745,6 +745,13 @@ export async function recordPayment( meta, `Deposit invoice ${existing.invoiceNumber} paid`, ); + + // Deposit-paid also fires the berth-rule for `deposit_received` so admins + // can auto-mark the primary berth as Sold (default rule mode: 'auto'). + // Dynamic import keeps the invoices ↔ berth-rules graph one-way and + // mirrors the existing advanceStageIfBehind dispatch above. + const { evaluateRule } = await import('@/lib/services/berth-rules-engine'); + void evaluateRule('deposit_received', updated.interestId, portId, meta); } return updated; diff --git a/src/lib/services/portal.service.ts b/src/lib/services/portal.service.ts index eba00417..5ba92ebe 100644 --- a/src/lib/services/portal.service.ts +++ b/src/lib/services/portal.service.ts @@ -1,4 +1,4 @@ -import { and, eq, count, inArray, isNull, desc, sql } from 'drizzle-orm'; +import { and, eq, count, inArray, isNull, desc, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients } from '@/lib/db/schema/clients'; @@ -248,22 +248,58 @@ export async function getClientInvoices( .filter((c) => c.channel === 'email') .map((c) => c.value.toLowerCase()); - if (emailContacts.length === 0) return []; + // G-I5: the most common B2B pattern is "individual client buys through their + // company" — those invoices ship with billingEntityType='company' and the + // portal user (client) is just a director of that company. Filtering on + // billingEmail alone hides these invoices. Resolve director memberships + // through company_memberships (role='director', active = endDate IS NULL) + // and OR them into the predicate. + const directorMemberships = await db + .select({ companyId: companyMemberships.companyId }) + .from(companyMemberships) + .innerJoin(companies, eq(companyMemberships.companyId, companies.id)) + .where( + and( + eq(companyMemberships.clientId, clientId), + eq(companyMemberships.role, 'director'), + isNull(companyMemberships.endDate), + eq(companies.portId, portId), + ), + ); + const directorCompanyIds = directorMemberships.map((m) => m.companyId); - // Fetch only the invoices matching any of the client's email addresses. - // Without the inArray push-down here every portal invoice page-load - // full-scanned the invoices table and filtered in JS — by 12mo it would - // have been the worst portal endpoint in the platform. Defensive limit - // 100 caps the upper bound for clients with abnormally many invoices. + // If the portal user has neither billing emails on file nor any active + // director memberships, there's nothing this query could return. + if (emailContacts.length === 0 && directorCompanyIds.length === 0) return []; + + // Build the OR predicate: (billingEmail ∈ client emails) OR + // (billingEntityType='company' AND billingEntityId ∈ director company ids). + const emailPredicate = + emailContacts.length > 0 + ? inArray(sql`lower(${invoices.billingEmail})`, emailContacts) + : undefined; + const companyPredicate = + directorCompanyIds.length > 0 + ? and( + eq(invoices.billingEntityType, 'company'), + inArray(invoices.billingEntityId, directorCompanyIds), + ) + : undefined; + const matchPredicate = + emailPredicate && companyPredicate + ? or(emailPredicate, companyPredicate) + : (emailPredicate ?? companyPredicate); + + // Fetch only the invoices matching any of the client's email addresses or + // company memberships. Without the predicate push-down here every portal + // invoice page-load full-scanned the invoices table and filtered in JS — + // by 12mo it would have been the worst portal endpoint in the platform. + // Defensive limit 100 caps the upper bound for clients with abnormally many + // invoices. const clientInvoices = await db .select() .from(invoices) - .where( - and( - eq(invoices.portId, portId), - inArray(sql`lower(${invoices.billingEmail})`, emailContacts), - ), - ) + .where(and(eq(invoices.portId, portId), matchPredicate)) .orderBy(invoices.createdAt) .limit(100);