@objectstack/plugin-reports 17.0.0-rc.0 → 17.0.0-rc.2

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,707 +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
- * [#3544 / #3710] The user-level export axis —
202
- * `ISecurityService.canExport(object, context)`, wired by the reports plugin
203
- * from `getService('security')`.
204
- *
205
- * A report rendered as `csv`/`json` IS a bulk machine-readable copy of the
206
- * object, so it is the same privilege `GET /data/:object/export` gates.
207
- * Without this the axis had a side door: a caller refused at that route could
208
- * save a report on the same object, run it as CSV — or schedule one to their
209
- * own inbox — and receive the identical rows.
210
- *
211
- * Omitted (no `plugin-security`, so no permission sets exist anywhere) → the
212
- * axis does not apply, matching the REST export route's own fail-open.
213
- */
214
- canExport?: (object: string, context: unknown) => Promise<boolean>;
215
- }
216
-
217
- /**
218
- * [#3544 / #3710] Report formats that constitute a BULK EXPORT rather than a
219
- * rendering.
220
- *
221
- * `csv`/`json` are machine-readable copies — re-importable elsewhere, and
222
- * exactly what `GET /data/:object/export` serves. `html_table` is a PRESENTED
223
- * view: the report equivalent of reading rows on screen, which any caller
224
- * holding `allowRead` may already do. Gating it would restrict reading rather
225
- * than exporting and would take the axis past what it is for.
226
- */
227
- const BULK_EXPORT_FORMATS: ReadonlySet<string> = new Set(['csv', 'json']);
228
-
229
- export class ReportService implements IReportService {
230
- private readonly engine: ReportEngine;
231
- private readonly email?: ReportEmail;
232
- private readonly clock: ReportClock;
233
- private readonly logger: NonNullable<ReportServiceOptions['logger']>;
234
- private readonly maxRows: number;
235
- private readonly resolveOwnerContext?: OwnerContextResolver;
236
- private readonly canExportFn?: (object: string, context: unknown) => Promise<boolean>;
237
-
238
- constructor(opts: ReportServiceOptions) {
239
- this.engine = opts.engine;
240
- this.email = opts.email;
241
- this.clock = opts.clock ?? { now: () => new Date() };
242
- this.logger = opts.logger ?? {};
243
- this.maxRows = Math.max(1, opts.maxRows ?? 5000);
244
- this.resolveOwnerContext = opts.resolveOwnerContext;
245
- this.canExportFn = opts.canExport;
246
- }
247
-
248
- /**
249
- * [#3544 / #3710] Gate a report rendering on the user-level export axis.
250
- *
251
- * Throws `EXPORT_NOT_PERMITTED` when the principal behind `context` may not
252
- * take a bulk copy of `object`. A no-op for non-bulk formats (`html_table`),
253
- * for a system context, and when no `canExport` is wired.
254
- *
255
- * Deliberately checked HERE — one place — rather than at each of the three
256
- * callers (`runReport`, the ad-hoc run, and the scheduled dispatch): a gate
257
- * per call site is how a fourth call site later ships ungated. `dispatchDue`
258
- * routes through `executeReport` too, so the scheduled CSV is covered by the
259
- * same line. (`scheduleReport` additionally pre-checks, so an author is
260
- * refused when they create the schedule rather than silently at 3am — but
261
- * that is UX, and THIS is the enforcement: a grant revoked after the schedule
262
- * was created must still stop the delivery.)
263
- *
264
- * Fails CLOSED on a throw — it resolves permission sets to decide, and a
265
- * resolution failure must never read as a grant (ADR-0049).
266
- */
267
- private async assertExportAllowed(
268
- object: string,
269
- format: string,
270
- context: SharingExecutionContext | undefined,
271
- ): Promise<void> {
272
- if (!BULK_EXPORT_FORMATS.has(format)) return;
273
- if (context?.isSystem) return;
274
- if (!this.canExportFn) return;
275
- let allowed: boolean;
276
- try {
277
- allowed = await this.canExportFn(object, context);
278
- } catch (err) {
279
- this.logger.warn?.('ReportService: canExport check failed — denying export', err);
280
- allowed = false;
281
- }
282
- if (!allowed) {
283
- throw new Error(
284
- `EXPORT_NOT_PERMITTED: exporting '${object}' as ${format} is not permitted for this user`,
285
- );
286
- }
287
- }
288
-
289
- // ── Access control ─────────────────────────────────────────────
290
-
291
- /**
292
- * Authorization for a saved-report row. `sys_saved_report` is a
293
- * protection-locked system object, so its rows are *read* with
294
- * `SYSTEM_CTX`; the caller's right to see/mutate a specific report is
295
- * enforced HERE, by owner match, not by the metadata read's own RLS —
296
- * otherwise any authenticated caller could read/delete/overwrite any
297
- * report by id (#2980). An explicit elevated context (`isSystem`) — the
298
- * scheduler / server tooling — sees everything.
299
- */
300
- private canAccessReport(row: { owner_id?: unknown } | null | undefined, context: SharingExecutionContext | undefined): boolean {
301
- if (!row) return false;
302
- if (context?.isSystem) return true;
303
- const userId = context?.userId;
304
- return !!userId && row.owner_id === userId;
305
- }
306
-
307
- /** Raw metadata read of a saved report by id (no authz — callers gate). */
308
- private async loadReportRow(reportId: string): Promise<any | null> {
309
- const rows = await this.engine.find('sys_saved_report', {
310
- filter: { id: reportId }, limit: 1, context: SYSTEM_CTX,
311
- });
312
- return Array.isArray(rows) && rows[0] ? rows[0] : null;
313
- }
314
-
315
- // ── Report CRUD ────────────────────────────────────────────────
316
-
317
- async saveReport(input: SaveReportInput, context: SharingExecutionContext): Promise<SavedReport> {
318
- if (!input.name) throw new Error('VALIDATION_FAILED: name is required');
319
- if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
320
- if (!input.query) throw new Error('VALIDATION_FAILED: query is required');
321
-
322
- const now = this.clock.now().toISOString();
323
- // A non-system caller always owns what they create — a caller-supplied
324
- // ownerId cannot assign the report to someone else (#2980). Only an
325
- // explicit elevated context (server tooling / import) may set it.
326
- const ownerId = context.isSystem ? (input.ownerId ?? context.userId ?? null) : (context.userId ?? null);
327
- const payload: any = {
328
- name: input.name,
329
- description: input.description ?? null,
330
- object_name: input.object,
331
- query_json: JSON.stringify(input.query ?? {}),
332
- format: input.format ?? DEFAULT_FORMAT,
333
- owner_id: ownerId,
334
- updated_at: now,
335
- };
336
-
337
- if (input.id) {
338
- const existing = await this.loadReportRow(input.id);
339
- if (existing) {
340
- // An update to an existing report is a mutation — a caller may only
341
- // overwrite a report they own (#2980). Not-found for others so the
342
- // response doesn't leak that the id exists.
343
- if (!this.canAccessReport(existing, context)) {
344
- throw new Error(`REPORT_NOT_FOUND: ${input.id}`);
345
- }
346
- // Never let a non-system caller reassign ownership away from the row.
347
- if (!context.isSystem) payload.owner_id = existing.owner_id ?? payload.owner_id;
348
- await this.engine.update('sys_saved_report', { id: input.id, ...payload }, { context: SYSTEM_CTX });
349
- return rowFromSaved({ ...existing, ...payload, id: input.id });
350
- }
351
- }
352
-
353
- const id = input.id ?? uid('rpt');
354
- const row = { id, ...payload, created_at: now };
355
- await this.engine.insert('sys_saved_report', row, { context: SYSTEM_CTX });
356
- return rowFromSaved(row);
357
- }
358
-
359
- async listReports(
360
- filter: { object?: string; ownerId?: string } | undefined,
361
- context: SharingExecutionContext,
362
- ): Promise<SavedReport[]> {
363
- const f: any = {};
364
- if (filter?.object) f.object_name = filter.object;
365
- // Owner scoping (#2980): a non-system caller sees ONLY their own reports —
366
- // a caller-supplied ownerId can never widen past their own id. A caller
367
- // with no identity sees nothing (fail closed). System/tooling sees all,
368
- // honouring an explicit ownerId narrow.
369
- if (context?.isSystem) {
370
- if (filter?.ownerId) f.owner_id = filter.ownerId;
371
- } else {
372
- if (!context?.userId) return [];
373
- if (filter?.ownerId && filter.ownerId !== context.userId) return [];
374
- f.owner_id = context.userId;
375
- }
376
- const rows = await this.engine.find('sys_saved_report', {
377
- filter: f, limit: 500, orderBy: [{ field: 'updated_at', order: 'desc' }], context: SYSTEM_CTX,
378
- });
379
- return Array.isArray(rows) ? rows.map(rowFromSaved) : [];
380
- }
381
-
382
- async getReport(reportId: string, context: SharingExecutionContext): Promise<SavedReport | null> {
383
- const row = await this.loadReportRow(reportId);
384
- // Unauthorized reads are indistinguishable from a genuine miss (#2980).
385
- if (!this.canAccessReport(row, context)) return null;
386
- return rowFromSaved(row);
387
- }
388
-
389
- async deleteReport(reportId: string, context: SharingExecutionContext): Promise<void> {
390
- if (!reportId) throw new Error('VALIDATION_FAILED: reportId is required');
391
- const row = await this.loadReportRow(reportId);
392
- if (!row) return; // idempotent — nothing to drop
393
- // A caller may only delete a report they own (#2980); others get a
394
- // not-found so the delete neither fires nor reveals the report's existence.
395
- if (!this.canAccessReport(row, context)) {
396
- throw new Error(`REPORT_NOT_FOUND: ${reportId}`);
397
- }
398
- // Cascade — drop attached schedules first.
399
- const schedules = await this.engine.find('sys_report_schedule', {
400
- filter: { report_id: reportId }, limit: 500, context: SYSTEM_CTX,
401
- });
402
- for (const s of (schedules ?? [])) {
403
- await this.engine.delete('sys_report_schedule', { where: { id: (s as any).id }, context: SYSTEM_CTX });
404
- }
405
- await this.engine.delete('sys_saved_report', { where: { id: reportId }, context: SYSTEM_CTX });
406
- }
407
-
408
- // ── Execution ───────────────────────────────────────────────────
409
-
410
- async run(reportId: string, context: SharingExecutionContext): Promise<ReportRunResult> {
411
- const report = await this.getReport(reportId, context);
412
- if (!report) throw new Error(`REPORT_NOT_FOUND: ${reportId}`);
413
- return this.executeReport(report, context);
414
- }
415
-
416
- async runAdHoc(input: SaveReportInput, context: SharingExecutionContext): Promise<ReportRunResult> {
417
- if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
418
- if (!input.query) throw new Error('VALIDATION_FAILED: query is required');
419
- const adhoc: SavedReport = {
420
- id: '__adhoc__',
421
- name: input.name ?? 'Ad-hoc report',
422
- object_name: input.object,
423
- query: input.query,
424
- format: input.format ?? DEFAULT_FORMAT,
425
- };
426
- return this.executeReport(adhoc, context, /* stamp */ false);
427
- }
428
-
429
- private async executeReport(
430
- report: SavedReport,
431
- context: SharingExecutionContext,
432
- stamp = true,
433
- ): Promise<ReportRunResult> {
434
- // [#3544 / #3710] The export axis, BEFORE any row is read — a refusal must
435
- // not be reachable after the data has already been pulled.
436
- await this.assertExportAllowed(report.object_name, report.format, context);
437
- const q = report.query ?? {};
438
- const limit = Math.min(q.limit ?? DEFAULT_LIMIT, this.maxRows);
439
- const rows = await this.engine.find(report.object_name, {
440
- filter: q.filter,
441
- fields: q.fields,
442
- orderBy: q.orderBy,
443
- limit,
444
- // Reports execute with the caller's identity so sharing rules
445
- // (if installed) apply. Falls back to system bypass only when
446
- // the report definition was created by a system writer.
447
- context: {
448
- userId: context.userId,
449
- tenantId: context.tenantId,
450
- positions: context.positions ?? [],
451
- permissions: context.permissions ?? [],
452
- isSystem: context.isSystem ?? false,
453
- },
454
- });
455
- const list = Array.isArray(rows) ? rows : [];
456
- const body = renderReport(list, report.format, q.fields);
457
- const ranAt = this.clock.now().toISOString();
458
-
459
- if (stamp && report.id !== '__adhoc__') {
460
- try {
461
- await this.engine.update('sys_saved_report', {
462
- id: report.id,
463
- last_run_at: ranAt,
464
- last_row_count: list.length,
465
- updated_at: ranAt,
466
- }, { context: SYSTEM_CTX });
467
- } catch (err) {
468
- this.logger.warn?.('ReportService: failed to stamp last_run_at', err);
469
- }
470
- }
471
-
472
- return {
473
- reportId: report.id,
474
- rowCount: list.length,
475
- format: report.format,
476
- body,
477
- rows: list,
478
- ranAt,
479
- };
480
- }
481
-
482
- // ── Schedules ──────────────────────────────────────────────────
483
-
484
- async scheduleReport(input: ScheduleReportInput, context: SharingExecutionContext): Promise<ReportSchedule> {
485
- if (!input.reportId) throw new Error('VALIDATION_FAILED: reportId is required');
486
- if (!input.recipients || input.recipients.length === 0) {
487
- throw new Error('VALIDATION_FAILED: recipients must be a non-empty array');
488
- }
489
- const report = await this.getReport(input.reportId, context);
490
- if (!report) throw new Error(`REPORT_NOT_FOUND: ${input.reportId}`);
491
-
492
- // [#3544 / #3710] Refuse a bulk-format schedule the author could not run
493
- // themselves, at CREATE time — otherwise the refusal only surfaces on the
494
- // first silent 3am sweep. Advisory only: `executeReport` re-checks on every
495
- // dispatch, which is what catches a grant revoked after this point.
496
- await this.assertExportAllowed(report.object_name, input.format ?? 'html_table', context);
497
-
498
- const now = this.clock.now();
499
- const interval = input.intervalMinutes ?? DEFAULT_INTERVAL_MIN;
500
- const cron = input.cronExpression?.trim() || null;
501
- if (cron) {
502
- // Validate eagerly so an author gets a clear error at schedule time
503
- // instead of a schedule that silently falls back to interval on sweep.
504
- try {
505
- new Cron(cron, { timezone: input.timezone || 'UTC' });
506
- } catch (err) {
507
- throw new Error(`VALIDATION_FAILED: invalid cron_expression '${cron}': ${(err as Error).message}`);
508
- }
509
- }
510
- const nextRun = this.nextRunAt(
511
- { cron_expression: cron, interval_minutes: interval, timezone: input.timezone ?? 'UTC' },
512
- now,
513
- ).toISOString();
514
- const id = uid('rsch');
515
- const row: any = {
516
- id,
517
- report_id: input.reportId,
518
- name: input.name ?? null,
519
- interval_minutes: interval,
520
- cron_expression: cron,
521
- timezone: input.timezone ?? 'UTC',
522
- active: input.active !== false,
523
- recipients: input.recipients.join(','),
524
- format: input.format ?? 'html_table',
525
- subject_template: input.subjectTemplate ?? null,
526
- owner_id: input.ownerId ?? context.userId ?? null,
527
- next_run_at: nextRun,
528
- created_at: now.toISOString(),
529
- updated_at: now.toISOString(),
530
- };
531
- await this.engine.insert('sys_report_schedule', row, { context: SYSTEM_CTX });
532
- return rowFromSchedule(row);
533
- }
534
-
535
- async unscheduleReport(scheduleId: string, _context: SharingExecutionContext): Promise<void> {
536
- if (!scheduleId) throw new Error('VALIDATION_FAILED: scheduleId is required');
537
- await this.engine.delete('sys_report_schedule', { where: { id: scheduleId }, context: SYSTEM_CTX });
538
- }
539
-
540
- async listSchedules(
541
- filter: { reportId?: string } | undefined,
542
- _context: SharingExecutionContext,
543
- ): Promise<ReportSchedule[]> {
544
- const f: any = {};
545
- if (filter?.reportId) f.report_id = filter.reportId;
546
- const rows = await this.engine.find('sys_report_schedule', {
547
- filter: f, limit: 500, orderBy: [{ field: 'next_run_at', order: 'asc' }], context: SYSTEM_CTX,
548
- });
549
- return Array.isArray(rows) ? rows.map(rowFromSchedule) : [];
550
- }
551
-
552
- // ── Dispatcher ─────────────────────────────────────────────────
553
-
554
- async dispatchDue(now?: Date): Promise<{ fired: number; failed: number; skipped: number }> {
555
- const ts = (now ?? this.clock.now()).toISOString();
556
- const due = await this.engine.find('sys_report_schedule', {
557
- filter: { active: true },
558
- limit: 200,
559
- context: SYSTEM_CTX,
560
- });
561
- const list = (Array.isArray(due) ? due : []).map(rowFromSchedule)
562
- .filter(s => !s.next_run_at || s.next_run_at <= ts);
563
-
564
- let fired = 0, failed = 0, skipped = 0;
565
- for (const schedule of list) {
566
- try {
567
- const row = await this.loadReportRow(schedule.report_id);
568
- if (!row) {
569
- skipped++;
570
- await this.markSchedule(schedule.id, {
571
- last_status: 'skipped',
572
- last_error: `report ${schedule.report_id} missing`,
573
- });
574
- continue;
575
- }
576
- const report = rowFromSaved(row);
577
-
578
- // Run the report under the OWNER's authority, not system (#2980).
579
- // A scheduled run must not read rows the report's owner cannot see —
580
- // that was a silent RLS bypass (a member's scheduled report emailed
581
- // the target object's entire table). Resolve the owner to a real
582
- // RLS-bearing context; if we can't (no resolver wired, or unknown/
583
- // disabled owner), FAIL CLOSED rather than run elevated.
584
- const ownerId = report.owner_id;
585
- const runContext = ownerId && this.resolveOwnerContext
586
- ? await this.resolveOwnerContext(ownerId).catch((err) => {
587
- this.logger.warn?.('ReportService.dispatchDue: owner context resolution failed', err);
588
- return null;
589
- })
590
- : null;
591
- if (!runContext) {
592
- failed++;
593
- await this.markSchedule(schedule.id, {
594
- last_status: 'failed',
595
- last_error: ownerId
596
- ? `owner '${ownerId}' context unavailable — refusing to run scheduled report with RLS bypassed (#2849/#2980)`
597
- : 'report has no owner — refusing to run scheduled report with RLS bypassed (#2849/#2980)',
598
- });
599
- continue;
600
- }
601
-
602
- // Force the schedule's own format so the recipient gets what
603
- // the admin configured (CSV attachment vs inline HTML table).
604
- const fmt: ReportFormat = (schedule.format ?? 'html_table') as ReportFormat;
605
- const result = await this.executeReport({ ...report, format: fmt }, runContext, false);
606
-
607
- const recipients = schedule.recipients.split(',').map(s => s.trim()).filter(Boolean);
608
- const subject = renderSubject(schedule.subject_template, {
609
- name: schedule.name ?? report.name,
610
- date: ts.slice(0, 10),
611
- rows: String(result.rowCount),
612
- });
613
-
614
- if (this.email && recipients.length > 0) {
615
- if (fmt === 'csv') {
616
- await this.email.send({
617
- to: recipients,
618
- subject,
619
- text: `Attached: ${result.rowCount} row(s).`,
620
- attachments: [{
621
- // Keep unicode letters (CJK schedule names) — only strip
622
- // filesystem-hostile characters, else 周报 becomes `__`.
623
- filename: `${(schedule.name ?? report.name).replace(/[^\p{L}\p{N}._-]+/gu, '_').replace(/^_+|_+$/g, '') || 'report'}-${ts.slice(0, 10)}.csv`,
624
- content: result.body,
625
- contentType: 'text/csv',
626
- }],
627
- relatedObject: 'sys_report_schedule',
628
- relatedId: schedule.id,
629
- });
630
- } else {
631
- await this.email.send({
632
- to: recipients,
633
- subject,
634
- html: `<p>${escapeHtml(report.name)} — ${result.rowCount} row(s)</p>${result.body}`,
635
- text: `${report.name} — ${result.rowCount} row(s)`,
636
- relatedObject: 'sys_report_schedule',
637
- relatedId: schedule.id,
638
- });
639
- }
640
- } else if (!this.email) {
641
- this.logger.warn?.('ReportService.dispatchDue: no email service — schedule fired but mail not sent');
642
- }
643
-
644
- await this.advanceSchedule(schedule, ts);
645
- fired++;
646
- } catch (err: any) {
647
- failed++;
648
- await this.markSchedule(schedule.id, {
649
- last_status: 'failed',
650
- last_error: String(err?.message ?? err ?? 'unknown').slice(0, 500),
651
- });
652
- this.logger.error?.('ReportService.dispatchDue: schedule failed', err);
653
- }
654
- }
655
- return { fired, failed, skipped };
656
- }
657
-
658
- /**
659
- * Compute the next fire time for a schedule. A `cron_expression` wins over
660
- * `interval_minutes` (the documented `sys_report_schedule` contract) and is
661
- * evaluated in the schedule's `timezone` (default UTC) via croner — the same
662
- * library the job scheduler uses. Falls back to `from + interval_minutes` for
663
- * interval schedules, and also if a cron expression is invalid or has no
664
- * future occurrence (logged; never throws into the sweep). `from` is the
665
- * reference instant (the injected clock), so `today()`-style boundaries honor
666
- * the test clock.
667
- */
668
- private nextRunAt(
669
- schedule: { cron_expression?: string | null; interval_minutes?: number | null; timezone?: string | null },
670
- from: Date,
671
- ): Date {
672
- const cron = (schedule.cron_expression ?? '').trim();
673
- if (cron) {
674
- try {
675
- const next = new Cron(cron, { timezone: schedule.timezone || 'UTC' }).nextRun(from);
676
- if (next) return next;
677
- this.logger.warn?.(`ReportService: cron '${cron}' has no next occurrence; falling back to interval`);
678
- } catch (err) {
679
- this.logger.warn?.(`ReportService: invalid cron '${cron}'; falling back to interval`, err);
680
- }
681
- }
682
- const interval = schedule.interval_minutes ?? DEFAULT_INTERVAL_MIN;
683
- return new Date(from.getTime() + interval * 60_000);
684
- }
685
-
686
- private async advanceSchedule(schedule: ReportSchedule, ranAt: string): Promise<void> {
687
- const nextRun = this.nextRunAt(schedule, this.clock.now()).toISOString();
688
- await this.engine.update('sys_report_schedule', {
689
- id: schedule.id,
690
- next_run_at: nextRun,
691
- last_sent_at: ranAt,
692
- last_status: 'ok',
693
- last_error: null,
694
- updated_at: ranAt,
695
- }, { context: SYSTEM_CTX });
696
- }
697
-
698
- private async markSchedule(id: string, patch: Record<string, unknown>): Promise<void> {
699
- try {
700
- await this.engine.update('sys_report_schedule', {
701
- id, ...patch, updated_at: this.clock.now().toISOString(),
702
- }, { context: SYSTEM_CTX });
703
- } catch (err) {
704
- this.logger.warn?.('ReportService: failed to mark schedule', err);
705
- }
706
- }
707
- }