From a44b90f307fe65b02e688a1b79ce917bdde70201 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 25 Feb 2026 13:23:31 -0500 Subject: [PATCH] feat: add weekly CIM report email every Thursday at noon ET Adds a Firebase scheduled function (sendWeeklyReport) that runs every Thursday at 12:00 America/New_York and emails a CSV attachment to the four BluePoint Capital recipients. The CSV covers all completed documents from the past 7 days with: Date Processed, Company Name, Core Operations Summary, Geography, Deal Source, Industry/Sector, Stated Reason for Sale, LTM Revenue, and LTM EBITDA. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/index.ts | 21 ++ backend/src/services/weeklyReportService.ts | 235 ++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 backend/src/services/weeklyReportService.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 8da2cba..8bb07ee 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -215,6 +215,7 @@ const emailHost = defineString('EMAIL_HOST', { default: 'smtp.gmail.com' }); const emailPort = defineString('EMAIL_PORT', { default: '587' }); const emailSecure = defineString('EMAIL_SECURE', { default: 'false' }); const emailWeeklyRecipient = defineString('EMAIL_WEEKLY_RECIPIENT', { default: '' }); +// EMAIL_REPORT_RECIPIENTS is non-sensitive — read directly from process.env with hardcoded fallback in weeklyReportService // Configure Firebase Functions v2 for larger uploads // Note: defineString() values are automatically available in process.env @@ -354,6 +355,26 @@ export const runHealthProbes = onSchedule({ }); }); +// Scheduled function to send weekly CIM summary report every Monday at 12:00 UTC +export const sendWeeklyReport = onSchedule({ + schedule: 'every thursday 12:00', + timeZone: 'America/New_York', + timeoutSeconds: 120, + memory: '256MiB', + retryCount: 1, + secrets: [ + databaseUrl, + supabaseServiceKey, + supabaseAnonKey, + emailPass, + ], +}, async (_event) => { + logger.info('sendWeeklyReport: triggered', { timestamp: new Date().toISOString() }); + const { weeklyReportService } = await import('./services/weeklyReportService'); + await weeklyReportService.sendWeeklyReport(); + logger.info('sendWeeklyReport: complete', { timestamp: new Date().toISOString() }); +}); + // Scheduled function to clean up old database records // Runs daily at 3 AM UTC to enforce retention policies // Also handles monitoring table retention (INFR-03) — consolidated from former runRetentionCleanup diff --git a/backend/src/services/weeklyReportService.ts b/backend/src/services/weeklyReportService.ts new file mode 100644 index 0000000..cb335be --- /dev/null +++ b/backend/src/services/weeklyReportService.ts @@ -0,0 +1,235 @@ +import nodemailer from 'nodemailer'; +import { logger } from '../utils/logger'; + +// ============================================================================= +// Types +// ============================================================================= + +interface WeeklyReportRow { + dateProcessed: string; + companyName: string; + coreOperationsSummary: string; + geography: string; + dealSource: string; + industrySector: string; + statedReasonForSale: string; + ltmRevenue: string; + ltmEbitda: string; +} + +// ============================================================================= +// Private helpers +// ============================================================================= + +/** + * Create a nodemailer transporter using SMTP config from process.env. + * Created inside function scope — NOT at module level — because Firebase Secrets + * are not available at module load time. + */ +function createTransporter(): nodemailer.Transporter { + return nodemailer.createTransport({ + host: process.env['EMAIL_HOST'] ?? 'smtp.gmail.com', + port: parseInt(process.env['EMAIL_PORT'] ?? '587', 10), + secure: process.env['EMAIL_SECURE'] === 'true', + auth: { + user: process.env['EMAIL_USER'], + pass: process.env['EMAIL_PASS'], + }, + }); +} + +/** + * Escape a value for inclusion in a CSV cell. + * Wraps in quotes if the value contains commas, quotes, or newlines. + */ +function escapeCSV(value: string | null | undefined): string { + const str = value ?? ''; + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +/** + * Query the last 7 days of completed documents from the database. + */ +async function fetchWeeklyDocuments(): Promise { + const { getPostgresPool } = await import('../config/supabase'); + const pool = getPostgresPool(); + + const result = await pool.query<{ + processing_completed_at: Date | null; + created_at: Date; + target_company_name: string | null; + core_operations_summary: string | null; + geography: string | null; + deal_source: string | null; + industry_sector: string | null; + stated_reason_for_sale: string | null; + ltm_revenue: string | null; + ltm_ebitda: string | null; + }>(` + SELECT + processing_completed_at, + created_at, + analysis_data->'dealOverview'->>'targetCompanyName' AS target_company_name, + analysis_data->'businessDescription'->>'coreOperationsSummary' AS core_operations_summary, + analysis_data->'dealOverview'->>'geography' AS geography, + analysis_data->'dealOverview'->>'dealSource' AS deal_source, + analysis_data->'dealOverview'->>'industrySector' AS industry_sector, + analysis_data->'dealOverview'->>'statedReasonForSale' AS stated_reason_for_sale, + analysis_data->'financialSummary'->'financials'->'ltm'->>'revenue' AS ltm_revenue, + analysis_data->'financialSummary'->'financials'->'ltm'->>'ebitda' AS ltm_ebitda + FROM documents + WHERE + status = 'completed' + AND analysis_data IS NOT NULL + AND COALESCE(processing_completed_at, created_at) >= NOW() - INTERVAL '7 days' + ORDER BY COALESCE(processing_completed_at, created_at) DESC + `); + + return result.rows.map((row) => { + const dateRaw = row.processing_completed_at ?? row.created_at; + const dateProcessed = dateRaw + ? new Date(dateRaw).toLocaleDateString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: 'UTC', + }) + : ''; + + return { + dateProcessed, + companyName: row.target_company_name ?? '', + coreOperationsSummary: row.core_operations_summary ?? '', + geography: row.geography ?? '', + dealSource: row.deal_source ?? '', + industrySector: row.industry_sector ?? '', + statedReasonForSale: row.stated_reason_for_sale ?? '', + ltmRevenue: row.ltm_revenue ?? '', + ltmEbitda: row.ltm_ebitda ?? '', + }; + }); +} + +/** + * Build the CSV string from report rows. + */ +function buildCSV(rows: WeeklyReportRow[]): string { + const header = [ + 'Date Processed', + 'Company Name', + 'Core Operations Summary', + 'Geography (HQ & Key Operations)', + 'Deal Source', + 'Industry/Sector', + 'Stated Reason for Sale', + 'LTM Revenue', + 'LTM EBITDA', + ].join(','); + + const dataRows = rows.map((row) => + [ + escapeCSV(row.dateProcessed), + escapeCSV(row.companyName), + escapeCSV(row.coreOperationsSummary), + escapeCSV(row.geography), + escapeCSV(row.dealSource), + escapeCSV(row.industrySector), + escapeCSV(row.statedReasonForSale), + escapeCSV(row.ltmRevenue), + escapeCSV(row.ltmEbitda), + ].join(',') + ); + + return [header, ...dataRows].join('\r\n'); +} + +// ============================================================================= +// Exported service +// ============================================================================= + +/** + * Generate a weekly CIM summary CSV and email it to the configured recipients. + * + * Recipients are read from EMAIL_REPORT_RECIPIENTS (comma-separated). + * Covers documents completed in the last 7 days. + * Email failures are caught and logged — they do NOT throw. + */ +async function sendWeeklyReport(): Promise { + const defaultRecipients = + 'jpressnell@bluepointcapital.com,dcampbell@bluepointcapital.com,atkach@bluepointcapital.com,mkneipp@bluepointcapital.com'; + const recipientsRaw = process.env['EMAIL_REPORT_RECIPIENTS'] ?? defaultRecipients; + const recipients = recipientsRaw + .split(',') + .map((e) => e.trim()) + .filter(Boolean); + + if (recipients.length === 0) { + logger.warn('weeklyReportService.sendWeeklyReport: no EMAIL_REPORT_RECIPIENTS configured — skipping'); + return; + } + + let rows: WeeklyReportRow[]; + try { + rows = await fetchWeeklyDocuments(); + } catch (err) { + logger.error('weeklyReportService.sendWeeklyReport: failed to fetch documents', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const csv = buildCSV(rows); + const weekEnd = new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: 'UTC', + }); + const filename = `CIM_Weekly_Report_${weekEnd.replace(/\//g, '-')}.csv`; + const subject = `CIM Weekly Report — ${rows.length} deal${rows.length !== 1 ? 's' : ''} (week ending ${weekEnd})`; + + const bodyText = + rows.length === 0 + ? 'No CIMs were processed in the past 7 days.' + : `${rows.length} CIM${rows.length !== 1 ? 's' : ''} processed in the past 7 days. See the attached CSV for details.`; + + const bodyHtml = + rows.length === 0 + ? '

No CIMs were processed in the past 7 days.

' + : `

${rows.length} CIM${rows.length !== 1 ? 's' : ''} processed in the past 7 days. See the attached CSV for details.

`; + + try { + const transporter = createTransporter(); + await transporter.sendMail({ + from: process.env['EMAIL_FROM'] ?? process.env['EMAIL_USER'], + to: recipients.join(', '), + subject, + text: bodyText, + html: bodyHtml, + attachments: [ + { + filename, + content: csv, + contentType: 'text/csv', + }, + ], + }); + + logger.info('weeklyReportService.sendWeeklyReport: report sent', { + recipients, + rowCount: rows.length, + filename, + }); + } catch (err) { + logger.error('weeklyReportService.sendWeeklyReport: failed to send email', { + error: err instanceof Error ? err.message : String(err), + recipients, + }); + // Do NOT re-throw — email failure must not break the scheduled function + } +} + +export const weeklyReportService = { sendWeeklyReport };