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:
Matt Ciaccio
2026-05-05 20:41:23 +02:00
parent d3a6a9beef
commit 7854cbabe4
5 changed files with 351 additions and 64 deletions

View File

@@ -1,4 +1,4 @@
import { and, eq, count, inArray, isNull, desc } from 'drizzle-orm';
import { and, eq, count, inArray, isNull, desc, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
@@ -250,16 +250,22 @@ export async function getClientInvoices(
if (emailContacts.length === 0) return [];
// Fetch invoices matching any of the client's email addresses
const allInvoices = await db
// 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.
const clientInvoices = await db
.select()
.from(invoices)
.where(eq(invoices.portId, portId))
.orderBy(invoices.createdAt);
const clientInvoices = allInvoices.filter(
(inv) => inv.billingEmail && emailContacts.includes(inv.billingEmail.toLowerCase()),
);
.where(
and(
eq(invoices.portId, portId),
inArray(sql`lower(${invoices.billingEmail})`, emailContacts),
),
)
.orderBy(invoices.createdAt)
.limit(100);
return clientInvoices.map((inv) => ({
id: inv.id,