From d01258b4389f0ef26eb323e2f5d0ec0f93e908c6 Mon Sep 17 00:00:00 2001 From: Retkatmun Date: Mon, 19 Oct 2026 15:00:23 +0100 Subject: [PATCH] feat(notifications): add scheduled email reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #1758. Merchants can now schedule recurring analytics summary emails (daily / weekly / monthly) delivered via the existing EmailProvider (SendGrid or SES). New files backend/services/notification/scheduledReportService.ts - ScheduledReportService: CRUD for schedules, recipient management, buildReportEmail() (pure renderer), sendReport(), runDue() - InMemoryReportStore with injectable ReportStore interface for swap-in - nextSendAt() helper with clock-drift-safe hour normalisation - Report types: analytics_summary, mrr_breakdown, churn_report, full_report backend/services/notification/scheduledReportRouter.ts - Plain Node.js HTTP router (no framework) mounted at /scheduled-reports - POST / GET (list) / GET /:id / PATCH /:id / DELETE /:id - POST /:id/recipients DELETE /:id/recipients - POST /:id/send-now (ad-hoc trigger) backend/services/notification/jobs/scheduledReportJob.ts - createScheduledReportPoller() — lightweight setInterval loop (15 min) - registerScheduledReportJob() — registers with WeightedFairQueue - createScheduledReportHandler() — queue handler factory backend/services/notification/__tests__/scheduledReportService.test.ts - 49 unit tests; all passing Modified backend/server.ts — wires the service, router, and poller into the server lifecycle; poller stopped cleanly on shutdown Closes #1758 --- backend/server.ts | 65 ++ .../__tests__/scheduledReportService.test.ts | 564 +++++++++++++++++ .../notification/jobs/scheduledReportJob.ts | 172 +++++ .../notification/scheduledReportRouter.ts | 218 +++++++ .../notification/scheduledReportService.ts | 589 ++++++++++++++++++ 5 files changed, 1608 insertions(+) create mode 100644 backend/services/notification/__tests__/scheduledReportService.test.ts create mode 100644 backend/services/notification/jobs/scheduledReportJob.ts create mode 100644 backend/services/notification/scheduledReportRouter.ts create mode 100644 backend/services/notification/scheduledReportService.ts diff --git a/backend/server.ts b/backend/server.ts index 255b7c82..8e4f29a0 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -59,6 +59,11 @@ import { buildMetricsService } from './services/shared/buildMetricsService'; import { createVersionMiddleware, versionRegistry } from './services/shared/apiVersioning'; import { createLoggerFor } from './services/shared/logging'; import { createTracerFromEnv, type Tracer } from './services/shared/tracing'; +import { createScheduledReportService } from './services/notification/scheduledReportService'; +import { createScheduledReportRouter } from './services/notification/scheduledReportRouter'; +import { createScheduledReportPoller } from './services/notification/jobs/scheduledReportJob'; +import { createEmailProviderFromEnv } from './services/notification/emailProvider'; +import type { ReportAnalyticsSnapshot } from './services/notification/scheduledReportService'; import { createRequestObservability } from './shared/middleware/requestObservability'; export interface StartServerOptions { @@ -450,6 +455,49 @@ export async function startServer(options: StartServerOptions = {}): Promise => { + const now = new Date(); + const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + return { + merchantId, + mrr: 0, + arr: 0, + mrrGrowthRate: 0, + activeSubscribers: 0, + newSubscribersThisPeriod: 0, + churned: 0, + grossChurnRate: 0, + netChurnRate: 0, + arpu: 0, + ltv: 0, + currencySymbol: '$', + periodStart: monthStart.toISOString(), + periodEnd: now.toISOString(), + }; + }; + + const scheduledReportRouter = createScheduledReportRouter( + scheduledReportService, + reportSnapshotFactory + ); + + const reportPoller = createScheduledReportPoller( + scheduledReportService, + reportSnapshotFactory + ); + // Seed a default permissive CORS policy for the server's own tenant. // In production, policies should be loaded from the database per-tenant. upsertPolicy('default', { @@ -1968,6 +2016,14 @@ export async function startServer(options: StartServerOptions = {}): Promise((resolve, reject) => { @@ -2120,6 +2177,14 @@ export async function startServer(options: StartServerOptions = {}): Promise = {}): ReportAnalyticsSnapshot { + return { + merchantId: 'merch_1', + merchantName: 'Acme Corp', + mrr: 10_000, + arr: 120_000, + mrrGrowthRate: 0.05, + activeSubscribers: 250, + newSubscribersThisPeriod: 12, + churned: 3, + grossChurnRate: 0.012, + netChurnRate: 0.008, + arpu: 40, + ltv: 480, + currencySymbol: '$', + periodStart: '2026-03-01T00:00:00Z', + periodEnd: '2026-03-15T00:00:00Z', + ...overrides, + }; +} + +function makeInput(overrides: Partial = {}): CreateScheduledReportInput { + return { + merchantId: 'merch_1', + name: 'Weekly Summary', + reportType: 'analytics_summary', + frequency: 'weekly', + recipients: ['alice@example.com'], + ...overrides, + }; +} + +let emailsSent: EmailMessage[]; + +function makeEmailProvider(alwaysSucceed = true): EmailProvider { + return { + providerName: 'stub', + send: async (msg: EmailMessage): Promise => { + emailsSent.push(msg); + if (!alwaysSucceed && emailsSent.length === 2) { + return { success: false, provider: 'stub', error: 'Mailbox full' }; + } + return { success: true, provider: 'stub', messageId: `mid_${emailsSent.length}` }; + }, + }; +} + +function makeService(opts: { succeed?: boolean; store?: InMemoryReportStore } = {}): ScheduledReportService { + return createScheduledReportService({ + emailProvider: makeEmailProvider(opts.succeed ?? true), + fromAddress: 'reports@subtrackr.io', + store: opts.store, + now: () => NOW, + }); +} + +// ─── nextSendAt ─────────────────────────────────────────────────────────────── + +describe('nextSendAt', () => { + it('adds 1 day for daily', () => { + const next = nextSendAt(new Date('2026-03-15T09:30:00Z'), 'daily'); + expect(next.toISOString()).toBe('2026-03-16T09:00:00.000Z'); + }); + + it('adds 7 days for weekly', () => { + const next = nextSendAt(new Date('2026-03-15T09:30:00Z'), 'weekly'); + expect(next.toISOString()).toBe('2026-03-22T09:00:00.000Z'); + }); + + it('adds 1 month for monthly', () => { + const next = nextSendAt(new Date('2026-03-15T09:30:00Z'), 'monthly'); + expect(next.toISOString()).toBe('2026-04-15T09:00:00.000Z'); + }); + + it('normalises to start of hour (zeroes minutes/seconds/ms)', () => { + const next = nextSendAt(new Date('2026-03-15T09:59:59.999Z'), 'daily'); + expect(next.getUTCMinutes()).toBe(0); + expect(next.getUTCSeconds()).toBe(0); + expect(next.getUTCMilliseconds()).toBe(0); + }); +}); + +// ─── InMemoryReportStore ────────────────────────────────────────────────────── + +describe('InMemoryReportStore', () => { + let store: InMemoryReportStore; + + beforeEach(() => { + store = new InMemoryReportStore(); + }); + + function stubReport(overrides: Partial = {}): ScheduledReport { + return { + id: 'sr_1', + merchantId: 'merch_1', + name: 'Test', + reportType: 'analytics_summary', + frequency: 'weekly', + recipients: ['test@example.com'], + lastSentAt: null, + nextSendAt: '2026-03-15T08:00:00.000Z', // in the past relative to NOW + enabled: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; + } + + it('save and findById round-trip', () => { + const r = stubReport(); + store.save(r); + expect(store.findById('sr_1')).toEqual(r); + }); + + it('findByMerchant filters by merchantId', () => { + store.save(stubReport({ id: 'sr_1', merchantId: 'merch_1' })); + store.save(stubReport({ id: 'sr_2', merchantId: 'merch_2' })); + expect(store.findByMerchant('merch_1')).toHaveLength(1); + expect(store.findByMerchant('merch_1')[0]?.id).toBe('sr_1'); + }); + + it('findDue returns schedules where nextSendAt <= now and enabled', () => { + store.save(stubReport({ id: 'due', nextSendAt: '2026-03-15T08:00:00.000Z', enabled: true })); + store.save(stubReport({ id: 'future', nextSendAt: '2026-03-20T00:00:00.000Z', enabled: true })); + store.save(stubReport({ id: 'disabled', nextSendAt: '2026-03-01T00:00:00.000Z', enabled: false })); + const due = store.findDue(NOW); + expect(due.map((r) => r.id)).toEqual(['due']); + }); + + it('delete removes the record', () => { + store.save(stubReport()); + expect(store.delete('sr_1')).toBe(true); + expect(store.findById('sr_1')).toBeUndefined(); + }); + + it('delete returns false for unknown id', () => { + expect(store.delete('ghost')).toBe(false); + }); +}); + +// ─── createSchedule ─────────────────────────────────────────────────────────── + +describe('ScheduledReportService.createSchedule', () => { + beforeEach(() => { emailsSent = []; }); + + it('persists a new schedule with a future nextSendAt', () => { + const svc = makeService(); + const schedule = svc.createSchedule(makeInput()); + expect(schedule.id).toBeTruthy(); + expect(schedule.enabled).toBe(true); + expect(new Date(schedule.nextSendAt).getTime()).toBeGreaterThan(NOW.getTime()); + }); + + it('normalises recipient emails to lowercase', () => { + const svc = makeService(); + const schedule = svc.createSchedule(makeInput({ recipients: ['Alice@Example.COM'] })); + expect(schedule.recipients).toEqual(['alice@example.com']); + }); + + it('deduplicates recipients', () => { + const svc = makeService(); + const schedule = svc.createSchedule( + makeInput({ recipients: ['a@example.com', 'a@example.com', 'b@example.com'] }) + ); + expect(schedule.recipients).toHaveLength(2); + }); + + it('throws for missing merchantId', () => { + const svc = makeService(); + expect(() => svc.createSchedule(makeInput({ merchantId: '' }))).toThrow('merchantId is required'); + }); + + it('throws for empty name', () => { + const svc = makeService(); + expect(() => svc.createSchedule(makeInput({ name: ' ' }))).toThrow('name is required'); + }); + + it('throws for invalid reportType', () => { + const svc = makeService(); + expect(() => + svc.createSchedule(makeInput({ reportType: 'bad_type' as never })) + ).toThrow('Invalid reportType'); + }); + + it('throws for invalid frequency', () => { + const svc = makeService(); + expect(() => + svc.createSchedule(makeInput({ frequency: 'hourly' as never })) + ).toThrow('Invalid frequency'); + }); + + it('throws for empty recipients array', () => { + const svc = makeService(); + expect(() => svc.createSchedule(makeInput({ recipients: [] }))).toThrow('At least one recipient'); + }); + + it('throws for invalid email in recipients', () => { + const svc = makeService(); + expect(() => + svc.createSchedule(makeInput({ recipients: ['not-an-email'] })) + ).toThrow('Invalid email address'); + }); +}); + +// ─── getSchedule / listSchedules ───────────────────────────────────────────── + +describe('ScheduledReportService get / list', () => { + beforeEach(() => { emailsSent = []; }); + + it('getSchedule returns null for unknown id', () => { + const svc = makeService(); + expect(svc.getSchedule('nope')).toBeNull(); + }); + + it("listSchedules returns only the merchant's schedules", () => { + const svc = makeService(); + svc.createSchedule(makeInput({ merchantId: 'merch_1' })); + svc.createSchedule(makeInput({ merchantId: 'merch_2' })); + expect(svc.listSchedules('merch_1')).toHaveLength(1); + }); +}); + +// ─── updateSchedule ────────────────────────────────────────────────────────── + +describe('ScheduledReportService.updateSchedule', () => { + beforeEach(() => { emailsSent = []; }); + + it('updates the name', () => { + const svc = makeService(); + const { id } = svc.createSchedule(makeInput()); + const updated = svc.updateSchedule(id, { name: 'New Name' }); + expect(updated.name).toBe('New Name'); + }); + + it('disables a schedule', () => { + const svc = makeService(); + const { id } = svc.createSchedule(makeInput()); + const updated = svc.updateSchedule(id, { enabled: false }); + expect(updated.enabled).toBe(false); + }); + + it('recomputes nextSendAt when frequency changes', () => { + const svc = makeService(); + const { id, nextSendAt: before } = svc.createSchedule(makeInput({ frequency: 'weekly' })); + const updated = svc.updateSchedule(id, { frequency: 'monthly' }); + expect(updated.frequency).toBe('monthly'); + expect(updated.nextSendAt).not.toBe(before); + }); + + it('throws for unknown id', () => { + const svc = makeService(); + expect(() => svc.updateSchedule('ghost', { name: 'X' })).toThrow('not found'); + }); + + it('throws for invalid reportType', () => { + const svc = makeService(); + const { id } = svc.createSchedule(makeInput()); + expect(() => svc.updateSchedule(id, { reportType: 'nope' as never })).toThrow('Invalid reportType'); + }); +}); + +// ─── deleteSchedule ─────────────────────────────────────────────────────────── + +describe('ScheduledReportService.deleteSchedule', () => { + beforeEach(() => { emailsSent = []; }); + + it('returns true when found and deleted', () => { + const svc = makeService(); + const { id } = svc.createSchedule(makeInput()); + expect(svc.deleteSchedule(id)).toBe(true); + expect(svc.getSchedule(id)).toBeNull(); + }); + + it('returns false when not found', () => { + const svc = makeService(); + expect(svc.deleteSchedule('ghost')).toBe(false); + }); +}); + +// ─── Recipient management ───────────────────────────────────────────────────── + +describe('ScheduledReportService recipient management', () => { + beforeEach(() => { emailsSent = []; }); + + it('addRecipients merges new addresses without duplicates', () => { + const svc = makeService(); + const { id } = svc.createSchedule(makeInput({ recipients: ['a@example.com'] })); + const updated = svc.addRecipients(id, ['b@example.com', 'a@example.com']); + expect(updated.recipients).toHaveLength(2); + expect(updated.recipients).toContain('b@example.com'); + }); + + it('addRecipients silently drops invalid email formats', () => { + const svc = makeService(); + const { id } = svc.createSchedule(makeInput({ recipients: ['a@example.com'] })); + const updated = svc.addRecipients(id, ['not-valid', 'b@example.com']); + expect(updated.recipients).not.toContain('not-valid'); + expect(updated.recipients).toContain('b@example.com'); + }); + + it('addRecipients throws for unknown schedule id', () => { + const svc = makeService(); + expect(() => svc.addRecipients('ghost', ['x@example.com'])).toThrow('not found'); + }); + + it('removeRecipients removes specified addresses', () => { + const svc = makeService(); + const { id } = svc.createSchedule( + makeInput({ recipients: ['a@example.com', 'b@example.com'] }) + ); + const updated = svc.removeRecipients(id, ['a@example.com']); + expect(updated.recipients).toEqual(['b@example.com']); + }); + + it('removeRecipients throws for unknown schedule id', () => { + const svc = makeService(); + expect(() => svc.removeRecipients('ghost', ['x@example.com'])).toThrow('not found'); + }); +}); + +// ─── buildReportEmail ──────────────────────────────────────────────────────── + +describe('ScheduledReportService.buildReportEmail', () => { + beforeEach(() => { emailsSent = []; }); + + const svc = makeService(); + + function makeSchedule(overrides: Partial = {}): ScheduledReport { + return { + id: 'sr_x', + merchantId: 'merch_1', + name: 'Test', + reportType: 'analytics_summary', + frequency: 'weekly', + recipients: ['r@example.com'], + lastSentAt: null, + nextSendAt: '2026-03-22T09:00:00.000Z', + enabled: true, + createdAt: NOW.toISOString(), + updatedAt: NOW.toISOString(), + ...overrides, + }; + } + + it('includes a non-empty subject', () => { + const { subject } = svc.buildReportEmail(makeSchedule(), makeSnapshot()); + expect(subject).toBeTruthy(); + expect(subject).toContain('SubTrackr'); + }); + + it('subject contains the merchant name', () => { + const { subject } = svc.buildReportEmail(makeSchedule(), makeSnapshot({ merchantName: 'Acme Corp' })); + expect(subject).toContain('Acme Corp'); + }); + + it('text and html are both populated', () => { + const { text, html } = svc.buildReportEmail(makeSchedule(), makeSnapshot()); + expect(text.length).toBeGreaterThan(50); + expect(html).toContain('<'); + }); + + for (const reportType of REPORT_TYPES) { + it(`renders valid content for reportType=${reportType}`, () => { + const { text, html } = svc.buildReportEmail(makeSchedule({ reportType }), makeSnapshot()); + expect(text.length).toBeGreaterThan(10); + expect(html.length).toBeGreaterThan(10); + }); + } + + it('escapes HTML special characters in merchant name', () => { + const { html } = svc.buildReportEmail( + makeSchedule(), + makeSnapshot({ merchantName: '' }) + ); + expect(html).not.toContain(' { + beforeEach(() => { emailsSent = []; }); + + it('sends one email per recipient', async () => { + const svc = makeService(); + const schedule = svc.createSchedule( + makeInput({ recipients: ['a@example.com', 'b@example.com'] }) + ); + const result = await svc.sendReport(schedule, makeSnapshot()); + expect(emailsSent).toHaveLength(2); + expect(result.succeeded).toBe(2); + expect(result.failed).toBe(0); + }); + + it('records partial failures without throwing', async () => { + const svc = makeService({ succeed: false }); + const schedule = svc.createSchedule( + makeInput({ recipients: ['a@example.com', 'b@example.com'] }) + ); + const result = await svc.sendReport(schedule, makeSnapshot()); + expect(result.succeeded).toBe(1); + expect(result.failed).toBe(1); + expect(result.errors[0]?.recipient).toBe('b@example.com'); + }); + + it('advances lastSentAt and nextSendAt after sending', async () => { + const store = new InMemoryReportStore(); + const svc = makeService({ store }); + const { id } = svc.createSchedule(makeInput()); + const before = svc.getSchedule(id)!; + await svc.sendReport(before, makeSnapshot()); + const after = svc.getSchedule(id)!; + expect(after.lastSentAt).toBe(NOW.toISOString()); + expect(new Date(after.nextSendAt).getTime()).toBeGreaterThan(NOW.getTime()); + }); + + it('sets sentAt in the result', async () => { + const svc = makeService(); + const schedule = svc.createSchedule(makeInput()); + const result = await svc.sendReport(schedule, makeSnapshot()); + expect(result.sentAt).toBe(NOW.toISOString()); + }); +}); + +// ─── runDue ─────────────────────────────────────────────────────────────────── + +describe('ScheduledReportService.runDue', () => { + beforeEach(() => { emailsSent = []; }); + + const PAST = new Date(NOW.getTime() - 60_000); // 1 minute ago + + function makeServiceWithPastSchedules(): ScheduledReportService { + return createScheduledReportService({ + emailProvider: makeEmailProvider(true), + fromAddress: 'reports@subtrackr.io', + now: () => NOW, + }); + } + + it('fires all due and enabled schedules', async () => { + const svc = makeServiceWithPastSchedules(); + // Create a schedule and manually back-date its nextSendAt so runDue picks it up. + const { id } = svc.createSchedule(makeInput({ recipients: ['a@example.com'] })); + // Mutate via updateSchedule — use a custom store to reach the record directly. + const store = new InMemoryReportStore(); + const svc2 = createScheduledReportService({ + emailProvider: makeEmailProvider(true), + fromAddress: 'reports@subtrackr.io', + store, + now: () => NOW, + }); + const s = svc2.createSchedule(makeInput({ recipients: ['a@example.com'] })); + // Back-date by writing directly to the store. + store.save({ ...s, nextSendAt: PAST.toISOString() }); + + const snapshot = makeSnapshot(); + const summary = await svc2.runDue(() => snapshot); + expect(summary.fired).toBe(1); + expect(summary.considered).toBe(1); + expect(emailsSent).toHaveLength(1); + }); + + it('skips schedules with no recipients', async () => { + const store = new InMemoryReportStore(); + const svc = createScheduledReportService({ + emailProvider: makeEmailProvider(true), + fromAddress: 'reports@subtrackr.io', + store, + now: () => NOW, + }); + const s = svc.createSchedule(makeInput({ recipients: ['a@example.com'] })); + // Remove all recipients and back-date. + store.save({ ...s, recipients: [], nextSendAt: PAST.toISOString() }); + + const summary = await svc.runDue(() => makeSnapshot()); + expect(summary.skipped).toBe(1); + expect(summary.fired).toBe(0); + expect(emailsSent).toHaveLength(0); + }); + + it('does not fire disabled schedules', async () => { + const store = new InMemoryReportStore(); + const svc = createScheduledReportService({ + emailProvider: makeEmailProvider(true), + fromAddress: 'reports@subtrackr.io', + store, + now: () => NOW, + }); + const s = svc.createSchedule(makeInput({ recipients: ['a@example.com'] })); + store.save({ ...s, enabled: false, nextSendAt: PAST.toISOString() }); + + const summary = await svc.runDue(() => makeSnapshot()); + // Disabled schedules are not returned by findDue, so considered=0. + expect(summary.considered).toBe(0); + }); + + it('catches snapshot errors and records them without stopping other schedules', async () => { + const store = new InMemoryReportStore(); + const svc = createScheduledReportService({ + emailProvider: makeEmailProvider(true), + fromAddress: 'reports@subtrackr.io', + store, + now: () => NOW, + }); + const s1 = svc.createSchedule(makeInput({ merchantId: 'bad', recipients: ['a@example.com'] })); + const s2 = svc.createSchedule(makeInput({ merchantId: 'good', recipients: ['b@example.com'] })); + store.save({ ...s1, nextSendAt: PAST.toISOString() }); + store.save({ ...s2, nextSendAt: PAST.toISOString() }); + + const snapshotFactory = async (merchantId: string) => { + if (merchantId === 'bad') throw new Error('analytics unavailable'); + return makeSnapshot({ merchantId }); + }; + + const summary = await svc.runDue(snapshotFactory); + expect(summary.considered).toBe(2); + // One errored (recorded as a failed result), one succeeded. + expect(summary.results).toHaveLength(2); + const badResult = summary.results.find((r) => r.reportId === s1.id); + expect(badResult?.failed).toBeGreaterThan(0); + }); + + it('returns an empty summary when nothing is due', async () => { + const svc = makeService(); + const summary = await svc.runDue(() => makeSnapshot()); + expect(summary.considered).toBe(0); + expect(summary.fired).toBe(0); + }); +}); diff --git a/backend/services/notification/jobs/scheduledReportJob.ts b/backend/services/notification/jobs/scheduledReportJob.ts new file mode 100644 index 00000000..86bcaab6 --- /dev/null +++ b/backend/services/notification/jobs/scheduledReportJob.ts @@ -0,0 +1,172 @@ +/** + * scheduledReportJob + * + * Wires ScheduledReportService into the JobScheduler so that due report + * schedules are fired automatically on a configurable cadence. + * + * Design notes + * ──────────── + * The job only triggers `service.runDue()`; all scheduling logic (which + * schedules are due, what to send) lives entirely in the service. This keeps + * the job file thin and the service independently testable. + * + * The `snapshotFactory` is injected so callers can plug in the live + * SubscriptionAnalyticsService or a test stub. + * + * Registration + * ──────────── + * Call `registerScheduledReportJob(scheduler, service, snapshotFactory)` once + * at server boot (see server.ts). The job is registered under the name + * `'scheduled-reports:run-due'` so it can be manually triggered via + * `scheduler.fireNow('scheduled-reports:run-due')` in maintenance scripts. + */ + +import type { JobScheduler } from '../../shared/queue/jobScheduler'; +import type { + ScheduledReportService, + ReportAnalyticsSnapshot, + RunDueSummary, +} from '../scheduledReportService'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** Factory that returns an analytics snapshot for a given merchant. */ +export type SnapshotFactory = ( + merchantId: string +) => ReportAnalyticsSnapshot | Promise; + +export interface ScheduledReportJobOptions { + /** + * 6-field cron expression for the polling tick. + * Defaults to every 15 minutes: '0 0/15 * * * *' + */ + cron?: string; + /** Skip the tick if the previous one is still running. Default: true. */ + skipIfRunning?: boolean; +} + +export interface ScheduledReportJobHandle { + /** Job name used with the scheduler — useful for fireNow() calls. */ + readonly jobName: string; + /** Manually trigger a run-due pass — useful in tests and maintenance scripts. */ + runNow(): Promise; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +export const SCHEDULED_REPORT_JOB_NAME = 'scheduled-reports:run-due'; +/** Poll every 15 minutes; fine-grained enough without hammering the DB. */ +export const DEFAULT_REPORT_JOB_CRON = '0 0/15 * * * *'; + +// ─── Registration helper ────────────────────────────────────────────────────── + +/** + * Register the scheduled-report job with a JobScheduler. + * + * The enqueued payload is `{ triggeredAt: string }` — lightweight so the + * WeightedFairQueue is not loaded with large analytics objects. The actual + * work is done inside the queue handler (see `createScheduledReportHandler`). + * + * Returns a handle with `runNow()` for ad-hoc manual invocations. + */ +export function registerScheduledReportJob( + scheduler: JobScheduler, + service: ScheduledReportService, + snapshotFactory: SnapshotFactory, + options: ScheduledReportJobOptions = {} +): ScheduledReportJobHandle { + const cron = options.cron ?? DEFAULT_REPORT_JOB_CRON; + const skipIfRunning = options.skipIfRunning ?? true; + + scheduler.register<{ triggeredAt: string }>({ + name: SCHEDULED_REPORT_JOB_NAME, + cron, + priority: 'normal', + jobName: SCHEDULED_REPORT_JOB_NAME, + dataFactory: () => ({ triggeredAt: new Date().toISOString() }), + description: 'Fire all scheduled email reports that are currently due', + skipIfRunning, + }); + + return { + jobName: SCHEDULED_REPORT_JOB_NAME, + runNow: () => service.runDue(snapshotFactory), + }; +} + +// ─── Lightweight poller (no queue dependency) ───────────────────────────────── + +/** + * Create a self-contained setInterval-based polling loop that calls + * `service.runDue()` directly. Useful when a full WeightedFairQueue is not + * set up (lightweight deployments, integration tests). + * + * Returns `{ stop, runNow }`. + */ +export function createScheduledReportPoller( + service: ScheduledReportService, + snapshotFactory: SnapshotFactory, + intervalMs: number = 15 * 60 * 1_000 +): { stop: () => void; runNow: () => Promise } { + let running = false; + let timer: ReturnType | null = null; + + const runNow = async (): Promise => { + if (running) { + return { considered: 0, fired: 0, skipped: 0, results: [] }; + } + running = true; + try { + const summary = await service.runDue(snapshotFactory); + if (summary.fired > 0 || summary.skipped > 0) { + console.info( + `[ScheduledReportJob] fired=${summary.fired} skipped=${summary.skipped} considered=${summary.considered}` + ); + } + return summary; + } catch (err) { + console.error('[ScheduledReportJob] runDue error:', err); + return { considered: 0, fired: 0, skipped: 0, results: [] }; + } finally { + running = false; + } + }; + + timer = setInterval(() => void runNow(), intervalMs); + // Allow the process to exit cleanly even when the timer is live. + timer.unref(); + + return { + stop: () => { + if (timer) { + clearInterval(timer); + timer = null; + } + }, + runNow, + }; +} + +// ─── WeightedFairQueue handler factory ─────────────────────────────────────── + +/** + * Build a queue handler for `SCHEDULED_REPORT_JOB_NAME`. + * + * Usage in server bootstrap: + * queue.registerHandler( + * SCHEDULED_REPORT_JOB_NAME, + * createScheduledReportHandler(service, snapshotFactory) + * ); + */ +export function createScheduledReportHandler( + service: ScheduledReportService, + snapshotFactory: SnapshotFactory +): (data: { triggeredAt: string }) => Promise { + return async (data) => { + console.info(`[ScheduledReportJob] processing due reports (triggered at ${data.triggeredAt})`); + const summary = await service.runDue(snapshotFactory); + console.info( + `[ScheduledReportJob] done — fired=${summary.fired} skipped=${summary.skipped} considered=${summary.considered}` + ); + }; +} diff --git a/backend/services/notification/scheduledReportRouter.ts b/backend/services/notification/scheduledReportRouter.ts new file mode 100644 index 00000000..0580b0b3 --- /dev/null +++ b/backend/services/notification/scheduledReportRouter.ts @@ -0,0 +1,218 @@ +/** + * scheduledReportRouter + * + * Plain Node.js HTTP handler for managing scheduled email report schedules + * and their recipient lists. Mounts under `/scheduled-reports` in server.ts. + * + * Routes + * ────── + * POST /scheduled-reports Create a new schedule + * GET /scheduled-reports?merchantId=… List schedules for a merchant + * GET /scheduled-reports/:id Get a single schedule + * PATCH /scheduled-reports/:id Update a schedule + * DELETE /scheduled-reports/:id Delete a schedule + * + * POST /scheduled-reports/:id/recipients Add recipients + * DELETE /scheduled-reports/:id/recipients Remove recipients + * + * POST /scheduled-reports/:id/send-now Trigger an immediate send + * + * All endpoints return JSON. Errors are returned as `{ error: string }`. + */ + +import type http from 'node:http'; +import { URL } from 'node:url'; +import type { ScheduledReportService, ReportAnalyticsSnapshot } from '../scheduledReportService'; +import type { SnapshotFactory } from '../jobs/scheduledReportJob'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + const json = JSON.stringify(body); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(json); +} + +async function readJsonBody(req: http.IncomingMessage, maxBytes = 64_000): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + reject(new Error('Request body too large')); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); +} + +/** Extract the last path segment from a pathname like /scheduled-reports/:id */ +function extractId(pathname: string, prefix: string): string | null { + const after = pathname.slice(prefix.length); + if (!after || after === '/') return null; + const segment = after.replace(/^\//, '').split('/')[0]; + return segment || null; +} + +/** True when the segment after `/:id` equals `sub`. */ +function hasSubPath(pathname: string, prefix: string, sub: string): boolean { + const after = pathname.slice(prefix.length).replace(/^\//, ''); + const parts = after.split('/'); + return parts.length === 2 && parts[1] === sub; +} + +// ─── Router factory ─────────────────────────────────────────────────────────── + +export function createScheduledReportRouter( + service: ScheduledReportService, + snapshotFactory: SnapshotFactory +) { + const PREFIX = '/scheduled-reports'; + + /** + * Handle a request if it matches the `/scheduled-reports` prefix. + * Returns `true` when the request was handled (caller should return). + * Returns `false` to let the caller fall through to the next handler. + */ + return async function handleScheduledReportRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + pathname: string + ): Promise { + const method = req.method ?? 'GET'; + + if (!pathname.startsWith(PREFIX)) return false; + + // ── POST /scheduled-reports (create) ──────────────────────────────────── + if (pathname === PREFIX && method === 'POST') { + try { + const body = await readJsonBody(req) as Record; + const schedule = service.createSchedule({ + merchantId: String(body['merchantId'] ?? ''), + name: String(body['name'] ?? ''), + reportType: body['reportType'] as never, + frequency: body['frequency'] as never, + recipients: Array.isArray(body['recipients']) ? body['recipients'].map(String) : [], + startAt: body['startAt'] ? String(body['startAt']) : undefined, + }); + sendJson(res, 201, schedule); + } catch (err) { + sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) }); + } + return true; + } + + // ── GET /scheduled-reports?merchantId=… (list) ────────────────────────── + if (pathname === PREFIX && method === 'GET') { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const merchantId = url.searchParams.get('merchantId') ?? ''; + if (!merchantId) { + sendJson(res, 400, { error: 'merchantId query parameter is required' }); + return true; + } + sendJson(res, 200, service.listSchedules(merchantId)); + return true; + } + + const id = extractId(pathname, PREFIX); + + // ── POST /scheduled-reports/:id/recipients (add recipients) ───────────── + if (id && hasSubPath(pathname, PREFIX, 'recipients') && method === 'POST') { + try { + const body = await readJsonBody(req) as Record; + const emails = Array.isArray(body['emails']) ? body['emails'].map(String) : []; + const updated = service.addRecipients(id, emails); + sendJson(res, 200, updated); + } catch (err) { + const status = /not found/i.test(String(err)) ? 404 : 400; + sendJson(res, status, { error: err instanceof Error ? err.message : String(err) }); + } + return true; + } + + // ── DELETE /scheduled-reports/:id/recipients (remove recipients) ───────── + if (id && hasSubPath(pathname, PREFIX, 'recipients') && method === 'DELETE') { + try { + const body = await readJsonBody(req) as Record; + const emails = Array.isArray(body['emails']) ? body['emails'].map(String) : []; + const updated = service.removeRecipients(id, emails); + sendJson(res, 200, updated); + } catch (err) { + const status = /not found/i.test(String(err)) ? 404 : 400; + sendJson(res, status, { error: err instanceof Error ? err.message : String(err) }); + } + return true; + } + + // ── POST /scheduled-reports/:id/send-now (trigger immediate send) ──────── + if (id && hasSubPath(pathname, PREFIX, 'send-now') && method === 'POST') { + const schedule = service.getSchedule(id); + if (!schedule) { + sendJson(res, 404, { error: `Schedule not found: ${id}` }); + return true; + } + try { + const snapshot = await snapshotFactory(schedule.merchantId); + const result = await service.sendReport(schedule, snapshot); + sendJson(res, 200, result); + } catch (err) { + sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); + } + return true; + } + + if (!id) return false; + + // ── GET /scheduled-reports/:id (get single) ───────────────────────────── + if (method === 'GET') { + const schedule = service.getSchedule(id); + if (!schedule) { + sendJson(res, 404, { error: `Schedule not found: ${id}` }); + } else { + sendJson(res, 200, schedule); + } + return true; + } + + // ── PATCH /scheduled-reports/:id (update) ──────────────────────────────── + if (method === 'PATCH') { + try { + const body = await readJsonBody(req) as Record; + const updated = service.updateSchedule(id, { + name: body['name'] !== undefined ? String(body['name']) : undefined, + reportType: body['reportType'] as never, + frequency: body['frequency'] as never, + enabled: body['enabled'] !== undefined ? Boolean(body['enabled']) : undefined, + }); + sendJson(res, 200, updated); + } catch (err) { + const status = /not found/i.test(String(err)) ? 404 : 400; + sendJson(res, status, { error: err instanceof Error ? err.message : String(err) }); + } + return true; + } + + // ── DELETE /scheduled-reports/:id (delete) ─────────────────────────────── + if (method === 'DELETE') { + const deleted = service.deleteSchedule(id); + if (!deleted) { + sendJson(res, 404, { error: `Schedule not found: ${id}` }); + } else { + sendJson(res, 204, null); + } + return true; + } + + return false; + }; +} diff --git a/backend/services/notification/scheduledReportService.ts b/backend/services/notification/scheduledReportService.ts new file mode 100644 index 00000000..8e0491d2 --- /dev/null +++ b/backend/services/notification/scheduledReportService.ts @@ -0,0 +1,589 @@ +/** + * ScheduledReportService + * + * Manages recurring email report schedules and their recipient lists. + * A "report" is a summary email (MRR, active subscribers, churn, etc.) that + * is delivered on a configurable cadence to one or more email addresses. + * + * Responsibilities + * ───────────────── + * - CRUD for ScheduledReport records (stored in-memory; swap in a DB + * repository by passing a ReportStore implementation). + * - Recipient management: add / remove / list email addresses per schedule. + * - `buildReportEmail()` — pure render of the report email body from an + * analytics snapshot; testable without a live email provider. + * - `sendReport()` — send a single report to all active recipients. + * - `runDue()` — called by the cron job; fires all schedules whose next-send + * time has passed. + * + * Keeping delivery and persistence concerns separate from scheduling means: + * - The scheduler can call `runDue()` without knowing about cron internals. + * - Tests can exercise the build / send path without a timer. + */ + +import type { EmailProvider, EmailMessage } from './emailProvider'; + +// ─── Public types ───────────────────────────────────────────────────────────── + +/** How often the report is sent. */ +export type ReportFrequency = 'daily' | 'weekly' | 'monthly'; + +export const REPORT_FREQUENCIES: ReportFrequency[] = ['daily', 'weekly', 'monthly']; + +/** The report types that can be scheduled. */ +export type ReportType = 'analytics_summary' | 'mrr_breakdown' | 'churn_report' | 'full_report'; + +export const REPORT_TYPES: ReportType[] = [ + 'analytics_summary', + 'mrr_breakdown', + 'churn_report', + 'full_report', +]; + +export interface ScheduledReport { + id: string; + merchantId: string; + name: string; + reportType: ReportType; + frequency: ReportFrequency; + /** Recipients for this specific schedule. */ + recipients: string[]; + /** ISO-8601 timestamp of the last successful send. */ + lastSentAt: string | null; + /** ISO-8601 timestamp of when the next send is due. */ + nextSendAt: string; + /** Whether this schedule is active. Disabled schedules are skipped by runDue(). */ + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateScheduledReportInput { + merchantId: string; + name: string; + reportType: ReportType; + frequency: ReportFrequency; + recipients: string[]; + /** If omitted, the first send is scheduled from now. */ + startAt?: string; +} + +export interface UpdateScheduledReportInput { + name?: string; + reportType?: ReportType; + frequency?: ReportFrequency; + enabled?: boolean; +} + +/** Snapshot of analytics data passed into buildReportEmail(). */ +export interface ReportAnalyticsSnapshot { + merchantId: string; + merchantName?: string; + /** Monthly Recurring Revenue in the account's base currency. */ + mrr: number; + arr: number; + mrrGrowthRate: number; + activeSubscribers: number; + newSubscribersThisPeriod: number; + churned: number; + grossChurnRate: number; + netChurnRate: number; + arpu: number; + ltv: number; + /** The currency symbol, e.g. "$" */ + currencySymbol?: string; + /** ISO-8601 period start for context. */ + periodStart: string; + /** ISO-8601 period end for context. */ + periodEnd: string; +} + +export interface ReportEmailContent { + subject: string; + text: string; + html: string; +} + +export interface SendReportResult { + reportId: string; + recipientCount: number; + succeeded: number; + failed: number; + errors: Array<{ recipient: string; error: string }>; + sentAt: string; +} + +export interface RunDueSummary { + considered: number; + fired: number; + skipped: number; + results: SendReportResult[]; +} + +// ─── Persistence interface ──────────────────────────────────────────────────── + +/** + * Storage backend for ScheduledReport records. + * The built-in InMemoryReportStore is used unless a real DB store is injected. + */ +export interface ReportStore { + findById(id: string): ScheduledReport | undefined; + findByMerchant(merchantId: string): ScheduledReport[]; + findDue(now: Date): ScheduledReport[]; + save(report: ScheduledReport): void; + delete(id: string): boolean; + all(): ScheduledReport[]; +} + +export class InMemoryReportStore implements ReportStore { + private readonly records = new Map(); + + findById(id: string): ScheduledReport | undefined { + return this.records.get(id); + } + + findByMerchant(merchantId: string): ScheduledReport[] { + return [...this.records.values()].filter((r) => r.merchantId === merchantId); + } + + findDue(now: Date): ScheduledReport[] { + return [...this.records.values()].filter( + (r) => r.enabled && new Date(r.nextSendAt) <= now + ); + } + + save(report: ScheduledReport): void { + this.records.set(report.id, { ...report }); + } + + delete(id: string): boolean { + return this.records.delete(id); + } + + all(): ScheduledReport[] { + return [...this.records.values()]; + } + + /** Test helper — wipe all records. */ + clear(): void { + this.records.clear(); + } +} + +// ─── Dependencies ───────────────────────────────────────────────────────────── + +export interface ScheduledReportServiceDeps { + emailProvider: EmailProvider; + fromAddress: string; + store?: ReportStore; + /** Injectable clock for deterministic tests. */ + now?: () => Date; +} + +// ─── Pure helpers ───────────────────────────────────────────────────────────── + +/** Compute the next send time from a reference date and frequency. */ +export function nextSendAt(from: Date, frequency: ReportFrequency): Date { + const next = new Date(from); + switch (frequency) { + case 'daily': + next.setUTCDate(next.getUTCDate() + 1); + break; + case 'weekly': + next.setUTCDate(next.getUTCDate() + 7); + break; + case 'monthly': + next.setUTCMonth(next.getUTCMonth() + 1); + break; + } + // Normalise to the start of the hour so small clock drift does not cause + // a schedule to be skipped for a whole period. + next.setUTCMinutes(0, 0, 0); + return next; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function fmt(n: number, decimals = 2): string { + return n.toFixed(decimals); +} + +function pct(rate: number): string { + return `${(rate * 100).toFixed(2)}%`; +} + +function currency(n: number, symbol: string): string { + return `${symbol}${fmt(n)}`; +} + +// ─── Service ────────────────────────────────────────────────────────────────── + +export class ScheduledReportService { + private readonly emailProvider: EmailProvider; + private readonly fromAddress: string; + private readonly store: ReportStore; + private readonly now: () => Date; + private idCounter = 0; + + constructor(deps: ScheduledReportServiceDeps) { + this.emailProvider = deps.emailProvider; + this.fromAddress = deps.fromAddress; + this.store = deps.store ?? new InMemoryReportStore(); + this.now = deps.now ?? (() => new Date()); + } + + // ── Schedule management ──────────────────────────────────────────────────── + + /** Create a new schedule. Validates inputs and returns the persisted record. */ + createSchedule(input: CreateScheduledReportInput): ScheduledReport { + validateCreateInput(input); + const now = this.now(); + const startFrom = input.startAt ? new Date(input.startAt) : now; + const id = this.generateId(); + const report: ScheduledReport = { + id, + merchantId: input.merchantId, + name: input.name.trim(), + reportType: input.reportType, + frequency: input.frequency, + recipients: [...new Set(input.recipients.map((r) => r.toLowerCase().trim()))], + lastSentAt: null, + nextSendAt: nextSendAt(startFrom, input.frequency).toISOString(), + enabled: true, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + this.store.save(report); + return report; + } + + /** Retrieve a single schedule; returns null when not found. */ + getSchedule(id: string): ScheduledReport | null { + return this.store.findById(id) ?? null; + } + + /** List all schedules for a merchant. */ + listSchedules(merchantId: string): ScheduledReport[] { + return this.store.findByMerchant(merchantId); + } + + /** Update mutable fields of a schedule. Returns the updated record. */ + updateSchedule(id: string, input: UpdateScheduledReportInput): ScheduledReport { + const existing = this.store.findById(id); + if (!existing) throw new Error(`Schedule not found: ${id}`); + if (input.reportType !== undefined && !REPORT_TYPES.includes(input.reportType)) { + throw new Error(`Invalid reportType: ${input.reportType}`); + } + if (input.frequency !== undefined && !REPORT_FREQUENCIES.includes(input.frequency)) { + throw new Error(`Invalid frequency: ${input.frequency}`); + } + const now = this.now(); + const updated: ScheduledReport = { + ...existing, + name: input.name !== undefined ? input.name.trim() : existing.name, + reportType: input.reportType ?? existing.reportType, + frequency: input.frequency ?? existing.frequency, + enabled: input.enabled !== undefined ? input.enabled : existing.enabled, + updatedAt: now.toISOString(), + }; + // If the frequency changed, recompute the next send time. + if (input.frequency !== undefined && input.frequency !== existing.frequency) { + const base = existing.lastSentAt ? new Date(existing.lastSentAt) : now; + updated.nextSendAt = nextSendAt(base, input.frequency).toISOString(); + } + this.store.save(updated); + return updated; + } + + /** Soft-delete a schedule by ID. Returns true when found and removed. */ + deleteSchedule(id: string): boolean { + return this.store.delete(id); + } + + // ── Recipient management ─────────────────────────────────────────────────── + + /** Add one or more email addresses to a schedule's recipient list. */ + addRecipients(id: string, emails: string[]): ScheduledReport { + const existing = this.store.findById(id); + if (!existing) throw new Error(`Schedule not found: ${id}`); + const normalised = emails.map((e) => e.toLowerCase().trim()).filter(isValidEmail); + const merged = [...new Set([...existing.recipients, ...normalised])]; + const updated: ScheduledReport = { + ...existing, + recipients: merged, + updatedAt: this.now().toISOString(), + }; + this.store.save(updated); + return updated; + } + + /** Remove one or more email addresses from a schedule's recipient list. */ + removeRecipients(id: string, emails: string[]): ScheduledReport { + const existing = this.store.findById(id); + if (!existing) throw new Error(`Schedule not found: ${id}`); + const toRemove = new Set(emails.map((e) => e.toLowerCase().trim())); + const updated: ScheduledReport = { + ...existing, + recipients: existing.recipients.filter((r) => !toRemove.has(r)), + updatedAt: this.now().toISOString(), + }; + this.store.save(updated); + return updated; + } + + // ── Email content ────────────────────────────────────────────────────────── + + /** + * Build the plain-text and HTML email body for a report snapshot. + * Pure: no side effects, no network calls. Safe to call in tests without + * a real email provider. + */ + buildReportEmail( + report: ScheduledReport, + snapshot: ReportAnalyticsSnapshot + ): ReportEmailContent { + const sym = snapshot.currencySymbol ?? '$'; + const name = snapshot.merchantName ?? snapshot.merchantId; + const freq = report.frequency.charAt(0).toUpperCase() + report.frequency.slice(1); + const subject = `[SubTrackr] ${freq} ${titleFor(report.reportType)} — ${name}`; + + const sections = buildSections(report.reportType, snapshot, sym); + + const textLines: string[] = [ + subject, + `Period: ${snapshot.periodStart} – ${snapshot.periodEnd}`, + '', + ]; + const htmlParts: string[] = [ + `

