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) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 13:53:10 +02:00
parent 1b00c8a7a2
commit c0e5af8b92
5 changed files with 149 additions and 17 deletions

View File

@@ -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,

View File

@@ -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<Parameters<typeof db.transaction>[0]>[0];
@@ -287,6 +288,7 @@ export async function removeInterestBerth(
interestId: string,
berthId: string,
portId: string,
meta?: AuditMeta,
): Promise<void> {
// 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);
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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);