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