@objectstack/plugin-reports 16.1.0 → 17.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,628 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type {
4
- IReportService,
5
- SavedReport,
6
- ReportSchedule,
7
- ReportQuery,
8
- ReportRunResult,
9
- ReportFormat,
10
- SaveReportInput,
11
- ScheduleReportInput,
12
- SharingExecutionContext,
13
- } from '@objectstack/spec/contracts';
14
- import { Cron } from 'croner';
15
-
16
- /**
17
- * Narrow engine surface — keeps the service testable without booting
18
- * a real ObjectQL kernel.
19
- */
20
- export interface ReportEngine {
21
- find(object: string, options?: any): Promise<any[]>;
22
- findOne?(object: string, options?: any): Promise<any>;
23
- insert(object: string, data: any, options?: any): Promise<any>;
24
- update(object: string, idOrData: any, dataOrOptions?: any, options?: any): Promise<any>;
25
- delete(object: string, options?: any): Promise<any>;
26
- }
27
-
28
- /**
29
- * Minimum email surface — implementations may pass the full
30
- * `IEmailService` instance straight through.
31
- */
32
- export interface ReportEmail {
33
- send(input: {
34
- to: string | string[];
35
- subject: string;
36
- text?: string;
37
- html?: string;
38
- attachments?: Array<{ filename: string; content: string; contentType?: string }>;
39
- relatedObject?: string;
40
- relatedId?: string;
41
- }): Promise<{ status: 'sent' | 'queued' | 'failed' }>;
42
- }
43
-
44
- /** Stamped only in tests / specialised callers to make `now` deterministic. */
45
- export interface ReportClock { now(): Date }
46
-
47
- const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
48
-
49
- const DEFAULT_FORMAT: ReportFormat = 'csv';
50
- const DEFAULT_INTERVAL_MIN = 1440;
51
- const DEFAULT_LIMIT = 1000;
52
-
53
- function uid(prefix: string): string {
54
- const g: any = globalThis as any;
55
- if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
56
- return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
57
- }
58
-
59
- function parseQuery(raw: unknown): ReportQuery {
60
- if (!raw) return {};
61
- if (typeof raw === 'string') {
62
- try { return JSON.parse(raw) as ReportQuery; }
63
- catch { return {}; }
64
- }
65
- if (typeof raw === 'object') return raw as ReportQuery;
66
- return {};
67
- }
68
-
69
- function rowFromSaved(row: any): SavedReport {
70
- return {
71
- id: String(row.id),
72
- name: String(row.name ?? ''),
73
- description: row.description ?? undefined,
74
- object_name: String(row.object_name ?? ''),
75
- query: parseQuery(row.query_json),
76
- format: (row.format as ReportFormat) ?? DEFAULT_FORMAT,
77
- owner_id: row.owner_id ?? undefined,
78
- last_run_at: row.last_run_at ?? undefined,
79
- last_row_count: row.last_row_count ?? undefined,
80
- created_at: row.created_at ?? undefined,
81
- updated_at: row.updated_at ?? undefined,
82
- };
83
- }
84
-
85
- function rowFromSchedule(row: any): ReportSchedule {
86
- return {
87
- id: String(row.id),
88
- report_id: String(row.report_id),
89
- name: row.name ?? undefined,
90
- interval_minutes: row.interval_minutes ?? undefined,
91
- cron_expression: row.cron_expression ?? undefined,
92
- timezone: row.timezone ?? undefined,
93
- active: row.active !== false,
94
- recipients: String(row.recipients ?? ''),
95
- format: row.format ?? undefined,
96
- subject_template: row.subject_template ?? undefined,
97
- owner_id: row.owner_id ?? undefined,
98
- next_run_at: row.next_run_at ?? undefined,
99
- last_sent_at: row.last_sent_at ?? undefined,
100
- last_status: row.last_status ?? undefined,
101
- last_error: row.last_error ?? undefined,
102
- };
103
- }
104
-
105
- // ─── Rendering ─────────────────────────────────────────────────────
106
-
107
- function escapeCsvCell(v: unknown): string {
108
- if (v == null) return '';
109
- const s = typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));
110
- if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
111
- return s;
112
- }
113
-
114
- function pickFields(rows: any[], explicit?: string[]): string[] {
115
- if (explicit && explicit.length > 0) return explicit;
116
- const seen = new Set<string>();
117
- for (const r of rows.slice(0, 50)) {
118
- if (r && typeof r === 'object') for (const k of Object.keys(r)) seen.add(k);
119
- }
120
- return Array.from(seen);
121
- }
122
-
123
- function renderCsv(rows: any[], fields?: string[]): string {
124
- const cols = pickFields(rows, fields);
125
- const head = cols.join(',');
126
- const body = rows.map(r => cols.map(c => escapeCsvCell(r?.[c])).join(',')).join('\r\n');
127
- return body.length > 0 ? `${head}\r\n${body}` : head;
128
- }
129
-
130
- function renderJson(rows: any[]): string {
131
- return JSON.stringify(rows, null, 2);
132
- }
133
-
134
- function escapeHtml(s: string): string {
135
- return s.replace(/[&<>"']/g, c => ({
136
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
137
- } as Record<string, string>)[c]);
138
- }
139
-
140
- function renderHtmlTable(rows: any[], fields?: string[]): string {
141
- const cols = pickFields(rows, fields);
142
- const th = cols.map(c => `<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;">${escapeHtml(c)}</th>`).join('');
143
- const trs = rows.map(r => {
144
- const tds = cols.map(c => {
145
- const v = r?.[c];
146
- const s = v == null ? '' : (typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v)));
147
- return `<td style="padding:4px 8px;border-bottom:1px solid #eee;">${escapeHtml(s)}</td>`;
148
- }).join('');
149
- return `<tr>${tds}</tr>`;
150
- }).join('');
151
- return `<table style="border-collapse:collapse;font-family:system-ui,Arial,sans-serif;font-size:13px;">`
152
- + `<thead><tr>${th}</tr></thead><tbody>${trs}</tbody></table>`;
153
- }
154
-
155
- export function renderReport(rows: any[], format: ReportFormat, fields?: string[]): string {
156
- switch (format) {
157
- case 'json': return renderJson(rows);
158
- case 'html_table': return renderHtmlTable(rows, fields);
159
- case 'csv':
160
- default: return renderCsv(rows, fields);
161
- }
162
- }
163
-
164
- // ─── Subject templating (minimal {{var}}) ─────────────────────────
165
-
166
- function renderSubject(template: string | undefined, vars: Record<string, string>): string {
167
- const tpl = template ?? '{{name}} — {{date}}';
168
- return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, k) => vars[String(k)] ?? '');
169
- }
170
-
171
- // ─── Service ──────────────────────────────────────────────────────
172
-
173
- /**
174
- * Resolves a saved report's owner (`owner_id`) into a real, RLS-bearing
175
- * `ExecutionContext` so a **scheduled** report executes under the owner's
176
- * authority — the same rows the owner would see interactively — instead of
177
- * bypassing RLS with a system context. Returns `null` when the owner cannot
178
- * be resolved (unknown/disabled user), in which case the scheduler fails the
179
- * run closed rather than running elevated (#2849 / #2980). Supplying this
180
- * resolver is the reports-surface consumer of ADR-0073's user-less identity
181
- * resolution.
182
- */
183
- export type OwnerContextResolver = (
184
- ownerId: string,
185
- ) => Promise<SharingExecutionContext | null>;
186
-
187
- export interface ReportServiceOptions {
188
- engine: ReportEngine;
189
- email?: ReportEmail;
190
- clock?: ReportClock;
191
- logger?: { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; error?: (msg: any, ...rest: any[]) => void };
192
- /** Cap rows per report to protect both DB and email size. */
193
- maxRows?: number;
194
- /**
195
- * Resolves a report owner into an RLS-bearing context for scheduled runs
196
- * (see {@link OwnerContextResolver}). When omitted, scheduled reports fail
197
- * closed instead of running with RLS bypassed (#2980).
198
- */
199
- resolveOwnerContext?: OwnerContextResolver;
200
- }
201
-
202
- export class ReportService implements IReportService {
203
- private readonly engine: ReportEngine;
204
- private readonly email?: ReportEmail;
205
- private readonly clock: ReportClock;
206
- private readonly logger: NonNullable<ReportServiceOptions['logger']>;
207
- private readonly maxRows: number;
208
- private readonly resolveOwnerContext?: OwnerContextResolver;
209
-
210
- constructor(opts: ReportServiceOptions) {
211
- this.engine = opts.engine;
212
- this.email = opts.email;
213
- this.clock = opts.clock ?? { now: () => new Date() };
214
- this.logger = opts.logger ?? {};
215
- this.maxRows = Math.max(1, opts.maxRows ?? 5000);
216
- this.resolveOwnerContext = opts.resolveOwnerContext;
217
- }
218
-
219
- // ── Access control ─────────────────────────────────────────────
220
-
221
- /**
222
- * Authorization for a saved-report row. `sys_saved_report` is a
223
- * protection-locked system object, so its rows are *read* with
224
- * `SYSTEM_CTX`; the caller's right to see/mutate a specific report is
225
- * enforced HERE, by owner match, not by the metadata read's own RLS —
226
- * otherwise any authenticated caller could read/delete/overwrite any
227
- * report by id (#2980). An explicit elevated context (`isSystem`) — the
228
- * scheduler / server tooling — sees everything.
229
- */
230
- private canAccessReport(row: { owner_id?: unknown } | null | undefined, context: SharingExecutionContext | undefined): boolean {
231
- if (!row) return false;
232
- if (context?.isSystem) return true;
233
- const userId = context?.userId;
234
- return !!userId && row.owner_id === userId;
235
- }
236
-
237
- /** Raw metadata read of a saved report by id (no authz — callers gate). */
238
- private async loadReportRow(reportId: string): Promise<any | null> {
239
- const rows = await this.engine.find('sys_saved_report', {
240
- filter: { id: reportId }, limit: 1, context: SYSTEM_CTX,
241
- });
242
- return Array.isArray(rows) && rows[0] ? rows[0] : null;
243
- }
244
-
245
- // ── Report CRUD ────────────────────────────────────────────────
246
-
247
- async saveReport(input: SaveReportInput, context: SharingExecutionContext): Promise<SavedReport> {
248
- if (!input.name) throw new Error('VALIDATION_FAILED: name is required');
249
- if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
250
- if (!input.query) throw new Error('VALIDATION_FAILED: query is required');
251
-
252
- const now = this.clock.now().toISOString();
253
- // A non-system caller always owns what they create — a caller-supplied
254
- // ownerId cannot assign the report to someone else (#2980). Only an
255
- // explicit elevated context (server tooling / import) may set it.
256
- const ownerId = context.isSystem ? (input.ownerId ?? context.userId ?? null) : (context.userId ?? null);
257
- const payload: any = {
258
- name: input.name,
259
- description: input.description ?? null,
260
- object_name: input.object,
261
- query_json: JSON.stringify(input.query ?? {}),
262
- format: input.format ?? DEFAULT_FORMAT,
263
- owner_id: ownerId,
264
- updated_at: now,
265
- };
266
-
267
- if (input.id) {
268
- const existing = await this.loadReportRow(input.id);
269
- if (existing) {
270
- // An update to an existing report is a mutation — a caller may only
271
- // overwrite a report they own (#2980). Not-found for others so the
272
- // response doesn't leak that the id exists.
273
- if (!this.canAccessReport(existing, context)) {
274
- throw new Error(`REPORT_NOT_FOUND: ${input.id}`);
275
- }
276
- // Never let a non-system caller reassign ownership away from the row.
277
- if (!context.isSystem) payload.owner_id = existing.owner_id ?? payload.owner_id;
278
- await this.engine.update('sys_saved_report', { id: input.id, ...payload }, { context: SYSTEM_CTX });
279
- return rowFromSaved({ ...existing, ...payload, id: input.id });
280
- }
281
- }
282
-
283
- const id = input.id ?? uid('rpt');
284
- const row = { id, ...payload, created_at: now };
285
- await this.engine.insert('sys_saved_report', row, { context: SYSTEM_CTX });
286
- return rowFromSaved(row);
287
- }
288
-
289
- async listReports(
290
- filter: { object?: string; ownerId?: string } | undefined,
291
- context: SharingExecutionContext,
292
- ): Promise<SavedReport[]> {
293
- const f: any = {};
294
- if (filter?.object) f.object_name = filter.object;
295
- // Owner scoping (#2980): a non-system caller sees ONLY their own reports —
296
- // a caller-supplied ownerId can never widen past their own id. A caller
297
- // with no identity sees nothing (fail closed). System/tooling sees all,
298
- // honouring an explicit ownerId narrow.
299
- if (context?.isSystem) {
300
- if (filter?.ownerId) f.owner_id = filter.ownerId;
301
- } else {
302
- if (!context?.userId) return [];
303
- if (filter?.ownerId && filter.ownerId !== context.userId) return [];
304
- f.owner_id = context.userId;
305
- }
306
- const rows = await this.engine.find('sys_saved_report', {
307
- filter: f, limit: 500, orderBy: [{ field: 'updated_at', order: 'desc' }], context: SYSTEM_CTX,
308
- });
309
- return Array.isArray(rows) ? rows.map(rowFromSaved) : [];
310
- }
311
-
312
- async getReport(reportId: string, context: SharingExecutionContext): Promise<SavedReport | null> {
313
- const row = await this.loadReportRow(reportId);
314
- // Unauthorized reads are indistinguishable from a genuine miss (#2980).
315
- if (!this.canAccessReport(row, context)) return null;
316
- return rowFromSaved(row);
317
- }
318
-
319
- async deleteReport(reportId: string, context: SharingExecutionContext): Promise<void> {
320
- if (!reportId) throw new Error('VALIDATION_FAILED: reportId is required');
321
- const row = await this.loadReportRow(reportId);
322
- if (!row) return; // idempotent — nothing to drop
323
- // A caller may only delete a report they own (#2980); others get a
324
- // not-found so the delete neither fires nor reveals the report's existence.
325
- if (!this.canAccessReport(row, context)) {
326
- throw new Error(`REPORT_NOT_FOUND: ${reportId}`);
327
- }
328
- // Cascade — drop attached schedules first.
329
- const schedules = await this.engine.find('sys_report_schedule', {
330
- filter: { report_id: reportId }, limit: 500, context: SYSTEM_CTX,
331
- });
332
- for (const s of (schedules ?? [])) {
333
- await this.engine.delete('sys_report_schedule', { where: { id: (s as any).id }, context: SYSTEM_CTX });
334
- }
335
- await this.engine.delete('sys_saved_report', { where: { id: reportId }, context: SYSTEM_CTX });
336
- }
337
-
338
- // ── Execution ───────────────────────────────────────────────────
339
-
340
- async run(reportId: string, context: SharingExecutionContext): Promise<ReportRunResult> {
341
- const report = await this.getReport(reportId, context);
342
- if (!report) throw new Error(`REPORT_NOT_FOUND: ${reportId}`);
343
- return this.executeReport(report, context);
344
- }
345
-
346
- async runAdHoc(input: SaveReportInput, context: SharingExecutionContext): Promise<ReportRunResult> {
347
- if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
348
- if (!input.query) throw new Error('VALIDATION_FAILED: query is required');
349
- const adhoc: SavedReport = {
350
- id: '__adhoc__',
351
- name: input.name ?? 'Ad-hoc report',
352
- object_name: input.object,
353
- query: input.query,
354
- format: input.format ?? DEFAULT_FORMAT,
355
- };
356
- return this.executeReport(adhoc, context, /* stamp */ false);
357
- }
358
-
359
- private async executeReport(
360
- report: SavedReport,
361
- context: SharingExecutionContext,
362
- stamp = true,
363
- ): Promise<ReportRunResult> {
364
- const q = report.query ?? {};
365
- const limit = Math.min(q.limit ?? DEFAULT_LIMIT, this.maxRows);
366
- const rows = await this.engine.find(report.object_name, {
367
- filter: q.filter,
368
- fields: q.fields,
369
- orderBy: q.orderBy,
370
- limit,
371
- // Reports execute with the caller's identity so sharing rules
372
- // (if installed) apply. Falls back to system bypass only when
373
- // the report definition was created by a system writer.
374
- context: {
375
- userId: context.userId,
376
- tenantId: context.tenantId,
377
- positions: context.positions ?? [],
378
- permissions: context.permissions ?? [],
379
- isSystem: context.isSystem ?? false,
380
- },
381
- });
382
- const list = Array.isArray(rows) ? rows : [];
383
- const body = renderReport(list, report.format, q.fields);
384
- const ranAt = this.clock.now().toISOString();
385
-
386
- if (stamp && report.id !== '__adhoc__') {
387
- try {
388
- await this.engine.update('sys_saved_report', {
389
- id: report.id,
390
- last_run_at: ranAt,
391
- last_row_count: list.length,
392
- updated_at: ranAt,
393
- }, { context: SYSTEM_CTX });
394
- } catch (err) {
395
- this.logger.warn?.('ReportService: failed to stamp last_run_at', err);
396
- }
397
- }
398
-
399
- return {
400
- reportId: report.id,
401
- rowCount: list.length,
402
- format: report.format,
403
- body,
404
- rows: list,
405
- ranAt,
406
- };
407
- }
408
-
409
- // ── Schedules ──────────────────────────────────────────────────
410
-
411
- async scheduleReport(input: ScheduleReportInput, context: SharingExecutionContext): Promise<ReportSchedule> {
412
- if (!input.reportId) throw new Error('VALIDATION_FAILED: reportId is required');
413
- if (!input.recipients || input.recipients.length === 0) {
414
- throw new Error('VALIDATION_FAILED: recipients must be a non-empty array');
415
- }
416
- const report = await this.getReport(input.reportId, context);
417
- if (!report) throw new Error(`REPORT_NOT_FOUND: ${input.reportId}`);
418
-
419
- const now = this.clock.now();
420
- const interval = input.intervalMinutes ?? DEFAULT_INTERVAL_MIN;
421
- const cron = input.cronExpression?.trim() || null;
422
- if (cron) {
423
- // Validate eagerly so an author gets a clear error at schedule time
424
- // instead of a schedule that silently falls back to interval on sweep.
425
- try {
426
- new Cron(cron, { timezone: input.timezone || 'UTC' });
427
- } catch (err) {
428
- throw new Error(`VALIDATION_FAILED: invalid cron_expression '${cron}': ${(err as Error).message}`);
429
- }
430
- }
431
- const nextRun = this.nextRunAt(
432
- { cron_expression: cron, interval_minutes: interval, timezone: input.timezone ?? 'UTC' },
433
- now,
434
- ).toISOString();
435
- const id = uid('rsch');
436
- const row: any = {
437
- id,
438
- report_id: input.reportId,
439
- name: input.name ?? null,
440
- interval_minutes: interval,
441
- cron_expression: cron,
442
- timezone: input.timezone ?? 'UTC',
443
- active: input.active !== false,
444
- recipients: input.recipients.join(','),
445
- format: input.format ?? 'html_table',
446
- subject_template: input.subjectTemplate ?? null,
447
- owner_id: input.ownerId ?? context.userId ?? null,
448
- next_run_at: nextRun,
449
- created_at: now.toISOString(),
450
- updated_at: now.toISOString(),
451
- };
452
- await this.engine.insert('sys_report_schedule', row, { context: SYSTEM_CTX });
453
- return rowFromSchedule(row);
454
- }
455
-
456
- async unscheduleReport(scheduleId: string, _context: SharingExecutionContext): Promise<void> {
457
- if (!scheduleId) throw new Error('VALIDATION_FAILED: scheduleId is required');
458
- await this.engine.delete('sys_report_schedule', { where: { id: scheduleId }, context: SYSTEM_CTX });
459
- }
460
-
461
- async listSchedules(
462
- filter: { reportId?: string } | undefined,
463
- _context: SharingExecutionContext,
464
- ): Promise<ReportSchedule[]> {
465
- const f: any = {};
466
- if (filter?.reportId) f.report_id = filter.reportId;
467
- const rows = await this.engine.find('sys_report_schedule', {
468
- filter: f, limit: 500, orderBy: [{ field: 'next_run_at', order: 'asc' }], context: SYSTEM_CTX,
469
- });
470
- return Array.isArray(rows) ? rows.map(rowFromSchedule) : [];
471
- }
472
-
473
- // ── Dispatcher ─────────────────────────────────────────────────
474
-
475
- async dispatchDue(now?: Date): Promise<{ fired: number; failed: number; skipped: number }> {
476
- const ts = (now ?? this.clock.now()).toISOString();
477
- const due = await this.engine.find('sys_report_schedule', {
478
- filter: { active: true },
479
- limit: 200,
480
- context: SYSTEM_CTX,
481
- });
482
- const list = (Array.isArray(due) ? due : []).map(rowFromSchedule)
483
- .filter(s => !s.next_run_at || s.next_run_at <= ts);
484
-
485
- let fired = 0, failed = 0, skipped = 0;
486
- for (const schedule of list) {
487
- try {
488
- const row = await this.loadReportRow(schedule.report_id);
489
- if (!row) {
490
- skipped++;
491
- await this.markSchedule(schedule.id, {
492
- last_status: 'skipped',
493
- last_error: `report ${schedule.report_id} missing`,
494
- });
495
- continue;
496
- }
497
- const report = rowFromSaved(row);
498
-
499
- // Run the report under the OWNER's authority, not system (#2980).
500
- // A scheduled run must not read rows the report's owner cannot see —
501
- // that was a silent RLS bypass (a member's scheduled report emailed
502
- // the target object's entire table). Resolve the owner to a real
503
- // RLS-bearing context; if we can't (no resolver wired, or unknown/
504
- // disabled owner), FAIL CLOSED rather than run elevated.
505
- const ownerId = report.owner_id;
506
- const runContext = ownerId && this.resolveOwnerContext
507
- ? await this.resolveOwnerContext(ownerId).catch((err) => {
508
- this.logger.warn?.('ReportService.dispatchDue: owner context resolution failed', err);
509
- return null;
510
- })
511
- : null;
512
- if (!runContext) {
513
- failed++;
514
- await this.markSchedule(schedule.id, {
515
- last_status: 'failed',
516
- last_error: ownerId
517
- ? `owner '${ownerId}' context unavailable — refusing to run scheduled report with RLS bypassed (#2849/#2980)`
518
- : 'report has no owner — refusing to run scheduled report with RLS bypassed (#2849/#2980)',
519
- });
520
- continue;
521
- }
522
-
523
- // Force the schedule's own format so the recipient gets what
524
- // the admin configured (CSV attachment vs inline HTML table).
525
- const fmt: ReportFormat = (schedule.format ?? 'html_table') as ReportFormat;
526
- const result = await this.executeReport({ ...report, format: fmt }, runContext, false);
527
-
528
- const recipients = schedule.recipients.split(',').map(s => s.trim()).filter(Boolean);
529
- const subject = renderSubject(schedule.subject_template, {
530
- name: schedule.name ?? report.name,
531
- date: ts.slice(0, 10),
532
- rows: String(result.rowCount),
533
- });
534
-
535
- if (this.email && recipients.length > 0) {
536
- if (fmt === 'csv') {
537
- await this.email.send({
538
- to: recipients,
539
- subject,
540
- text: `Attached: ${result.rowCount} row(s).`,
541
- attachments: [{
542
- // Keep unicode letters (CJK schedule names) — only strip
543
- // filesystem-hostile characters, else 周报 becomes `__`.
544
- filename: `${(schedule.name ?? report.name).replace(/[^\p{L}\p{N}._-]+/gu, '_').replace(/^_+|_+$/g, '') || 'report'}-${ts.slice(0, 10)}.csv`,
545
- content: result.body,
546
- contentType: 'text/csv',
547
- }],
548
- relatedObject: 'sys_report_schedule',
549
- relatedId: schedule.id,
550
- });
551
- } else {
552
- await this.email.send({
553
- to: recipients,
554
- subject,
555
- html: `<p>${escapeHtml(report.name)} — ${result.rowCount} row(s)</p>${result.body}`,
556
- text: `${report.name} — ${result.rowCount} row(s)`,
557
- relatedObject: 'sys_report_schedule',
558
- relatedId: schedule.id,
559
- });
560
- }
561
- } else if (!this.email) {
562
- this.logger.warn?.('ReportService.dispatchDue: no email service — schedule fired but mail not sent');
563
- }
564
-
565
- await this.advanceSchedule(schedule, ts);
566
- fired++;
567
- } catch (err: any) {
568
- failed++;
569
- await this.markSchedule(schedule.id, {
570
- last_status: 'failed',
571
- last_error: String(err?.message ?? err ?? 'unknown').slice(0, 500),
572
- });
573
- this.logger.error?.('ReportService.dispatchDue: schedule failed', err);
574
- }
575
- }
576
- return { fired, failed, skipped };
577
- }
578
-
579
- /**
580
- * Compute the next fire time for a schedule. A `cron_expression` wins over
581
- * `interval_minutes` (the documented `sys_report_schedule` contract) and is
582
- * evaluated in the schedule's `timezone` (default UTC) via croner — the same
583
- * library the job scheduler uses. Falls back to `from + interval_minutes` for
584
- * interval schedules, and also if a cron expression is invalid or has no
585
- * future occurrence (logged; never throws into the sweep). `from` is the
586
- * reference instant (the injected clock), so `today()`-style boundaries honor
587
- * the test clock.
588
- */
589
- private nextRunAt(
590
- schedule: { cron_expression?: string | null; interval_minutes?: number | null; timezone?: string | null },
591
- from: Date,
592
- ): Date {
593
- const cron = (schedule.cron_expression ?? '').trim();
594
- if (cron) {
595
- try {
596
- const next = new Cron(cron, { timezone: schedule.timezone || 'UTC' }).nextRun(from);
597
- if (next) return next;
598
- this.logger.warn?.(`ReportService: cron '${cron}' has no next occurrence; falling back to interval`);
599
- } catch (err) {
600
- this.logger.warn?.(`ReportService: invalid cron '${cron}'; falling back to interval`, err);
601
- }
602
- }
603
- const interval = schedule.interval_minutes ?? DEFAULT_INTERVAL_MIN;
604
- return new Date(from.getTime() + interval * 60_000);
605
- }
606
-
607
- private async advanceSchedule(schedule: ReportSchedule, ranAt: string): Promise<void> {
608
- const nextRun = this.nextRunAt(schedule, this.clock.now()).toISOString();
609
- await this.engine.update('sys_report_schedule', {
610
- id: schedule.id,
611
- next_run_at: nextRun,
612
- last_sent_at: ranAt,
613
- last_status: 'ok',
614
- last_error: null,
615
- updated_at: ranAt,
616
- }, { context: SYSTEM_CTX });
617
- }
618
-
619
- private async markSchedule(id: string, patch: Record<string, unknown>): Promise<void> {
620
- try {
621
- await this.engine.update('sys_report_schedule', {
622
- id, ...patch, updated_at: this.clock.now().toISOString(),
623
- }, { context: SYSTEM_CTX });
624
- } catch (err) {
625
- this.logger.warn?.('ReportService: failed to mark schedule', err);
626
- }
627
- }
628
- }