perf(audit-tier-3): bulk-fetch the five hot N+1 loops
Replaces per-row fan-out with grouped queries / inArray pre-fetches across the five dashboard + cron hotspots flagged in the audit (MED §13 / HIGH §11–14): * reminders.processFollowUpReminders — was 3 round trips per enabled-and-due interest. Now: filter in JS, single clients bulk-fetch, single reminders bulk-insert, single interests bulk-update, one summary socket emit. 1k due interests: 6 round trips total instead of 3000+. * portal.getClientInvoices — was a full-table scan filtered in JS. Now an inArray push-down on lower(billingEmail) + defensive limit(100). After 12mo this would have been the worst portal endpoint. * interest-scoring.calculateBulkScores — was 6N round trips (1 redis + 1 findFirst + 4 counts per interest). Now 4 grouped count queries on the port's interest set + a single redis pipeline to refresh the cache. 1k interests: ~7 round trips. * document-reminders.processReminderQueue — was 5N round trips per cron tick (port + template + lastReminder + pendingSigners + send per doc). Now hoists port + per-type template map + grouped lastReminder + bulk pendingSigners; per-row work collapses to a Map.get and the documenso send. 500 docs: ~7 round trips. * inquiry-notifications.sendInquiryNotifications — was sequential createNotification + emailQueue.add per recipient inside a public POST. Now Promise.all'd; a 20-user port stops blocking the public inquiry POST on ~80 round trips. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§11–14 (auditor-I Issues 1–4) + MED §13 (auditor-I Issue 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -427,27 +427,37 @@ export async function processFollowUpReminders() {
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const interest of enabledInterests) {
|
||||
if (!interest.reminderDays) continue;
|
||||
|
||||
// Check if enough days have passed since last activity
|
||||
// Pick the interests whose follow-up window has elapsed. Pre-filtering
|
||||
// here means the per-row N+1 walk that used to issue (1 client lookup
|
||||
// + 1 reminder insert + 1 interest update) per interest is replaced by
|
||||
// a single client-bulk-fetch + a single reminder bulk-insert + a
|
||||
// single interests bulk-update against an `inArray` set.
|
||||
const dueInterests = enabledInterests.filter((interest) => {
|
||||
if (!interest.reminderDays) return false;
|
||||
const lastActivity = interest.reminderLastFired ?? interest.updatedAt;
|
||||
const daysSinceActivity = (now.getTime() - lastActivity.getTime()) / (1000 * 60 * 60 * 24);
|
||||
return daysSinceActivity >= interest.reminderDays;
|
||||
});
|
||||
|
||||
if (daysSinceActivity < interest.reminderDays) continue;
|
||||
if (dueInterests.length === 0) continue;
|
||||
|
||||
// Get client name for the reminder title
|
||||
const client = interest.clientId
|
||||
? await db.query.clients.findFirst({ where: eq(clients.id, interest.clientId) })
|
||||
: null;
|
||||
const clientIds = Array.from(
|
||||
new Set(dueInterests.map((i) => i.clientId).filter((v): v is string => Boolean(v))),
|
||||
);
|
||||
const clientsByIdEntries =
|
||||
clientIds.length > 0
|
||||
? await db
|
||||
.select({ id: clients.id, fullName: clients.fullName })
|
||||
.from(clients)
|
||||
.where(inArray(clients.id, clientIds))
|
||||
: [];
|
||||
const clientById = new Map(clientsByIdEntries.map((c) => [c.id, c]));
|
||||
|
||||
const title = client ? `Follow up with ${client.fullName}` : 'Follow up on interest';
|
||||
|
||||
// Find the assigned user (first userPortRole for this port, or fallback)
|
||||
// For now, leave assignedTo null - the notification goes to the port room
|
||||
await db.insert(reminders).values({
|
||||
const newReminders = dueInterests.map((interest) => {
|
||||
const client = interest.clientId ? clientById.get(interest.clientId) : null;
|
||||
return {
|
||||
portId: port.id,
|
||||
title,
|
||||
title: client ? `Follow up with ${client.fullName}` : 'Follow up on interest',
|
||||
note: 'Auto-generated: no activity detected within the configured follow-up window.',
|
||||
dueAt: now,
|
||||
priority: 'medium',
|
||||
@@ -456,23 +466,39 @@ export async function processFollowUpReminders() {
|
||||
interestId: interest.id,
|
||||
clientId: interest.clientId,
|
||||
autoGenerated: true,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
// Update last fired timestamp
|
||||
if (newReminders.length > 0) {
|
||||
await db.insert(reminders).values(newReminders);
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ reminderLastFired: now })
|
||||
.where(eq(interests.id, interest.id));
|
||||
.where(
|
||||
inArray(
|
||||
interests.id,
|
||||
dueInterests.map((i) => i.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire notification to the port room
|
||||
// Single port-room emit summarising the batch — the per-row emit was
|
||||
// mostly noise to the dashboard and amplified socket traffic linearly
|
||||
// with interest count.
|
||||
if (newReminders.length > 0) {
|
||||
emitToRoom(`port:${port.id}`, 'system:alert', {
|
||||
alertType: 'follow_up_created',
|
||||
message: title,
|
||||
message: `${newReminders.length} follow-up reminder${
|
||||
newReminders.length === 1 ? '' : 's'
|
||||
} created`,
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
logger.info({ interestId: interest.id, portId: port.id }, 'Auto follow-up reminder created');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ portId: port.id, created: newReminders.length },
|
||||
'Auto follow-up reminders created (bulk)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user