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 <noreply@anthropic.com>
This commit is contained in:
admin
2026-02-25 13:23:31 -05:00
parent 7ed9571cb9
commit a44b90f307
2 changed files with 256 additions and 0 deletions

View File

@@ -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

View File

@@ -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<WeeklyReportRow[]> {
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<void> {
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
? '<p>No CIMs were processed in the past 7 days.</p>'
: `<p><strong>${rows.length} CIM${rows.length !== 1 ? 's' : ''}</strong> processed in the past 7 days. See the attached CSV for details.</p>`;
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 };