/** * Server-side data resolvers for the list-report PDF kinds * (clients, berths, interests). Each returns a capped flat array * matching the shape the matching React-PDF report component * expects. * * Caps: * - 1 000 rows max per export. Above that, the PDF becomes * unreadable (hundreds of pages) and pdf-renderer memory cost * grows linearly. Reps wanting fuller exports use CSV. * - When the cap is hit, the report renders a "Showing top N of * " line so the export isn't silently truncated. * * Filters carried via the `filters` config: * Clients: search, source, nationality, includeArchived * Berths: search, status, area, includeArchived * Interests: search, pipelineStage, includeArchived * * Validation happens at the route layer; this service trusts the * caller has run the inputs through the same zod schemas the * existing list endpoints use. */ import { and, desc, eq, isNull, sql, type SQL } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients } from '@/lib/db/schema/clients'; import { berths } from '@/lib/db/schema/berths'; import { interests, interestBerths } from '@/lib/db/schema/interests'; const REPORT_ROW_CAP = 1_000; export interface ClientReportRow { id: string; fullName: string; source: string | null; nationality: string | null; primaryEmail: string | null; primaryPhone: string | null; createdAt: string; } export interface ClientReportData { rows: ClientReportRow[]; total: number; capHit: boolean; } /** * Slim client export. Pulls the bare minimum columns the report * surface renders so payload size and PDF length stay reasonable * even on a 1 000-row port. */ export async function resolveClientReportData( portId: string, filters: { includeArchived?: boolean } = {}, ): Promise { const whereParts: SQL[] = [eq(clients.portId, portId)]; if (!filters.includeArchived) whereParts.push(isNull(clients.archivedAt)); const whereClause = whereParts.length > 1 ? and(...whereParts) : whereParts[0]; const countRows = await db .select({ count: sql`count(*)::int` }) .from(clients) .where(whereClause); const total = Number(countRows[0]?.count ?? 0); // Subqueries pick a single email / phone per client. `is_primary // DESC, created_at DESC` picks the primary if set; otherwise the // most recent contact in that channel. Matches the ordering the // canonical `listClients` service uses. const rows = await db .select({ id: clients.id, fullName: clients.fullName, source: clients.source, nationality: clients.nationalityIso, primaryEmail: sql`( SELECT cc.value FROM client_contacts cc WHERE cc.client_id = ${clients.id} AND cc.channel = 'email' ORDER BY cc.is_primary DESC, cc.created_at DESC LIMIT 1 )`, primaryPhone: sql`( SELECT cc.value FROM client_contacts cc WHERE cc.client_id = ${clients.id} AND cc.channel = 'phone' ORDER BY cc.is_primary DESC, cc.created_at DESC LIMIT 1 )`, createdAt: clients.createdAt, }) .from(clients) .where(whereClause) .orderBy(desc(clients.createdAt)) .limit(REPORT_ROW_CAP); return { rows: rows.map((r) => ({ id: r.id, fullName: r.fullName, source: r.source ?? null, nationality: r.nationality ?? null, primaryEmail: r.primaryEmail ?? null, primaryPhone: r.primaryPhone ?? null, createdAt: r.createdAt.toISOString(), })), total, capHit: total > REPORT_ROW_CAP, }; } export interface BerthReportRow { id: string; mooringNumber: string; area: string | null; status: string; lengthFt: string | null; widthFt: string | null; draftFt: string | null; price: string | null; priceCurrency: string; tenureType: string; } export interface BerthReportData { rows: BerthReportRow[]; total: number; capHit: boolean; } export async function resolveBerthReportData( portId: string, filters: { includeArchived?: boolean } = {}, ): Promise { const whereParts: SQL[] = [eq(berths.portId, portId)]; if (!filters.includeArchived) whereParts.push(isNull(berths.archivedAt)); const whereClause = whereParts.length > 1 ? and(...whereParts) : whereParts[0]; const countRows = await db .select({ count: sql`count(*)::int` }) .from(berths) .where(whereClause); const total = Number(countRows[0]?.count ?? 0); const rows = await db .select({ id: berths.id, mooringNumber: berths.mooringNumber, area: berths.area, status: berths.status, lengthFt: berths.lengthFt, widthFt: berths.widthFt, draftFt: berths.draftFt, price: berths.price, priceCurrency: berths.priceCurrency, tenureType: berths.tenureType, }) .from(berths) .where(whereClause) .orderBy(berths.mooringNumber) .limit(REPORT_ROW_CAP); return { rows, total, capHit: total > REPORT_ROW_CAP, }; } export interface InterestReportRow { id: string; clientName: string | null; primaryMooring: string | null; pipelineStage: string; source: string | null; outcome: string | null; createdAt: string; } export interface InterestReportData { rows: InterestReportRow[]; total: number; capHit: boolean; } export async function resolveInterestReportData( portId: string, filters: { includeArchived?: boolean } = {}, ): Promise { const whereParts: SQL[] = [eq(interests.portId, portId)]; if (!filters.includeArchived) whereParts.push(isNull(interests.archivedAt)); const whereClause = whereParts.length > 1 ? and(...whereParts) : whereParts[0]; const countRows = await db .select({ count: sql`count(*)::int` }) .from(interests) .where(whereClause); const total = Number(countRows[0]?.count ?? 0); // Join client (one-to-one) + primary berth (one-to-one via the // `is_primary=true` row). Keep the join LEFT so interests without // a primary berth still render - those are the early-stage deals // that haven't been pitched a specific mooring yet. const rows = await db .select({ id: interests.id, clientName: clients.fullName, primaryMooring: berths.mooringNumber, pipelineStage: interests.pipelineStage, source: interests.source, outcome: interests.outcome, createdAt: interests.createdAt, }) .from(interests) .leftJoin(clients, eq(clients.id, interests.clientId)) .leftJoin( interestBerths, and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)), ) .leftJoin(berths, eq(berths.id, interestBerths.berthId)) .where(whereClause) .orderBy(desc(interests.updatedAt)) .limit(REPORT_ROW_CAP); return { rows: rows.map((r) => ({ id: r.id, clientName: r.clientName, primaryMooring: r.primaryMooring, pipelineStage: r.pipelineStage, source: r.source, outcome: r.outcome, createdAt: r.createdAt.toISOString(), })), total, capHit: total > REPORT_ROW_CAP, }; }