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 emailPort = defineString('EMAIL_PORT', { default: '587' });
|
||||||
const emailSecure = defineString('EMAIL_SECURE', { default: 'false' });
|
const emailSecure = defineString('EMAIL_SECURE', { default: 'false' });
|
||||||
const emailWeeklyRecipient = defineString('EMAIL_WEEKLY_RECIPIENT', { default: '' });
|
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
|
// Configure Firebase Functions v2 for larger uploads
|
||||||
// Note: defineString() values are automatically available in process.env
|
// 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
|
// Scheduled function to clean up old database records
|
||||||
// Runs daily at 3 AM UTC to enforce retention policies
|
// Runs daily at 3 AM UTC to enforce retention policies
|
||||||
// Also handles monitoring table retention (INFR-03) — consolidated from former runRetentionCleanup
|
// 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