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 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({
|
void createAuditLog({
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { db } from '@/lib/db';
|
|||||||
import { interestBerths, interests, type InterestBerth } from '@/lib/db/schema/interests';
|
import { interestBerths, interests, type InterestBerth } from '@/lib/db/schema/interests';
|
||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
import { CodedError, NotFoundError } from '@/lib/errors';
|
import { CodedError, NotFoundError } from '@/lib/errors';
|
||||||
|
import type { AuditMeta } from '@/lib/audit';
|
||||||
|
|
||||||
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||||
|
|
||||||
@@ -287,6 +288,7 @@ export async function removeInterestBerth(
|
|||||||
interestId: string,
|
interestId: string,
|
||||||
berthId: string,
|
berthId: string,
|
||||||
portId: string,
|
portId: string,
|
||||||
|
meta?: AuditMeta,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Verify both the interest and the berth belong to the caller's
|
// Verify both the interest and the berth belong to the caller's
|
||||||
// port before issuing the delete. A tenant boundary breach would
|
// port before issuing the delete. A tenant boundary breach would
|
||||||
@@ -305,4 +307,14 @@ export async function removeInterestBerth(
|
|||||||
await db
|
await db
|
||||||
.delete(interestBerths)
|
.delete(interestBerths)
|
||||||
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
|
.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 { db } from '@/lib/db';
|
||||||
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
|
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 { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
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 {
|
import {
|
||||||
getPrimaryBerth,
|
getPrimaryBerth,
|
||||||
getPrimaryBerthsForInterests,
|
getPrimaryBerthsForInterests,
|
||||||
@@ -705,7 +708,7 @@ export async function updateInterest(
|
|||||||
addedBy: meta.userId,
|
addedBy: meta.userId,
|
||||||
});
|
});
|
||||||
} else if (currentBerthId) {
|
} else if (currentBerthId) {
|
||||||
await removeInterestBerth(id, currentBerthId, portId);
|
await removeInterestBerth(id, currentBerthId, portId, meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,6 +933,12 @@ export async function setInterestOutcome(
|
|||||||
oldStage,
|
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 };
|
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);
|
await softDelete(interests, interests.id, id);
|
||||||
|
|
||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
@@ -1011,6 +1026,63 @@ export async function archiveInterest(id: string, portId: string, meta: AuditMet
|
|||||||
});
|
});
|
||||||
|
|
||||||
emitToRoom(`port:${portId}`, 'interest:archived', { interestId: id });
|
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) {
|
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;
|
const oldBerthId = previousPrimary?.berthId ?? null;
|
||||||
|
|
||||||
if (oldBerthId) {
|
if (oldBerthId) {
|
||||||
await removeInterestBerth(id, oldBerthId, portId);
|
await removeInterestBerth(id, oldBerthId, portId, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
|
|||||||
@@ -745,6 +745,13 @@ export async function recordPayment(
|
|||||||
meta,
|
meta,
|
||||||
`Deposit invoice ${existing.invoiceNumber} paid`,
|
`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;
|
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 { db } from '@/lib/db';
|
||||||
import { clients } from '@/lib/db/schema/clients';
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
@@ -248,22 +248,58 @@ export async function getClientInvoices(
|
|||||||
.filter((c) => c.channel === 'email')
|
.filter((c) => c.channel === 'email')
|
||||||
.map((c) => c.value.toLowerCase());
|
.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.
|
// If the portal user has neither billing emails on file nor any active
|
||||||
// Without the inArray push-down here every portal invoice page-load
|
// director memberships, there's nothing this query could return.
|
||||||
// full-scanned the invoices table and filtered in JS — by 12mo it would
|
if (emailContacts.length === 0 && directorCompanyIds.length === 0) return [];
|
||||||
// have been the worst portal endpoint in the platform. Defensive limit
|
|
||||||
// 100 caps the upper bound for clients with abnormally many invoices.
|
// 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
|
const clientInvoices = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(
|
.where(and(eq(invoices.portId, portId), matchPredicate))
|
||||||
and(
|
|
||||||
eq(invoices.portId, portId),
|
|
||||||
inArray(sql`lower(${invoices.billingEmail})`, emailContacts),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(invoices.createdAt)
|
.orderBy(invoices.createdAt)
|
||||||
.limit(100);
|
.limit(100);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user