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:
@@ -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
|
||||
|
||||
235
backend/src/services/weeklyReportService.ts
Normal file
235
backend/src/services/weeklyReportService.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user