${escapeHtml(subject)}

`, + `

Period: ${escapeHtml(snapshot.periodStart)} – ${escapeHtml(snapshot.periodEnd)}

`, + ]; + + for (const [heading, rows] of sections) { + textLines.push(heading.toUpperCase(), '─'.repeat(40)); + htmlParts.push(`

${escapeHtml(heading)}

`); + for (const [label, value] of rows) { + textLines.push(` ${label}: ${value}`); + htmlParts.push( + `` + + `` + ); + } + textLines.push(''); + htmlParts.push('
${escapeHtml(label)}${escapeHtml(value)}
'); + } + + textLines.push('—', 'SubTrackr Reporting'); + htmlParts.push('

— SubTrackr Reporting

'); + + return { + subject, + text: textLines.join('\n'), + html: htmlParts.join(''), + }; + } + + /** + * Send a report to all active recipients of a single schedule. + * Partial failures are collected and returned; one bad address does not + * block the rest. + */ + async sendReport( + report: ScheduledReport, + snapshot: ReportAnalyticsSnapshot + ): Promise { + const { subject, text, html } = this.buildReportEmail(report, snapshot); + const now = this.now(); + const errors: SendReportResult['errors'] = []; + let succeeded = 0; + + for (const recipient of report.recipients) { + const message: EmailMessage = { + to: { email: recipient }, + from: { email: this.fromAddress }, + subject, + text, + html, + tags: ['scheduled-report', `report-type:${report.reportType}`, `frequency:${report.frequency}`], + }; + try { + const result = await this.emailProvider.send(message); + if (result.success) { + succeeded += 1; + } else { + errors.push({ recipient, error: result.error ?? 'Unknown delivery error' }); + } + } catch (err) { + errors.push({ recipient, error: err instanceof Error ? err.message : String(err) }); + } + } + + // Advance the schedule's timestamps whether delivery succeeded or not, so + // a broken address does not cause an infinite retry loop. + const advanced: ScheduledReport = { + ...report, + lastSentAt: now.toISOString(), + nextSendAt: nextSendAt(now, report.frequency).toISOString(), + updatedAt: now.toISOString(), + }; + this.store.save(advanced); + + return { + reportId: report.id, + recipientCount: report.recipients.length, + succeeded, + failed: errors.length, + errors, + sentAt: now.toISOString(), + }; + } + + /** + * Run all schedules that are currently due. + * + * `snapshotFor` must be provided by the caller so the service stays + * decoupled from the analytics layer — pass a function that calls + * `SubscriptionAnalyticsService.compute()` or returns a stub in tests. + */ + async runDue( + snapshotFor: (merchantId: string) => ReportAnalyticsSnapshot | Promise + ): Promise { + const now = this.now(); + const due = this.store.findDue(now); + const results: SendReportResult[] = []; + let skipped = 0; + + for (const report of due) { + if (report.recipients.length === 0) { + skipped += 1; + continue; + } + try { + const snapshot = await snapshotFor(report.merchantId); + const result = await this.sendReport(report, snapshot); + results.push(result); + } catch (err) { + // Record a synthetic result so callers can see what failed. + results.push({ + reportId: report.id, + recipientCount: report.recipients.length, + succeeded: 0, + failed: report.recipients.length, + errors: [{ recipient: '(all)', error: err instanceof Error ? err.message : String(err) }], + sentAt: now.toISOString(), + }); + } + } + + return { + considered: due.length, + fired: results.length, + skipped, + results, + }; + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + private generateId(): string { + return `sr_${Date.now()}_${++this.idCounter}`; + } +} + +// ─── Local helpers ──────────────────────────────────────────────────────────── + +function titleFor(type: ReportType): string { + switch (type) { + case 'analytics_summary': return 'Analytics Summary'; + case 'mrr_breakdown': return 'MRR Breakdown'; + case 'churn_report': return 'Churn Report'; + case 'full_report': return 'Full Report'; + } +} + +/** + * Build the labelled sections for each report type. + * Returns an array of [sectionHeading, [[label, value], …]] tuples. + */ +function buildSections( + type: ReportType, + s: ReportAnalyticsSnapshot, + sym: string +): Array<[string, Array<[string, string]>]> { + const revenueRows: Array<[string, string]> = [ + ['MRR', currency(s.mrr, sym)], + ['ARR', currency(s.arr, sym)], + ['MRR Growth', pct(s.mrrGrowthRate)], + ['ARPU', currency(s.arpu, sym)], + ['LTV', currency(s.ltv, sym)], + ]; + + const subscriberRows: Array<[string, string]> = [ + ['Active Subscribers', String(s.activeSubscribers)], + ['New This Period', String(s.newSubscribersThisPeriod)], + ['Churned', String(s.churned)], + ]; + + const churnRows: Array<[string, string]> = [ + ['Gross Churn Rate', pct(s.grossChurnRate)], + ['Net Churn Rate', pct(s.netChurnRate)], + ]; + + switch (type) { + case 'analytics_summary': + return [ + ['Revenue', revenueRows.slice(0, 3)], + ['Subscribers', subscriberRows], + ]; + case 'mrr_breakdown': + return [ + ['MRR Metrics', revenueRows], + ]; + case 'churn_report': + return [ + ['Subscribers', subscriberRows], + ['Churn', churnRows], + ]; + case 'full_report': + return [ + ['Revenue', revenueRows], + ['Subscribers', subscriberRows], + ['Churn', churnRows], + ]; + } +} + +function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +function validateCreateInput(input: CreateScheduledReportInput): void { + if (!input.merchantId?.trim()) throw new Error('merchantId is required'); + if (!input.name?.trim()) throw new Error('name is required'); + if (!REPORT_TYPES.includes(input.reportType)) { + throw new Error(`Invalid reportType: ${input.reportType}. Must be one of: ${REPORT_TYPES.join(', ')}`); + } + if (!REPORT_FREQUENCIES.includes(input.frequency)) { + throw new Error(`Invalid frequency: ${input.frequency}. Must be one of: ${REPORT_FREQUENCIES.join(', ')}`); + } + if (!Array.isArray(input.recipients) || input.recipients.length === 0) { + throw new Error('At least one recipient is required'); + } + for (const r of input.recipients) { + if (!isValidEmail(r.trim())) throw new Error(`Invalid email address: ${r}`); + } +} + +// ─── Singleton factory ──────────────────────────────────────────────────────── + +export function createScheduledReportService( + deps: ScheduledReportServiceDeps +): ScheduledReportService { + return new ScheduledReportService(deps); +}