@objectstack/plugin-reports 9.8.0 → 9.9.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,5 +1,5 @@
1
1
 
2
- > @objectstack/plugin-reports@9.8.0 build /home/runner/work/framework/framework/packages/plugins/plugin-reports
2
+ > @objectstack/plugin-reports@9.9.1 build /home/runner/work/framework/framework/packages/plugins/plugin-reports
3
3
  > tsup --config ../../../tsup.config.ts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- ESM dist/index.mjs 17.77 KB
14
- ESM dist/index.mjs.map 36.81 KB
15
- ESM ⚡️ Build success in 91ms
16
- CJS dist/index.js 18.86 KB
17
- CJS dist/index.js.map 36.81 KB
18
- CJS ⚡️ Build success in 96ms
13
+ CJS dist/index.js 20.35 KB
14
+ CJS dist/index.js.map 39.23 KB
15
+ CJS ⚡️ Build success in 113ms
16
+ ESM dist/index.mjs 19.23 KB
17
+ ESM dist/index.mjs.map 39.24 KB
18
+ ESM ⚡️ Build success in 116ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 16130ms
21
- DTS dist/index.d.mts 4.99 KB
22
- DTS dist/index.d.ts 4.99 KB
20
+ DTS ⚡️ Build success in 16822ms
21
+ DTS dist/index.d.mts 5.60 KB
22
+ DTS dist/index.d.ts 5.60 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @objectstack/plugin-reports
2
2
 
3
+ ## 9.9.1
4
+
5
+ ### Patch Changes
6
+
7
+ - @objectstack/spec@9.9.1
8
+ - @objectstack/core@9.9.1
9
+ - @objectstack/platform-objects@9.9.1
10
+
11
+ ## 9.9.0
12
+
13
+ ### Minor Changes
14
+
15
+ - d42004b: feat(reports): report schedules honor `cron_expression` + `timezone`
16
+
17
+ `sys_report_schedule` has long carried `cron_expression` and `timezone` fields, but `ReportService` only ever advanced `next_run_at` by `interval_minutes` — both fields were stored and ignored. Scheduling now computes `next_run_at` from the cron expression (when present) in the schedule's timezone via `croner` — the same library the job scheduler uses — so "every weekday at 09:00 local" is expressible. `interval_minutes` remains the fallback.
18
+
19
+ - `cron_expression` wins over `interval_minutes` when set (the field's documented contract).
20
+ - Evaluated in `timezone` (default `UTC`).
21
+ - `scheduleReport` validates the cron expression eagerly and rejects an invalid one with `VALIDATION_FAILED`, rather than silently falling back at sweep time. A cron that becomes unschedulable later is logged and falls back to the interval — it never throws into the dispatch sweep.
22
+
23
+ The DB-polling dispatch model is unchanged; only the next-run computation is cron-aware. Part of ADR-0053 Phase 2 (#1983) but self-contained — report schedules run under a system context, so the timezone source is the schedule's own field, independent of the reference-timezone resolver.
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies [84249a4]
28
+ - Updated dependencies [11af299]
29
+ - Updated dependencies [d5774b5]
30
+ - Updated dependencies [134043a]
31
+ - Updated dependencies [90108e0]
32
+ - Updated dependencies [9afeb2d]
33
+ - Updated dependencies [6bec07e]
34
+ - Updated dependencies [601cc11]
35
+ - Updated dependencies [575448d]
36
+ - @objectstack/spec@9.9.0
37
+ - @objectstack/core@9.9.0
38
+ - @objectstack/platform-objects@9.9.0
39
+
3
40
  ## 9.8.0
4
41
 
5
42
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -79,6 +79,17 @@ declare class ReportService implements IReportService {
79
79
  failed: number;
80
80
  skipped: number;
81
81
  }>;
82
+ /**
83
+ * Compute the next fire time for a schedule. A `cron_expression` wins over
84
+ * `interval_minutes` (the documented `sys_report_schedule` contract) and is
85
+ * evaluated in the schedule's `timezone` (default UTC) via croner — the same
86
+ * library the job scheduler uses. Falls back to `from + interval_minutes` for
87
+ * interval schedules, and also if a cron expression is invalid or has no
88
+ * future occurrence (logged; never throws into the sweep). `from` is the
89
+ * reference instant (the injected clock), so `today()`-style boundaries honor
90
+ * the test clock.
91
+ */
92
+ private nextRunAt;
82
93
  private advanceSchedule;
83
94
  private markSchedule;
84
95
  }
package/dist/index.d.ts CHANGED
@@ -79,6 +79,17 @@ declare class ReportService implements IReportService {
79
79
  failed: number;
80
80
  skipped: number;
81
81
  }>;
82
+ /**
83
+ * Compute the next fire time for a schedule. A `cron_expression` wins over
84
+ * `interval_minutes` (the documented `sys_report_schedule` contract) and is
85
+ * evaluated in the schedule's `timezone` (default UTC) via croner — the same
86
+ * library the job scheduler uses. Falls back to `from + interval_minutes` for
87
+ * interval schedules, and also if a cron expression is invalid or has no
88
+ * future occurrence (logged; never throws into the sweep). `from` is the
89
+ * reference instant (the injected clock), so `today()`-style boundaries honor
90
+ * the test clock.
91
+ */
92
+ private nextRunAt;
82
93
  private advanceSchedule;
83
94
  private markSchedule;
84
95
  }
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ module.exports = __toCommonJS(index_exports);
30
30
  var import_audit2 = require("@objectstack/platform-objects/audit");
31
31
 
32
32
  // src/report-service.ts
33
+ var import_croner = require("croner");
33
34
  var SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
34
35
  var DEFAULT_FORMAT = "csv";
35
36
  var DEFAULT_INTERVAL_MIN = 1440;
@@ -293,14 +294,25 @@ var ReportService = class {
293
294
  if (!report) throw new Error(`REPORT_NOT_FOUND: ${input.reportId}`);
294
295
  const now = this.clock.now();
295
296
  const interval = input.intervalMinutes ?? DEFAULT_INTERVAL_MIN;
296
- const nextRun = new Date(now.getTime() + interval * 6e4).toISOString();
297
+ const cron = input.cronExpression?.trim() || null;
298
+ if (cron) {
299
+ try {
300
+ new import_croner.Cron(cron, { timezone: input.timezone || "UTC" });
301
+ } catch (err) {
302
+ throw new Error(`VALIDATION_FAILED: invalid cron_expression '${cron}': ${err.message}`);
303
+ }
304
+ }
305
+ const nextRun = this.nextRunAt(
306
+ { cron_expression: cron, interval_minutes: interval, timezone: input.timezone ?? "UTC" },
307
+ now
308
+ ).toISOString();
297
309
  const id = uid("rsch");
298
310
  const row = {
299
311
  id,
300
312
  report_id: input.reportId,
301
313
  name: input.name ?? null,
302
314
  interval_minutes: interval,
303
- cron_expression: input.cronExpression ?? null,
315
+ cron_expression: cron,
304
316
  timezone: input.timezone ?? "UTC",
305
317
  active: input.active !== false,
306
318
  recipients: input.recipients.join(","),
@@ -398,9 +410,32 @@ var ReportService = class {
398
410
  }
399
411
  return { fired, failed, skipped };
400
412
  }
401
- async advanceSchedule(schedule, ranAt) {
413
+ /**
414
+ * Compute the next fire time for a schedule. A `cron_expression` wins over
415
+ * `interval_minutes` (the documented `sys_report_schedule` contract) and is
416
+ * evaluated in the schedule's `timezone` (default UTC) via croner — the same
417
+ * library the job scheduler uses. Falls back to `from + interval_minutes` for
418
+ * interval schedules, and also if a cron expression is invalid or has no
419
+ * future occurrence (logged; never throws into the sweep). `from` is the
420
+ * reference instant (the injected clock), so `today()`-style boundaries honor
421
+ * the test clock.
422
+ */
423
+ nextRunAt(schedule, from) {
424
+ const cron = (schedule.cron_expression ?? "").trim();
425
+ if (cron) {
426
+ try {
427
+ const next = new import_croner.Cron(cron, { timezone: schedule.timezone || "UTC" }).nextRun(from);
428
+ if (next) return next;
429
+ this.logger.warn?.(`ReportService: cron '${cron}' has no next occurrence; falling back to interval`);
430
+ } catch (err) {
431
+ this.logger.warn?.(`ReportService: invalid cron '${cron}'; falling back to interval`, err);
432
+ }
433
+ }
402
434
  const interval = schedule.interval_minutes ?? DEFAULT_INTERVAL_MIN;
403
- const nextRun = new Date(this.clock.now().getTime() + interval * 6e4).toISOString();
435
+ return new Date(from.getTime() + interval * 6e4);
436
+ }
437
+ async advanceSchedule(schedule, ranAt) {
438
+ const nextRun = this.nextRunAt(schedule, this.clock.now()).toISOString();
404
439
  await this.engine.update("sys_report_schedule", {
405
440
  id: schedule.id,
406
441
  next_run_at: nextRun,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/report-service.ts","../src/reports-plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * @objectstack/plugin-reports\n *\n * Saved reports + scheduled email digests for ObjectStack.\n * Persists `sys_saved_report` definitions and `sys_report_schedule`\n * rows, then drives a dispatcher that runs due schedules and emails\n * the rendered output via the configured `email` service.\n */\n\nexport { SysSavedReport, SysReportSchedule } from '@objectstack/platform-objects/audit';\nexport {\n ReportService,\n renderReport,\n type ReportEngine,\n type ReportEmail,\n type ReportClock,\n type ReportServiceOptions,\n} from './report-service.js';\nexport {\n ReportsServicePlugin,\n type ReportsPluginOptions,\n} from './reports-plugin.js';\nexport type {\n IReportService,\n SavedReport,\n ReportSchedule,\n ReportQuery,\n ReportRunResult,\n ReportFormat,\n SaveReportInput,\n ScheduleReportInput,\n} from '@objectstack/spec/contracts';\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type {\n IReportService,\n SavedReport,\n ReportSchedule,\n ReportQuery,\n ReportRunResult,\n ReportFormat,\n SaveReportInput,\n ScheduleReportInput,\n SharingExecutionContext,\n} from '@objectstack/spec/contracts';\n\n/**\n * Narrow engine surface — keeps the service testable without booting\n * a real ObjectQL kernel.\n */\nexport interface ReportEngine {\n find(object: string, options?: any): Promise<any[]>;\n findOne?(object: string, options?: any): Promise<any>;\n insert(object: string, data: any, options?: any): Promise<any>;\n update(object: string, idOrData: any, dataOrOptions?: any, options?: any): Promise<any>;\n delete(object: string, options?: any): Promise<any>;\n}\n\n/**\n * Minimum email surface — implementations may pass the full\n * `IEmailService` instance straight through.\n */\nexport interface ReportEmail {\n send(input: {\n to: string | string[];\n subject: string;\n text?: string;\n html?: string;\n attachments?: Array<{ filename: string; content: string; contentType?: string }>;\n relatedObject?: string;\n relatedId?: string;\n }): Promise<{ status: 'sent' | 'queued' | 'failed' }>;\n}\n\n/** Stamped only in tests / specialised callers to make `now` deterministic. */\nexport interface ReportClock { now(): Date }\n\nconst SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;\n\nconst DEFAULT_FORMAT: ReportFormat = 'csv';\nconst DEFAULT_INTERVAL_MIN = 1440;\nconst DEFAULT_LIMIT = 1000;\n\nfunction uid(prefix: string): string {\n const g: any = globalThis as any;\n if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction parseQuery(raw: unknown): ReportQuery {\n if (!raw) return {};\n if (typeof raw === 'string') {\n try { return JSON.parse(raw) as ReportQuery; }\n catch { return {}; }\n }\n if (typeof raw === 'object') return raw as ReportQuery;\n return {};\n}\n\nfunction rowFromSaved(row: any): SavedReport {\n return {\n id: String(row.id),\n name: String(row.name ?? ''),\n description: row.description ?? undefined,\n object_name: String(row.object_name ?? ''),\n query: parseQuery(row.query_json),\n format: (row.format as ReportFormat) ?? DEFAULT_FORMAT,\n owner_id: row.owner_id ?? undefined,\n last_run_at: row.last_run_at ?? undefined,\n last_row_count: row.last_row_count ?? undefined,\n created_at: row.created_at ?? undefined,\n updated_at: row.updated_at ?? undefined,\n };\n}\n\nfunction rowFromSchedule(row: any): ReportSchedule {\n return {\n id: String(row.id),\n report_id: String(row.report_id),\n name: row.name ?? undefined,\n interval_minutes: row.interval_minutes ?? undefined,\n cron_expression: row.cron_expression ?? undefined,\n timezone: row.timezone ?? undefined,\n active: row.active !== false,\n recipients: String(row.recipients ?? ''),\n format: row.format ?? undefined,\n subject_template: row.subject_template ?? undefined,\n owner_id: row.owner_id ?? undefined,\n next_run_at: row.next_run_at ?? undefined,\n last_sent_at: row.last_sent_at ?? undefined,\n last_status: row.last_status ?? undefined,\n last_error: row.last_error ?? undefined,\n };\n}\n\n// ─── Rendering ─────────────────────────────────────────────────────\n\nfunction escapeCsvCell(v: unknown): string {\n if (v == null) return '';\n const s = typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));\n if (/[\",\\r\\n]/.test(s)) return `\"${s.replace(/\"/g, '\"\"')}\"`;\n return s;\n}\n\nfunction pickFields(rows: any[], explicit?: string[]): string[] {\n if (explicit && explicit.length > 0) return explicit;\n const seen = new Set<string>();\n for (const r of rows.slice(0, 50)) {\n if (r && typeof r === 'object') for (const k of Object.keys(r)) seen.add(k);\n }\n return Array.from(seen);\n}\n\nfunction renderCsv(rows: any[], fields?: string[]): string {\n const cols = pickFields(rows, fields);\n const head = cols.join(',');\n const body = rows.map(r => cols.map(c => escapeCsvCell(r?.[c])).join(',')).join('\\r\\n');\n return body.length > 0 ? `${head}\\r\\n${body}` : head;\n}\n\nfunction renderJson(rows: any[]): string {\n return JSON.stringify(rows, null, 2);\n}\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({\n '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;',\n } as Record<string, string>)[c]);\n}\n\nfunction renderHtmlTable(rows: any[], fields?: string[]): string {\n const cols = pickFields(rows, fields);\n const th = cols.map(c => `<th style=\"text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;\">${escapeHtml(c)}</th>`).join('');\n const trs = rows.map(r => {\n const tds = cols.map(c => {\n const v = r?.[c];\n const s = v == null ? '' : (typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v)));\n return `<td style=\"padding:4px 8px;border-bottom:1px solid #eee;\">${escapeHtml(s)}</td>`;\n }).join('');\n return `<tr>${tds}</tr>`;\n }).join('');\n return `<table style=\"border-collapse:collapse;font-family:system-ui,Arial,sans-serif;font-size:13px;\">`\n + `<thead><tr>${th}</tr></thead><tbody>${trs}</tbody></table>`;\n}\n\nexport function renderReport(rows: any[], format: ReportFormat, fields?: string[]): string {\n switch (format) {\n case 'json': return renderJson(rows);\n case 'html_table': return renderHtmlTable(rows, fields);\n case 'csv':\n default: return renderCsv(rows, fields);\n }\n}\n\n// ─── Subject templating (minimal {{var}}) ─────────────────────────\n\nfunction renderSubject(template: string | undefined, vars: Record<string, string>): string {\n const tpl = template ?? '{{name}} — {{date}}';\n return tpl.replace(/\\{\\{\\s*(\\w+)\\s*\\}\\}/g, (_m, k) => vars[String(k)] ?? '');\n}\n\n// ─── Service ──────────────────────────────────────────────────────\n\nexport interface ReportServiceOptions {\n engine: ReportEngine;\n email?: ReportEmail;\n clock?: ReportClock;\n logger?: { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; error?: (msg: any, ...rest: any[]) => void };\n /** Cap rows per report to protect both DB and email size. */\n maxRows?: number;\n}\n\nexport class ReportService implements IReportService {\n private readonly engine: ReportEngine;\n private readonly email?: ReportEmail;\n private readonly clock: ReportClock;\n private readonly logger: NonNullable<ReportServiceOptions['logger']>;\n private readonly maxRows: number;\n\n constructor(opts: ReportServiceOptions) {\n this.engine = opts.engine;\n this.email = opts.email;\n this.clock = opts.clock ?? { now: () => new Date() };\n this.logger = opts.logger ?? {};\n this.maxRows = Math.max(1, opts.maxRows ?? 5000);\n }\n\n // ── Report CRUD ────────────────────────────────────────────────\n\n async saveReport(input: SaveReportInput, context: SharingExecutionContext): Promise<SavedReport> {\n if (!input.name) throw new Error('VALIDATION_FAILED: name is required');\n if (!input.object) throw new Error('VALIDATION_FAILED: object is required');\n if (!input.query) throw new Error('VALIDATION_FAILED: query is required');\n\n const now = this.clock.now().toISOString();\n const payload: any = {\n name: input.name,\n description: input.description ?? null,\n object_name: input.object,\n query_json: JSON.stringify(input.query ?? {}),\n format: input.format ?? DEFAULT_FORMAT,\n owner_id: input.ownerId ?? context.userId ?? null,\n updated_at: now,\n };\n\n if (input.id) {\n const existing = await this.engine.find('sys_saved_report', {\n filter: { id: input.id }, limit: 1, context: SYSTEM_CTX,\n });\n if (Array.isArray(existing) && existing[0]) {\n await this.engine.update('sys_saved_report', { id: input.id, ...payload }, { context: SYSTEM_CTX });\n return rowFromSaved({ ...existing[0], ...payload, id: input.id });\n }\n }\n\n const id = input.id ?? uid('rpt');\n const row = { id, ...payload, created_at: now };\n await this.engine.insert('sys_saved_report', row, { context: SYSTEM_CTX });\n return rowFromSaved(row);\n }\n\n async listReports(\n filter: { object?: string; ownerId?: string } | undefined,\n _context: SharingExecutionContext,\n ): Promise<SavedReport[]> {\n const f: any = {};\n if (filter?.object) f.object_name = filter.object;\n if (filter?.ownerId) f.owner_id = filter.ownerId;\n const rows = await this.engine.find('sys_saved_report', {\n filter: f, limit: 500, orderBy: [{ field: 'updated_at', order: 'desc' }], context: SYSTEM_CTX,\n });\n return Array.isArray(rows) ? rows.map(rowFromSaved) : [];\n }\n\n async getReport(reportId: string, _context: SharingExecutionContext): Promise<SavedReport | null> {\n const rows = await this.engine.find('sys_saved_report', {\n filter: { id: reportId }, limit: 1, context: SYSTEM_CTX,\n });\n return Array.isArray(rows) && rows[0] ? rowFromSaved(rows[0]) : null;\n }\n\n async deleteReport(reportId: string, _context: SharingExecutionContext): Promise<void> {\n if (!reportId) throw new Error('VALIDATION_FAILED: reportId is required');\n // Cascade — drop attached schedules first.\n const schedules = await this.engine.find('sys_report_schedule', {\n filter: { report_id: reportId }, limit: 500, context: SYSTEM_CTX,\n });\n for (const s of (schedules ?? [])) {\n await this.engine.delete('sys_report_schedule', { where: { id: (s as any).id }, context: SYSTEM_CTX });\n }\n await this.engine.delete('sys_saved_report', { where: { id: reportId }, context: SYSTEM_CTX });\n }\n\n // ── Execution ───────────────────────────────────────────────────\n\n async run(reportId: string, context: SharingExecutionContext): Promise<ReportRunResult> {\n const report = await this.getReport(reportId, context);\n if (!report) throw new Error(`REPORT_NOT_FOUND: ${reportId}`);\n return this.executeReport(report, context);\n }\n\n async runAdHoc(input: SaveReportInput, context: SharingExecutionContext): Promise<ReportRunResult> {\n if (!input.object) throw new Error('VALIDATION_FAILED: object is required');\n if (!input.query) throw new Error('VALIDATION_FAILED: query is required');\n const adhoc: SavedReport = {\n id: '__adhoc__',\n name: input.name ?? 'Ad-hoc report',\n object_name: input.object,\n query: input.query,\n format: input.format ?? DEFAULT_FORMAT,\n };\n return this.executeReport(adhoc, context, /* stamp */ false);\n }\n\n private async executeReport(\n report: SavedReport,\n context: SharingExecutionContext,\n stamp = true,\n ): Promise<ReportRunResult> {\n const q = report.query ?? {};\n const limit = Math.min(q.limit ?? DEFAULT_LIMIT, this.maxRows);\n const rows = await this.engine.find(report.object_name, {\n filter: q.filter,\n fields: q.fields,\n orderBy: q.orderBy,\n limit,\n // Reports execute with the caller's identity so sharing rules\n // (if installed) apply. Falls back to system bypass only when\n // the report definition was created by a system writer.\n context: {\n userId: context.userId,\n tenantId: context.tenantId,\n roles: context.roles ?? [],\n permissions: context.permissions ?? [],\n isSystem: context.isSystem ?? false,\n },\n });\n const list = Array.isArray(rows) ? rows : [];\n const body = renderReport(list, report.format, q.fields);\n const ranAt = this.clock.now().toISOString();\n\n if (stamp && report.id !== '__adhoc__') {\n try {\n await this.engine.update('sys_saved_report', {\n id: report.id,\n last_run_at: ranAt,\n last_row_count: list.length,\n updated_at: ranAt,\n }, { context: SYSTEM_CTX });\n } catch (err) {\n this.logger.warn?.('ReportService: failed to stamp last_run_at', err);\n }\n }\n\n return {\n reportId: report.id,\n rowCount: list.length,\n format: report.format,\n body,\n rows: list,\n ranAt,\n };\n }\n\n // ── Schedules ──────────────────────────────────────────────────\n\n async scheduleReport(input: ScheduleReportInput, context: SharingExecutionContext): Promise<ReportSchedule> {\n if (!input.reportId) throw new Error('VALIDATION_FAILED: reportId is required');\n if (!input.recipients || input.recipients.length === 0) {\n throw new Error('VALIDATION_FAILED: recipients must be a non-empty array');\n }\n const report = await this.getReport(input.reportId, context);\n if (!report) throw new Error(`REPORT_NOT_FOUND: ${input.reportId}`);\n\n const now = this.clock.now();\n const interval = input.intervalMinutes ?? DEFAULT_INTERVAL_MIN;\n const nextRun = new Date(now.getTime() + interval * 60_000).toISOString();\n const id = uid('rsch');\n const row: any = {\n id,\n report_id: input.reportId,\n name: input.name ?? null,\n interval_minutes: interval,\n cron_expression: input.cronExpression ?? null,\n timezone: input.timezone ?? 'UTC',\n active: input.active !== false,\n recipients: input.recipients.join(','),\n format: input.format ?? 'html_table',\n subject_template: input.subjectTemplate ?? null,\n owner_id: input.ownerId ?? context.userId ?? null,\n next_run_at: nextRun,\n created_at: now.toISOString(),\n updated_at: now.toISOString(),\n };\n await this.engine.insert('sys_report_schedule', row, { context: SYSTEM_CTX });\n return rowFromSchedule(row);\n }\n\n async unscheduleReport(scheduleId: string, _context: SharingExecutionContext): Promise<void> {\n if (!scheduleId) throw new Error('VALIDATION_FAILED: scheduleId is required');\n await this.engine.delete('sys_report_schedule', { where: { id: scheduleId }, context: SYSTEM_CTX });\n }\n\n async listSchedules(\n filter: { reportId?: string } | undefined,\n _context: SharingExecutionContext,\n ): Promise<ReportSchedule[]> {\n const f: any = {};\n if (filter?.reportId) f.report_id = filter.reportId;\n const rows = await this.engine.find('sys_report_schedule', {\n filter: f, limit: 500, orderBy: [{ field: 'next_run_at', order: 'asc' }], context: SYSTEM_CTX,\n });\n return Array.isArray(rows) ? rows.map(rowFromSchedule) : [];\n }\n\n // ── Dispatcher ─────────────────────────────────────────────────\n\n async dispatchDue(now?: Date): Promise<{ fired: number; failed: number; skipped: number }> {\n const ts = (now ?? this.clock.now()).toISOString();\n const due = await this.engine.find('sys_report_schedule', {\n filter: { active: true },\n limit: 200,\n context: SYSTEM_CTX,\n });\n const list = (Array.isArray(due) ? due : []).map(rowFromSchedule)\n .filter(s => !s.next_run_at || s.next_run_at <= ts);\n\n let fired = 0, failed = 0, skipped = 0;\n for (const schedule of list) {\n try {\n const report = await this.getReport(schedule.report_id, { isSystem: true });\n if (!report) {\n skipped++;\n await this.markSchedule(schedule.id, {\n last_status: 'skipped',\n last_error: `report ${schedule.report_id} missing`,\n });\n continue;\n }\n // Force the schedule's own format so the recipient gets what\n // the admin configured (CSV attachment vs inline HTML table).\n const fmt: ReportFormat = (schedule.format ?? 'html_table') as ReportFormat;\n const result = await this.executeReport({ ...report, format: fmt }, { isSystem: true }, false);\n\n const recipients = schedule.recipients.split(',').map(s => s.trim()).filter(Boolean);\n const subject = renderSubject(schedule.subject_template, {\n name: schedule.name ?? report.name,\n date: ts.slice(0, 10),\n rows: String(result.rowCount),\n });\n\n if (this.email && recipients.length > 0) {\n if (fmt === 'csv') {\n await this.email.send({\n to: recipients,\n subject,\n text: `Attached: ${result.rowCount} row(s).`,\n attachments: [{\n filename: `${(schedule.name ?? report.name).replace(/[^\\w.-]+/g, '_')}-${ts.slice(0, 10)}.csv`,\n content: result.body,\n contentType: 'text/csv',\n }],\n relatedObject: 'sys_report_schedule',\n relatedId: schedule.id,\n });\n } else {\n await this.email.send({\n to: recipients,\n subject,\n html: `<p>${escapeHtml(report.name)} — ${result.rowCount} row(s)</p>${result.body}`,\n text: `${report.name} — ${result.rowCount} row(s)`,\n relatedObject: 'sys_report_schedule',\n relatedId: schedule.id,\n });\n }\n } else if (!this.email) {\n this.logger.warn?.('ReportService.dispatchDue: no email service — schedule fired but mail not sent');\n }\n\n await this.advanceSchedule(schedule, ts);\n fired++;\n } catch (err: any) {\n failed++;\n await this.markSchedule(schedule.id, {\n last_status: 'failed',\n last_error: String(err?.message ?? err ?? 'unknown').slice(0, 500),\n });\n this.logger.error?.('ReportService.dispatchDue: schedule failed', err);\n }\n }\n return { fired, failed, skipped };\n }\n\n private async advanceSchedule(schedule: ReportSchedule, ranAt: string): Promise<void> {\n const interval = schedule.interval_minutes ?? DEFAULT_INTERVAL_MIN;\n const nextRun = new Date(this.clock.now().getTime() + interval * 60_000).toISOString();\n await this.engine.update('sys_report_schedule', {\n id: schedule.id,\n next_run_at: nextRun,\n last_sent_at: ranAt,\n last_status: 'ok',\n last_error: null,\n updated_at: ranAt,\n }, { context: SYSTEM_CTX });\n }\n\n private async markSchedule(id: string, patch: Record<string, unknown>): Promise<void> {\n try {\n await this.engine.update('sys_report_schedule', {\n id, ...patch, updated_at: this.clock.now().toISOString(),\n }, { context: SYSTEM_CTX });\n } catch (err) {\n this.logger.warn?.('ReportService: failed to mark schedule', err);\n }\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport {\n SysSavedReport,\n SysReportSchedule,\n} from '@objectstack/platform-objects/audit';\nimport { ReportService, type ReportEngine, type ReportEmail } from './report-service.js';\n\nexport interface ReportsPluginOptions {\n /**\n * How often the dispatcher should poll `sys_report_schedule` for\n * due rows. Defaults to 60 seconds — short enough to honour\n * minute-grained schedules without flooding the DB.\n */\n dispatchIntervalMs?: number;\n /** Cap rows per report. Mirrors ReportServiceOptions.maxRows. */\n maxRows?: number;\n /** Disable the dispatcher tick entirely. */\n disableDispatcher?: boolean;\n}\n\n/**\n * ReportsServicePlugin — registers `sys_saved_report` /\n * `sys_report_schedule`, the `reports` service, and the dispatcher\n * loop that emails due schedules.\n *\n * The dispatcher uses `IJobService.schedule` when one is registered;\n * otherwise it falls back to a plain `setInterval` so single-kernel\n * deployments work without `service-job`.\n *\n * @example\n * ```ts\n * import { ReportsServicePlugin } from '@objectstack/plugin-reports';\n *\n * kernel.use(new ReportsServicePlugin({ dispatchIntervalMs: 60_000 }));\n * ```\n */\nexport class ReportsServicePlugin implements Plugin {\n name = 'com.objectstack.service.reports';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.engine.objectql'];\n\n private readonly options: ReportsPluginOptions;\n private service?: ReportService;\n private intervalHandle?: ReturnType<typeof setInterval>;\n private jobName?: string;\n private jobService?: any;\n\n constructor(options: ReportsPluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n ctx.getService<{ register(m: any): void }>('manifest').register({\n id: 'com.objectstack.service.reports',\n name: 'Reports Service',\n version: '1.0.0',\n type: 'plugin',\n scope: 'system',\n defaultDatasource: 'cloud',\n namespace: 'sys',\n objects: [SysSavedReport, SysReportSchedule],\n });\n ctx.logger.info('ReportsServicePlugin: schemas registered');\n }\n\n async start(ctx: PluginContext): Promise<void> {\n ctx.hook('kernel:ready', async () => {\n let engine: any = null;\n try { engine = ctx.getService<any>('objectql'); }\n catch { try { engine = ctx.getService<any>('data'); } catch { /* ignore */ } }\n if (!engine) {\n ctx.logger.warn('ReportsServicePlugin: no ObjectQL engine — service NOT registered');\n return;\n }\n\n let email: ReportEmail | undefined;\n try { email = ctx.getService<any>('email'); } catch { /* email is optional */ }\n if (!email) {\n ctx.logger.warn('ReportsServicePlugin: no email service — schedules will fire without delivery');\n }\n\n this.service = new ReportService({\n engine: engine as ReportEngine,\n email,\n logger: ctx.logger,\n maxRows: this.options.maxRows,\n });\n ctx.registerService('reports', this.service);\n\n if (this.options.disableDispatcher) {\n ctx.logger.info('ReportsServicePlugin: dispatcher disabled (disableDispatcher=true)');\n return;\n }\n\n const intervalMs = Math.max(5_000, this.options.dispatchIntervalMs ?? 60_000);\n\n // Prefer the platform job service when available — it lets ops\n // see report dispatch alongside every other scheduled job.\n try {\n const job = ctx.getService<any>('job');\n if (job && typeof job.schedule === 'function') {\n this.jobService = job;\n this.jobName = 'reports.dispatch';\n await job.schedule(this.jobName, { type: 'interval', intervalMs }, async () => {\n try { await this.service?.dispatchDue(); }\n catch (err) { ctx.logger.warn('ReportsServicePlugin: dispatch tick failed', err as any); }\n });\n ctx.logger.info('ReportsServicePlugin: dispatcher registered with job service', { intervalMs });\n return;\n }\n } catch { /* fall through to setInterval */ }\n\n this.intervalHandle = setInterval(() => {\n this.service?.dispatchDue().catch(err => {\n ctx.logger.warn('ReportsServicePlugin: dispatch tick failed', err);\n });\n }, intervalMs);\n // Don't keep Node alive purely for the dispatcher — common\n // mistake in tests / serverless. unref is a no-op in some\n // runtimes which is fine.\n (this.intervalHandle as any)?.unref?.();\n ctx.logger.info('ReportsServicePlugin: dispatcher registered (setInterval fallback)', { intervalMs });\n });\n }\n\n async stop(ctx: PluginContext): Promise<void> {\n if (this.intervalHandle) clearInterval(this.intervalHandle);\n this.intervalHandle = undefined;\n if (this.jobService && this.jobName && typeof this.jobService.cancel === 'function') {\n try { await this.jobService.cancel(this.jobName); }\n catch (err) { ctx.logger.warn('ReportsServicePlugin: failed to cancel job', err as any); }\n }\n }\n}\n"],"mappings":"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","names":["import_audit"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/report-service.ts","../src/reports-plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * @objectstack/plugin-reports\n *\n * Saved reports + scheduled email digests for ObjectStack.\n * Persists `sys_saved_report` definitions and `sys_report_schedule`\n * rows, then drives a dispatcher that runs due schedules and emails\n * the rendered output via the configured `email` service.\n */\n\nexport { SysSavedReport, SysReportSchedule } from '@objectstack/platform-objects/audit';\nexport {\n ReportService,\n renderReport,\n type ReportEngine,\n type ReportEmail,\n type ReportClock,\n type ReportServiceOptions,\n} from './report-service.js';\nexport {\n ReportsServicePlugin,\n type ReportsPluginOptions,\n} from './reports-plugin.js';\nexport type {\n IReportService,\n SavedReport,\n ReportSchedule,\n ReportQuery,\n ReportRunResult,\n ReportFormat,\n SaveReportInput,\n ScheduleReportInput,\n} from '@objectstack/spec/contracts';\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type {\n IReportService,\n SavedReport,\n ReportSchedule,\n ReportQuery,\n ReportRunResult,\n ReportFormat,\n SaveReportInput,\n ScheduleReportInput,\n SharingExecutionContext,\n} from '@objectstack/spec/contracts';\nimport { Cron } from 'croner';\n\n/**\n * Narrow engine surface — keeps the service testable without booting\n * a real ObjectQL kernel.\n */\nexport interface ReportEngine {\n find(object: string, options?: any): Promise<any[]>;\n findOne?(object: string, options?: any): Promise<any>;\n insert(object: string, data: any, options?: any): Promise<any>;\n update(object: string, idOrData: any, dataOrOptions?: any, options?: any): Promise<any>;\n delete(object: string, options?: any): Promise<any>;\n}\n\n/**\n * Minimum email surface — implementations may pass the full\n * `IEmailService` instance straight through.\n */\nexport interface ReportEmail {\n send(input: {\n to: string | string[];\n subject: string;\n text?: string;\n html?: string;\n attachments?: Array<{ filename: string; content: string; contentType?: string }>;\n relatedObject?: string;\n relatedId?: string;\n }): Promise<{ status: 'sent' | 'queued' | 'failed' }>;\n}\n\n/** Stamped only in tests / specialised callers to make `now` deterministic. */\nexport interface ReportClock { now(): Date }\n\nconst SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;\n\nconst DEFAULT_FORMAT: ReportFormat = 'csv';\nconst DEFAULT_INTERVAL_MIN = 1440;\nconst DEFAULT_LIMIT = 1000;\n\nfunction uid(prefix: string): string {\n const g: any = globalThis as any;\n if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction parseQuery(raw: unknown): ReportQuery {\n if (!raw) return {};\n if (typeof raw === 'string') {\n try { return JSON.parse(raw) as ReportQuery; }\n catch { return {}; }\n }\n if (typeof raw === 'object') return raw as ReportQuery;\n return {};\n}\n\nfunction rowFromSaved(row: any): SavedReport {\n return {\n id: String(row.id),\n name: String(row.name ?? ''),\n description: row.description ?? undefined,\n object_name: String(row.object_name ?? ''),\n query: parseQuery(row.query_json),\n format: (row.format as ReportFormat) ?? DEFAULT_FORMAT,\n owner_id: row.owner_id ?? undefined,\n last_run_at: row.last_run_at ?? undefined,\n last_row_count: row.last_row_count ?? undefined,\n created_at: row.created_at ?? undefined,\n updated_at: row.updated_at ?? undefined,\n };\n}\n\nfunction rowFromSchedule(row: any): ReportSchedule {\n return {\n id: String(row.id),\n report_id: String(row.report_id),\n name: row.name ?? undefined,\n interval_minutes: row.interval_minutes ?? undefined,\n cron_expression: row.cron_expression ?? undefined,\n timezone: row.timezone ?? undefined,\n active: row.active !== false,\n recipients: String(row.recipients ?? ''),\n format: row.format ?? undefined,\n subject_template: row.subject_template ?? undefined,\n owner_id: row.owner_id ?? undefined,\n next_run_at: row.next_run_at ?? undefined,\n last_sent_at: row.last_sent_at ?? undefined,\n last_status: row.last_status ?? undefined,\n last_error: row.last_error ?? undefined,\n };\n}\n\n// ─── Rendering ─────────────────────────────────────────────────────\n\nfunction escapeCsvCell(v: unknown): string {\n if (v == null) return '';\n const s = typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));\n if (/[\",\\r\\n]/.test(s)) return `\"${s.replace(/\"/g, '\"\"')}\"`;\n return s;\n}\n\nfunction pickFields(rows: any[], explicit?: string[]): string[] {\n if (explicit && explicit.length > 0) return explicit;\n const seen = new Set<string>();\n for (const r of rows.slice(0, 50)) {\n if (r && typeof r === 'object') for (const k of Object.keys(r)) seen.add(k);\n }\n return Array.from(seen);\n}\n\nfunction renderCsv(rows: any[], fields?: string[]): string {\n const cols = pickFields(rows, fields);\n const head = cols.join(',');\n const body = rows.map(r => cols.map(c => escapeCsvCell(r?.[c])).join(',')).join('\\r\\n');\n return body.length > 0 ? `${head}\\r\\n${body}` : head;\n}\n\nfunction renderJson(rows: any[]): string {\n return JSON.stringify(rows, null, 2);\n}\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({\n '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;',\n } as Record<string, string>)[c]);\n}\n\nfunction renderHtmlTable(rows: any[], fields?: string[]): string {\n const cols = pickFields(rows, fields);\n const th = cols.map(c => `<th style=\"text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;\">${escapeHtml(c)}</th>`).join('');\n const trs = rows.map(r => {\n const tds = cols.map(c => {\n const v = r?.[c];\n const s = v == null ? '' : (typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v)));\n return `<td style=\"padding:4px 8px;border-bottom:1px solid #eee;\">${escapeHtml(s)}</td>`;\n }).join('');\n return `<tr>${tds}</tr>`;\n }).join('');\n return `<table style=\"border-collapse:collapse;font-family:system-ui,Arial,sans-serif;font-size:13px;\">`\n + `<thead><tr>${th}</tr></thead><tbody>${trs}</tbody></table>`;\n}\n\nexport function renderReport(rows: any[], format: ReportFormat, fields?: string[]): string {\n switch (format) {\n case 'json': return renderJson(rows);\n case 'html_table': return renderHtmlTable(rows, fields);\n case 'csv':\n default: return renderCsv(rows, fields);\n }\n}\n\n// ─── Subject templating (minimal {{var}}) ─────────────────────────\n\nfunction renderSubject(template: string | undefined, vars: Record<string, string>): string {\n const tpl = template ?? '{{name}} — {{date}}';\n return tpl.replace(/\\{\\{\\s*(\\w+)\\s*\\}\\}/g, (_m, k) => vars[String(k)] ?? '');\n}\n\n// ─── Service ──────────────────────────────────────────────────────\n\nexport interface ReportServiceOptions {\n engine: ReportEngine;\n email?: ReportEmail;\n clock?: ReportClock;\n logger?: { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; error?: (msg: any, ...rest: any[]) => void };\n /** Cap rows per report to protect both DB and email size. */\n maxRows?: number;\n}\n\nexport class ReportService implements IReportService {\n private readonly engine: ReportEngine;\n private readonly email?: ReportEmail;\n private readonly clock: ReportClock;\n private readonly logger: NonNullable<ReportServiceOptions['logger']>;\n private readonly maxRows: number;\n\n constructor(opts: ReportServiceOptions) {\n this.engine = opts.engine;\n this.email = opts.email;\n this.clock = opts.clock ?? { now: () => new Date() };\n this.logger = opts.logger ?? {};\n this.maxRows = Math.max(1, opts.maxRows ?? 5000);\n }\n\n // ── Report CRUD ────────────────────────────────────────────────\n\n async saveReport(input: SaveReportInput, context: SharingExecutionContext): Promise<SavedReport> {\n if (!input.name) throw new Error('VALIDATION_FAILED: name is required');\n if (!input.object) throw new Error('VALIDATION_FAILED: object is required');\n if (!input.query) throw new Error('VALIDATION_FAILED: query is required');\n\n const now = this.clock.now().toISOString();\n const payload: any = {\n name: input.name,\n description: input.description ?? null,\n object_name: input.object,\n query_json: JSON.stringify(input.query ?? {}),\n format: input.format ?? DEFAULT_FORMAT,\n owner_id: input.ownerId ?? context.userId ?? null,\n updated_at: now,\n };\n\n if (input.id) {\n const existing = await this.engine.find('sys_saved_report', {\n filter: { id: input.id }, limit: 1, context: SYSTEM_CTX,\n });\n if (Array.isArray(existing) && existing[0]) {\n await this.engine.update('sys_saved_report', { id: input.id, ...payload }, { context: SYSTEM_CTX });\n return rowFromSaved({ ...existing[0], ...payload, id: input.id });\n }\n }\n\n const id = input.id ?? uid('rpt');\n const row = { id, ...payload, created_at: now };\n await this.engine.insert('sys_saved_report', row, { context: SYSTEM_CTX });\n return rowFromSaved(row);\n }\n\n async listReports(\n filter: { object?: string; ownerId?: string } | undefined,\n _context: SharingExecutionContext,\n ): Promise<SavedReport[]> {\n const f: any = {};\n if (filter?.object) f.object_name = filter.object;\n if (filter?.ownerId) f.owner_id = filter.ownerId;\n const rows = await this.engine.find('sys_saved_report', {\n filter: f, limit: 500, orderBy: [{ field: 'updated_at', order: 'desc' }], context: SYSTEM_CTX,\n });\n return Array.isArray(rows) ? rows.map(rowFromSaved) : [];\n }\n\n async getReport(reportId: string, _context: SharingExecutionContext): Promise<SavedReport | null> {\n const rows = await this.engine.find('sys_saved_report', {\n filter: { id: reportId }, limit: 1, context: SYSTEM_CTX,\n });\n return Array.isArray(rows) && rows[0] ? rowFromSaved(rows[0]) : null;\n }\n\n async deleteReport(reportId: string, _context: SharingExecutionContext): Promise<void> {\n if (!reportId) throw new Error('VALIDATION_FAILED: reportId is required');\n // Cascade — drop attached schedules first.\n const schedules = await this.engine.find('sys_report_schedule', {\n filter: { report_id: reportId }, limit: 500, context: SYSTEM_CTX,\n });\n for (const s of (schedules ?? [])) {\n await this.engine.delete('sys_report_schedule', { where: { id: (s as any).id }, context: SYSTEM_CTX });\n }\n await this.engine.delete('sys_saved_report', { where: { id: reportId }, context: SYSTEM_CTX });\n }\n\n // ── Execution ───────────────────────────────────────────────────\n\n async run(reportId: string, context: SharingExecutionContext): Promise<ReportRunResult> {\n const report = await this.getReport(reportId, context);\n if (!report) throw new Error(`REPORT_NOT_FOUND: ${reportId}`);\n return this.executeReport(report, context);\n }\n\n async runAdHoc(input: SaveReportInput, context: SharingExecutionContext): Promise<ReportRunResult> {\n if (!input.object) throw new Error('VALIDATION_FAILED: object is required');\n if (!input.query) throw new Error('VALIDATION_FAILED: query is required');\n const adhoc: SavedReport = {\n id: '__adhoc__',\n name: input.name ?? 'Ad-hoc report',\n object_name: input.object,\n query: input.query,\n format: input.format ?? DEFAULT_FORMAT,\n };\n return this.executeReport(adhoc, context, /* stamp */ false);\n }\n\n private async executeReport(\n report: SavedReport,\n context: SharingExecutionContext,\n stamp = true,\n ): Promise<ReportRunResult> {\n const q = report.query ?? {};\n const limit = Math.min(q.limit ?? DEFAULT_LIMIT, this.maxRows);\n const rows = await this.engine.find(report.object_name, {\n filter: q.filter,\n fields: q.fields,\n orderBy: q.orderBy,\n limit,\n // Reports execute with the caller's identity so sharing rules\n // (if installed) apply. Falls back to system bypass only when\n // the report definition was created by a system writer.\n context: {\n userId: context.userId,\n tenantId: context.tenantId,\n roles: context.roles ?? [],\n permissions: context.permissions ?? [],\n isSystem: context.isSystem ?? false,\n },\n });\n const list = Array.isArray(rows) ? rows : [];\n const body = renderReport(list, report.format, q.fields);\n const ranAt = this.clock.now().toISOString();\n\n if (stamp && report.id !== '__adhoc__') {\n try {\n await this.engine.update('sys_saved_report', {\n id: report.id,\n last_run_at: ranAt,\n last_row_count: list.length,\n updated_at: ranAt,\n }, { context: SYSTEM_CTX });\n } catch (err) {\n this.logger.warn?.('ReportService: failed to stamp last_run_at', err);\n }\n }\n\n return {\n reportId: report.id,\n rowCount: list.length,\n format: report.format,\n body,\n rows: list,\n ranAt,\n };\n }\n\n // ── Schedules ──────────────────────────────────────────────────\n\n async scheduleReport(input: ScheduleReportInput, context: SharingExecutionContext): Promise<ReportSchedule> {\n if (!input.reportId) throw new Error('VALIDATION_FAILED: reportId is required');\n if (!input.recipients || input.recipients.length === 0) {\n throw new Error('VALIDATION_FAILED: recipients must be a non-empty array');\n }\n const report = await this.getReport(input.reportId, context);\n if (!report) throw new Error(`REPORT_NOT_FOUND: ${input.reportId}`);\n\n const now = this.clock.now();\n const interval = input.intervalMinutes ?? DEFAULT_INTERVAL_MIN;\n const cron = input.cronExpression?.trim() || null;\n if (cron) {\n // Validate eagerly so an author gets a clear error at schedule time\n // instead of a schedule that silently falls back to interval on sweep.\n try {\n new Cron(cron, { timezone: input.timezone || 'UTC' });\n } catch (err) {\n throw new Error(`VALIDATION_FAILED: invalid cron_expression '${cron}': ${(err as Error).message}`);\n }\n }\n const nextRun = this.nextRunAt(\n { cron_expression: cron, interval_minutes: interval, timezone: input.timezone ?? 'UTC' },\n now,\n ).toISOString();\n const id = uid('rsch');\n const row: any = {\n id,\n report_id: input.reportId,\n name: input.name ?? null,\n interval_minutes: interval,\n cron_expression: cron,\n timezone: input.timezone ?? 'UTC',\n active: input.active !== false,\n recipients: input.recipients.join(','),\n format: input.format ?? 'html_table',\n subject_template: input.subjectTemplate ?? null,\n owner_id: input.ownerId ?? context.userId ?? null,\n next_run_at: nextRun,\n created_at: now.toISOString(),\n updated_at: now.toISOString(),\n };\n await this.engine.insert('sys_report_schedule', row, { context: SYSTEM_CTX });\n return rowFromSchedule(row);\n }\n\n async unscheduleReport(scheduleId: string, _context: SharingExecutionContext): Promise<void> {\n if (!scheduleId) throw new Error('VALIDATION_FAILED: scheduleId is required');\n await this.engine.delete('sys_report_schedule', { where: { id: scheduleId }, context: SYSTEM_CTX });\n }\n\n async listSchedules(\n filter: { reportId?: string } | undefined,\n _context: SharingExecutionContext,\n ): Promise<ReportSchedule[]> {\n const f: any = {};\n if (filter?.reportId) f.report_id = filter.reportId;\n const rows = await this.engine.find('sys_report_schedule', {\n filter: f, limit: 500, orderBy: [{ field: 'next_run_at', order: 'asc' }], context: SYSTEM_CTX,\n });\n return Array.isArray(rows) ? rows.map(rowFromSchedule) : [];\n }\n\n // ── Dispatcher ─────────────────────────────────────────────────\n\n async dispatchDue(now?: Date): Promise<{ fired: number; failed: number; skipped: number }> {\n const ts = (now ?? this.clock.now()).toISOString();\n const due = await this.engine.find('sys_report_schedule', {\n filter: { active: true },\n limit: 200,\n context: SYSTEM_CTX,\n });\n const list = (Array.isArray(due) ? due : []).map(rowFromSchedule)\n .filter(s => !s.next_run_at || s.next_run_at <= ts);\n\n let fired = 0, failed = 0, skipped = 0;\n for (const schedule of list) {\n try {\n const report = await this.getReport(schedule.report_id, { isSystem: true });\n if (!report) {\n skipped++;\n await this.markSchedule(schedule.id, {\n last_status: 'skipped',\n last_error: `report ${schedule.report_id} missing`,\n });\n continue;\n }\n // Force the schedule's own format so the recipient gets what\n // the admin configured (CSV attachment vs inline HTML table).\n const fmt: ReportFormat = (schedule.format ?? 'html_table') as ReportFormat;\n const result = await this.executeReport({ ...report, format: fmt }, { isSystem: true }, false);\n\n const recipients = schedule.recipients.split(',').map(s => s.trim()).filter(Boolean);\n const subject = renderSubject(schedule.subject_template, {\n name: schedule.name ?? report.name,\n date: ts.slice(0, 10),\n rows: String(result.rowCount),\n });\n\n if (this.email && recipients.length > 0) {\n if (fmt === 'csv') {\n await this.email.send({\n to: recipients,\n subject,\n text: `Attached: ${result.rowCount} row(s).`,\n attachments: [{\n filename: `${(schedule.name ?? report.name).replace(/[^\\w.-]+/g, '_')}-${ts.slice(0, 10)}.csv`,\n content: result.body,\n contentType: 'text/csv',\n }],\n relatedObject: 'sys_report_schedule',\n relatedId: schedule.id,\n });\n } else {\n await this.email.send({\n to: recipients,\n subject,\n html: `<p>${escapeHtml(report.name)} — ${result.rowCount} row(s)</p>${result.body}`,\n text: `${report.name} — ${result.rowCount} row(s)`,\n relatedObject: 'sys_report_schedule',\n relatedId: schedule.id,\n });\n }\n } else if (!this.email) {\n this.logger.warn?.('ReportService.dispatchDue: no email service — schedule fired but mail not sent');\n }\n\n await this.advanceSchedule(schedule, ts);\n fired++;\n } catch (err: any) {\n failed++;\n await this.markSchedule(schedule.id, {\n last_status: 'failed',\n last_error: String(err?.message ?? err ?? 'unknown').slice(0, 500),\n });\n this.logger.error?.('ReportService.dispatchDue: schedule failed', err);\n }\n }\n return { fired, failed, skipped };\n }\n\n /**\n * Compute the next fire time for a schedule. A `cron_expression` wins over\n * `interval_minutes` (the documented `sys_report_schedule` contract) and is\n * evaluated in the schedule's `timezone` (default UTC) via croner — the same\n * library the job scheduler uses. Falls back to `from + interval_minutes` for\n * interval schedules, and also if a cron expression is invalid or has no\n * future occurrence (logged; never throws into the sweep). `from` is the\n * reference instant (the injected clock), so `today()`-style boundaries honor\n * the test clock.\n */\n private nextRunAt(\n schedule: { cron_expression?: string | null; interval_minutes?: number | null; timezone?: string | null },\n from: Date,\n ): Date {\n const cron = (schedule.cron_expression ?? '').trim();\n if (cron) {\n try {\n const next = new Cron(cron, { timezone: schedule.timezone || 'UTC' }).nextRun(from);\n if (next) return next;\n this.logger.warn?.(`ReportService: cron '${cron}' has no next occurrence; falling back to interval`);\n } catch (err) {\n this.logger.warn?.(`ReportService: invalid cron '${cron}'; falling back to interval`, err);\n }\n }\n const interval = schedule.interval_minutes ?? DEFAULT_INTERVAL_MIN;\n return new Date(from.getTime() + interval * 60_000);\n }\n\n private async advanceSchedule(schedule: ReportSchedule, ranAt: string): Promise<void> {\n const nextRun = this.nextRunAt(schedule, this.clock.now()).toISOString();\n await this.engine.update('sys_report_schedule', {\n id: schedule.id,\n next_run_at: nextRun,\n last_sent_at: ranAt,\n last_status: 'ok',\n last_error: null,\n updated_at: ranAt,\n }, { context: SYSTEM_CTX });\n }\n\n private async markSchedule(id: string, patch: Record<string, unknown>): Promise<void> {\n try {\n await this.engine.update('sys_report_schedule', {\n id, ...patch, updated_at: this.clock.now().toISOString(),\n }, { context: SYSTEM_CTX });\n } catch (err) {\n this.logger.warn?.('ReportService: failed to mark schedule', err);\n }\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport {\n SysSavedReport,\n SysReportSchedule,\n} from '@objectstack/platform-objects/audit';\nimport { ReportService, type ReportEngine, type ReportEmail } from './report-service.js';\n\nexport interface ReportsPluginOptions {\n /**\n * How often the dispatcher should poll `sys_report_schedule` for\n * due rows. Defaults to 60 seconds — short enough to honour\n * minute-grained schedules without flooding the DB.\n */\n dispatchIntervalMs?: number;\n /** Cap rows per report. Mirrors ReportServiceOptions.maxRows. */\n maxRows?: number;\n /** Disable the dispatcher tick entirely. */\n disableDispatcher?: boolean;\n}\n\n/**\n * ReportsServicePlugin — registers `sys_saved_report` /\n * `sys_report_schedule`, the `reports` service, and the dispatcher\n * loop that emails due schedules.\n *\n * The dispatcher uses `IJobService.schedule` when one is registered;\n * otherwise it falls back to a plain `setInterval` so single-kernel\n * deployments work without `service-job`.\n *\n * @example\n * ```ts\n * import { ReportsServicePlugin } from '@objectstack/plugin-reports';\n *\n * kernel.use(new ReportsServicePlugin({ dispatchIntervalMs: 60_000 }));\n * ```\n */\nexport class ReportsServicePlugin implements Plugin {\n name = 'com.objectstack.service.reports';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.engine.objectql'];\n\n private readonly options: ReportsPluginOptions;\n private service?: ReportService;\n private intervalHandle?: ReturnType<typeof setInterval>;\n private jobName?: string;\n private jobService?: any;\n\n constructor(options: ReportsPluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n ctx.getService<{ register(m: any): void }>('manifest').register({\n id: 'com.objectstack.service.reports',\n name: 'Reports Service',\n version: '1.0.0',\n type: 'plugin',\n scope: 'system',\n defaultDatasource: 'cloud',\n namespace: 'sys',\n objects: [SysSavedReport, SysReportSchedule],\n });\n ctx.logger.info('ReportsServicePlugin: schemas registered');\n }\n\n async start(ctx: PluginContext): Promise<void> {\n ctx.hook('kernel:ready', async () => {\n let engine: any = null;\n try { engine = ctx.getService<any>('objectql'); }\n catch { try { engine = ctx.getService<any>('data'); } catch { /* ignore */ } }\n if (!engine) {\n ctx.logger.warn('ReportsServicePlugin: no ObjectQL engine — service NOT registered');\n return;\n }\n\n let email: ReportEmail | undefined;\n try { email = ctx.getService<any>('email'); } catch { /* email is optional */ }\n if (!email) {\n ctx.logger.warn('ReportsServicePlugin: no email service — schedules will fire without delivery');\n }\n\n this.service = new ReportService({\n engine: engine as ReportEngine,\n email,\n logger: ctx.logger,\n maxRows: this.options.maxRows,\n });\n ctx.registerService('reports', this.service);\n\n if (this.options.disableDispatcher) {\n ctx.logger.info('ReportsServicePlugin: dispatcher disabled (disableDispatcher=true)');\n return;\n }\n\n const intervalMs = Math.max(5_000, this.options.dispatchIntervalMs ?? 60_000);\n\n // Prefer the platform job service when available — it lets ops\n // see report dispatch alongside every other scheduled job.\n try {\n const job = ctx.getService<any>('job');\n if (job && typeof job.schedule === 'function') {\n this.jobService = job;\n this.jobName = 'reports.dispatch';\n await job.schedule(this.jobName, { type: 'interval', intervalMs }, async () => {\n try { await this.service?.dispatchDue(); }\n catch (err) { ctx.logger.warn('ReportsServicePlugin: dispatch tick failed', err as any); }\n });\n ctx.logger.info('ReportsServicePlugin: dispatcher registered with job service', { intervalMs });\n return;\n }\n } catch { /* fall through to setInterval */ }\n\n this.intervalHandle = setInterval(() => {\n this.service?.dispatchDue().catch(err => {\n ctx.logger.warn('ReportsServicePlugin: dispatch tick failed', err);\n });\n }, intervalMs);\n // Don't keep Node alive purely for the dispatcher — common\n // mistake in tests / serverless. unref is a no-op in some\n // runtimes which is fine.\n (this.intervalHandle as any)?.unref?.();\n ctx.logger.info('ReportsServicePlugin: dispatcher registered (setInterval fallback)', { intervalMs });\n });\n }\n\n async stop(ctx: PluginContext): Promise<void> {\n if (this.intervalHandle) clearInterval(this.intervalHandle);\n this.intervalHandle = undefined;\n if (this.jobService && this.jobName && typeof this.jobService.cancel === 'function') {\n try { await this.jobService.cancel(this.jobName); }\n catch (err) { ctx.logger.warn('ReportsServicePlugin: failed to cancel job', err as any); }\n }\n }\n}\n"],"mappings":"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","names":["import_audit"]}
package/dist/index.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { SysSavedReport as SysSavedReport2, SysReportSchedule as SysReportSchedule2 } from "@objectstack/platform-objects/audit";
3
3
 
4
4
  // src/report-service.ts
5
+ import { Cron } from "croner";
5
6
  var SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
6
7
  var DEFAULT_FORMAT = "csv";
7
8
  var DEFAULT_INTERVAL_MIN = 1440;
@@ -265,14 +266,25 @@ var ReportService = class {
265
266
  if (!report) throw new Error(`REPORT_NOT_FOUND: ${input.reportId}`);
266
267
  const now = this.clock.now();
267
268
  const interval = input.intervalMinutes ?? DEFAULT_INTERVAL_MIN;
268
- const nextRun = new Date(now.getTime() + interval * 6e4).toISOString();
269
+ const cron = input.cronExpression?.trim() || null;
270
+ if (cron) {
271
+ try {
272
+ new Cron(cron, { timezone: input.timezone || "UTC" });
273
+ } catch (err) {
274
+ throw new Error(`VALIDATION_FAILED: invalid cron_expression '${cron}': ${err.message}`);
275
+ }
276
+ }
277
+ const nextRun = this.nextRunAt(
278
+ { cron_expression: cron, interval_minutes: interval, timezone: input.timezone ?? "UTC" },
279
+ now
280
+ ).toISOString();
269
281
  const id = uid("rsch");
270
282
  const row = {
271
283
  id,
272
284
  report_id: input.reportId,
273
285
  name: input.name ?? null,
274
286
  interval_minutes: interval,
275
- cron_expression: input.cronExpression ?? null,
287
+ cron_expression: cron,
276
288
  timezone: input.timezone ?? "UTC",
277
289
  active: input.active !== false,
278
290
  recipients: input.recipients.join(","),
@@ -370,9 +382,32 @@ var ReportService = class {
370
382
  }
371
383
  return { fired, failed, skipped };
372
384
  }
373
- async advanceSchedule(schedule, ranAt) {
385
+ /**
386
+ * Compute the next fire time for a schedule. A `cron_expression` wins over
387
+ * `interval_minutes` (the documented `sys_report_schedule` contract) and is
388
+ * evaluated in the schedule's `timezone` (default UTC) via croner — the same
389
+ * library the job scheduler uses. Falls back to `from + interval_minutes` for
390
+ * interval schedules, and also if a cron expression is invalid or has no
391
+ * future occurrence (logged; never throws into the sweep). `from` is the
392
+ * reference instant (the injected clock), so `today()`-style boundaries honor
393
+ * the test clock.
394
+ */
395
+ nextRunAt(schedule, from) {
396
+ const cron = (schedule.cron_expression ?? "").trim();
397
+ if (cron) {
398
+ try {
399
+ const next = new Cron(cron, { timezone: schedule.timezone || "UTC" }).nextRun(from);
400
+ if (next) return next;
401
+ this.logger.warn?.(`ReportService: cron '${cron}' has no next occurrence; falling back to interval`);
402
+ } catch (err) {
403
+ this.logger.warn?.(`ReportService: invalid cron '${cron}'; falling back to interval`, err);
404
+ }
405
+ }
374
406
  const interval = schedule.interval_minutes ?? DEFAULT_INTERVAL_MIN;
375
- const nextRun = new Date(this.clock.now().getTime() + interval * 6e4).toISOString();
407
+ return new Date(from.getTime() + interval * 6e4);
408
+ }
409
+ async advanceSchedule(schedule, ranAt) {
410
+ const nextRun = this.nextRunAt(schedule, this.clock.now()).toISOString();
376
411
  await this.engine.update("sys_report_schedule", {
377
412
  id: schedule.id,
378
413
  next_run_at: nextRun,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/report-service.ts","../src/reports-plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * @objectstack/plugin-reports\n *\n * Saved reports + scheduled email digests for ObjectStack.\n * Persists `sys_saved_report` definitions and `sys_report_schedule`\n * rows, then drives a dispatcher that runs due schedules and emails\n * the rendered output via the configured `email` service.\n */\n\nexport { SysSavedReport, SysReportSchedule } from '@objectstack/platform-objects/audit';\nexport {\n ReportService,\n renderReport,\n type ReportEngine,\n type ReportEmail,\n type ReportClock,\n type ReportServiceOptions,\n} from './report-service.js';\nexport {\n ReportsServicePlugin,\n type ReportsPluginOptions,\n} from './reports-plugin.js';\nexport type {\n IReportService,\n SavedReport,\n ReportSchedule,\n ReportQuery,\n ReportRunResult,\n ReportFormat,\n SaveReportInput,\n ScheduleReportInput,\n} from '@objectstack/spec/contracts';\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type {\n IReportService,\n SavedReport,\n ReportSchedule,\n ReportQuery,\n ReportRunResult,\n ReportFormat,\n SaveReportInput,\n ScheduleReportInput,\n SharingExecutionContext,\n} from '@objectstack/spec/contracts';\n\n/**\n * Narrow engine surface — keeps the service testable without booting\n * a real ObjectQL kernel.\n */\nexport interface ReportEngine {\n find(object: string, options?: any): Promise<any[]>;\n findOne?(object: string, options?: any): Promise<any>;\n insert(object: string, data: any, options?: any): Promise<any>;\n update(object: string, idOrData: any, dataOrOptions?: any, options?: any): Promise<any>;\n delete(object: string, options?: any): Promise<any>;\n}\n\n/**\n * Minimum email surface — implementations may pass the full\n * `IEmailService` instance straight through.\n */\nexport interface ReportEmail {\n send(input: {\n to: string | string[];\n subject: string;\n text?: string;\n html?: string;\n attachments?: Array<{ filename: string; content: string; contentType?: string }>;\n relatedObject?: string;\n relatedId?: string;\n }): Promise<{ status: 'sent' | 'queued' | 'failed' }>;\n}\n\n/** Stamped only in tests / specialised callers to make `now` deterministic. */\nexport interface ReportClock { now(): Date }\n\nconst SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;\n\nconst DEFAULT_FORMAT: ReportFormat = 'csv';\nconst DEFAULT_INTERVAL_MIN = 1440;\nconst DEFAULT_LIMIT = 1000;\n\nfunction uid(prefix: string): string {\n const g: any = globalThis as any;\n if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction parseQuery(raw: unknown): ReportQuery {\n if (!raw) return {};\n if (typeof raw === 'string') {\n try { return JSON.parse(raw) as ReportQuery; }\n catch { return {}; }\n }\n if (typeof raw === 'object') return raw as ReportQuery;\n return {};\n}\n\nfunction rowFromSaved(row: any): SavedReport {\n return {\n id: String(row.id),\n name: String(row.name ?? ''),\n description: row.description ?? undefined,\n object_name: String(row.object_name ?? ''),\n query: parseQuery(row.query_json),\n format: (row.format as ReportFormat) ?? DEFAULT_FORMAT,\n owner_id: row.owner_id ?? undefined,\n last_run_at: row.last_run_at ?? undefined,\n last_row_count: row.last_row_count ?? undefined,\n created_at: row.created_at ?? undefined,\n updated_at: row.updated_at ?? undefined,\n };\n}\n\nfunction rowFromSchedule(row: any): ReportSchedule {\n return {\n id: String(row.id),\n report_id: String(row.report_id),\n name: row.name ?? undefined,\n interval_minutes: row.interval_minutes ?? undefined,\n cron_expression: row.cron_expression ?? undefined,\n timezone: row.timezone ?? undefined,\n active: row.active !== false,\n recipients: String(row.recipients ?? ''),\n format: row.format ?? undefined,\n subject_template: row.subject_template ?? undefined,\n owner_id: row.owner_id ?? undefined,\n next_run_at: row.next_run_at ?? undefined,\n last_sent_at: row.last_sent_at ?? undefined,\n last_status: row.last_status ?? undefined,\n last_error: row.last_error ?? undefined,\n };\n}\n\n// ─── Rendering ─────────────────────────────────────────────────────\n\nfunction escapeCsvCell(v: unknown): string {\n if (v == null) return '';\n const s = typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));\n if (/[\",\\r\\n]/.test(s)) return `\"${s.replace(/\"/g, '\"\"')}\"`;\n return s;\n}\n\nfunction pickFields(rows: any[], explicit?: string[]): string[] {\n if (explicit && explicit.length > 0) return explicit;\n const seen = new Set<string>();\n for (const r of rows.slice(0, 50)) {\n if (r && typeof r === 'object') for (const k of Object.keys(r)) seen.add(k);\n }\n return Array.from(seen);\n}\n\nfunction renderCsv(rows: any[], fields?: string[]): string {\n const cols = pickFields(rows, fields);\n const head = cols.join(',');\n const body = rows.map(r => cols.map(c => escapeCsvCell(r?.[c])).join(',')).join('\\r\\n');\n return body.length > 0 ? `${head}\\r\\n${body}` : head;\n}\n\nfunction renderJson(rows: any[]): string {\n return JSON.stringify(rows, null, 2);\n}\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({\n '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;',\n } as Record<string, string>)[c]);\n}\n\nfunction renderHtmlTable(rows: any[], fields?: string[]): string {\n const cols = pickFields(rows, fields);\n const th = cols.map(c => `<th style=\"text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;\">${escapeHtml(c)}</th>`).join('');\n const trs = rows.map(r => {\n const tds = cols.map(c => {\n const v = r?.[c];\n const s = v == null ? '' : (typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v)));\n return `<td style=\"padding:4px 8px;border-bottom:1px solid #eee;\">${escapeHtml(s)}</td>`;\n }).join('');\n return `<tr>${tds}</tr>`;\n }).join('');\n return `<table style=\"border-collapse:collapse;font-family:system-ui,Arial,sans-serif;font-size:13px;\">`\n + `<thead><tr>${th}</tr></thead><tbody>${trs}</tbody></table>`;\n}\n\nexport function renderReport(rows: any[], format: ReportFormat, fields?: string[]): string {\n switch (format) {\n case 'json': return renderJson(rows);\n case 'html_table': return renderHtmlTable(rows, fields);\n case 'csv':\n default: return renderCsv(rows, fields);\n }\n}\n\n// ─── Subject templating (minimal {{var}}) ─────────────────────────\n\nfunction renderSubject(template: string | undefined, vars: Record<string, string>): string {\n const tpl = template ?? '{{name}} — {{date}}';\n return tpl.replace(/\\{\\{\\s*(\\w+)\\s*\\}\\}/g, (_m, k) => vars[String(k)] ?? '');\n}\n\n// ─── Service ──────────────────────────────────────────────────────\n\nexport interface ReportServiceOptions {\n engine: ReportEngine;\n email?: ReportEmail;\n clock?: ReportClock;\n logger?: { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; error?: (msg: any, ...rest: any[]) => void };\n /** Cap rows per report to protect both DB and email size. */\n maxRows?: number;\n}\n\nexport class ReportService implements IReportService {\n private readonly engine: ReportEngine;\n private readonly email?: ReportEmail;\n private readonly clock: ReportClock;\n private readonly logger: NonNullable<ReportServiceOptions['logger']>;\n private readonly maxRows: number;\n\n constructor(opts: ReportServiceOptions) {\n this.engine = opts.engine;\n this.email = opts.email;\n this.clock = opts.clock ?? { now: () => new Date() };\n this.logger = opts.logger ?? {};\n this.maxRows = Math.max(1, opts.maxRows ?? 5000);\n }\n\n // ── Report CRUD ────────────────────────────────────────────────\n\n async saveReport(input: SaveReportInput, context: SharingExecutionContext): Promise<SavedReport> {\n if (!input.name) throw new Error('VALIDATION_FAILED: name is required');\n if (!input.object) throw new Error('VALIDATION_FAILED: object is required');\n if (!input.query) throw new Error('VALIDATION_FAILED: query is required');\n\n const now = this.clock.now().toISOString();\n const payload: any = {\n name: input.name,\n description: input.description ?? null,\n object_name: input.object,\n query_json: JSON.stringify(input.query ?? {}),\n format: input.format ?? DEFAULT_FORMAT,\n owner_id: input.ownerId ?? context.userId ?? null,\n updated_at: now,\n };\n\n if (input.id) {\n const existing = await this.engine.find('sys_saved_report', {\n filter: { id: input.id }, limit: 1, context: SYSTEM_CTX,\n });\n if (Array.isArray(existing) && existing[0]) {\n await this.engine.update('sys_saved_report', { id: input.id, ...payload }, { context: SYSTEM_CTX });\n return rowFromSaved({ ...existing[0], ...payload, id: input.id });\n }\n }\n\n const id = input.id ?? uid('rpt');\n const row = { id, ...payload, created_at: now };\n await this.engine.insert('sys_saved_report', row, { context: SYSTEM_CTX });\n return rowFromSaved(row);\n }\n\n async listReports(\n filter: { object?: string; ownerId?: string } | undefined,\n _context: SharingExecutionContext,\n ): Promise<SavedReport[]> {\n const f: any = {};\n if (filter?.object) f.object_name = filter.object;\n if (filter?.ownerId) f.owner_id = filter.ownerId;\n const rows = await this.engine.find('sys_saved_report', {\n filter: f, limit: 500, orderBy: [{ field: 'updated_at', order: 'desc' }], context: SYSTEM_CTX,\n });\n return Array.isArray(rows) ? rows.map(rowFromSaved) : [];\n }\n\n async getReport(reportId: string, _context: SharingExecutionContext): Promise<SavedReport | null> {\n const rows = await this.engine.find('sys_saved_report', {\n filter: { id: reportId }, limit: 1, context: SYSTEM_CTX,\n });\n return Array.isArray(rows) && rows[0] ? rowFromSaved(rows[0]) : null;\n }\n\n async deleteReport(reportId: string, _context: SharingExecutionContext): Promise<void> {\n if (!reportId) throw new Error('VALIDATION_FAILED: reportId is required');\n // Cascade — drop attached schedules first.\n const schedules = await this.engine.find('sys_report_schedule', {\n filter: { report_id: reportId }, limit: 500, context: SYSTEM_CTX,\n });\n for (const s of (schedules ?? [])) {\n await this.engine.delete('sys_report_schedule', { where: { id: (s as any).id }, context: SYSTEM_CTX });\n }\n await this.engine.delete('sys_saved_report', { where: { id: reportId }, context: SYSTEM_CTX });\n }\n\n // ── Execution ───────────────────────────────────────────────────\n\n async run(reportId: string, context: SharingExecutionContext): Promise<ReportRunResult> {\n const report = await this.getReport(reportId, context);\n if (!report) throw new Error(`REPORT_NOT_FOUND: ${reportId}`);\n return this.executeReport(report, context);\n }\n\n async runAdHoc(input: SaveReportInput, context: SharingExecutionContext): Promise<ReportRunResult> {\n if (!input.object) throw new Error('VALIDATION_FAILED: object is required');\n if (!input.query) throw new Error('VALIDATION_FAILED: query is required');\n const adhoc: SavedReport = {\n id: '__adhoc__',\n name: input.name ?? 'Ad-hoc report',\n object_name: input.object,\n query: input.query,\n format: input.format ?? DEFAULT_FORMAT,\n };\n return this.executeReport(adhoc, context, /* stamp */ false);\n }\n\n private async executeReport(\n report: SavedReport,\n context: SharingExecutionContext,\n stamp = true,\n ): Promise<ReportRunResult> {\n const q = report.query ?? {};\n const limit = Math.min(q.limit ?? DEFAULT_LIMIT, this.maxRows);\n const rows = await this.engine.find(report.object_name, {\n filter: q.filter,\n fields: q.fields,\n orderBy: q.orderBy,\n limit,\n // Reports execute with the caller's identity so sharing rules\n // (if installed) apply. Falls back to system bypass only when\n // the report definition was created by a system writer.\n context: {\n userId: context.userId,\n tenantId: context.tenantId,\n roles: context.roles ?? [],\n permissions: context.permissions ?? [],\n isSystem: context.isSystem ?? false,\n },\n });\n const list = Array.isArray(rows) ? rows : [];\n const body = renderReport(list, report.format, q.fields);\n const ranAt = this.clock.now().toISOString();\n\n if (stamp && report.id !== '__adhoc__') {\n try {\n await this.engine.update('sys_saved_report', {\n id: report.id,\n last_run_at: ranAt,\n last_row_count: list.length,\n updated_at: ranAt,\n }, { context: SYSTEM_CTX });\n } catch (err) {\n this.logger.warn?.('ReportService: failed to stamp last_run_at', err);\n }\n }\n\n return {\n reportId: report.id,\n rowCount: list.length,\n format: report.format,\n body,\n rows: list,\n ranAt,\n };\n }\n\n // ── Schedules ──────────────────────────────────────────────────\n\n async scheduleReport(input: ScheduleReportInput, context: SharingExecutionContext): Promise<ReportSchedule> {\n if (!input.reportId) throw new Error('VALIDATION_FAILED: reportId is required');\n if (!input.recipients || input.recipients.length === 0) {\n throw new Error('VALIDATION_FAILED: recipients must be a non-empty array');\n }\n const report = await this.getReport(input.reportId, context);\n if (!report) throw new Error(`REPORT_NOT_FOUND: ${input.reportId}`);\n\n const now = this.clock.now();\n const interval = input.intervalMinutes ?? DEFAULT_INTERVAL_MIN;\n const nextRun = new Date(now.getTime() + interval * 60_000).toISOString();\n const id = uid('rsch');\n const row: any = {\n id,\n report_id: input.reportId,\n name: input.name ?? null,\n interval_minutes: interval,\n cron_expression: input.cronExpression ?? null,\n timezone: input.timezone ?? 'UTC',\n active: input.active !== false,\n recipients: input.recipients.join(','),\n format: input.format ?? 'html_table',\n subject_template: input.subjectTemplate ?? null,\n owner_id: input.ownerId ?? context.userId ?? null,\n next_run_at: nextRun,\n created_at: now.toISOString(),\n updated_at: now.toISOString(),\n };\n await this.engine.insert('sys_report_schedule', row, { context: SYSTEM_CTX });\n return rowFromSchedule(row);\n }\n\n async unscheduleReport(scheduleId: string, _context: SharingExecutionContext): Promise<void> {\n if (!scheduleId) throw new Error('VALIDATION_FAILED: scheduleId is required');\n await this.engine.delete('sys_report_schedule', { where: { id: scheduleId }, context: SYSTEM_CTX });\n }\n\n async listSchedules(\n filter: { reportId?: string } | undefined,\n _context: SharingExecutionContext,\n ): Promise<ReportSchedule[]> {\n const f: any = {};\n if (filter?.reportId) f.report_id = filter.reportId;\n const rows = await this.engine.find('sys_report_schedule', {\n filter: f, limit: 500, orderBy: [{ field: 'next_run_at', order: 'asc' }], context: SYSTEM_CTX,\n });\n return Array.isArray(rows) ? rows.map(rowFromSchedule) : [];\n }\n\n // ── Dispatcher ─────────────────────────────────────────────────\n\n async dispatchDue(now?: Date): Promise<{ fired: number; failed: number; skipped: number }> {\n const ts = (now ?? this.clock.now()).toISOString();\n const due = await this.engine.find('sys_report_schedule', {\n filter: { active: true },\n limit: 200,\n context: SYSTEM_CTX,\n });\n const list = (Array.isArray(due) ? due : []).map(rowFromSchedule)\n .filter(s => !s.next_run_at || s.next_run_at <= ts);\n\n let fired = 0, failed = 0, skipped = 0;\n for (const schedule of list) {\n try {\n const report = await this.getReport(schedule.report_id, { isSystem: true });\n if (!report) {\n skipped++;\n await this.markSchedule(schedule.id, {\n last_status: 'skipped',\n last_error: `report ${schedule.report_id} missing`,\n });\n continue;\n }\n // Force the schedule's own format so the recipient gets what\n // the admin configured (CSV attachment vs inline HTML table).\n const fmt: ReportFormat = (schedule.format ?? 'html_table') as ReportFormat;\n const result = await this.executeReport({ ...report, format: fmt }, { isSystem: true }, false);\n\n const recipients = schedule.recipients.split(',').map(s => s.trim()).filter(Boolean);\n const subject = renderSubject(schedule.subject_template, {\n name: schedule.name ?? report.name,\n date: ts.slice(0, 10),\n rows: String(result.rowCount),\n });\n\n if (this.email && recipients.length > 0) {\n if (fmt === 'csv') {\n await this.email.send({\n to: recipients,\n subject,\n text: `Attached: ${result.rowCount} row(s).`,\n attachments: [{\n filename: `${(schedule.name ?? report.name).replace(/[^\\w.-]+/g, '_')}-${ts.slice(0, 10)}.csv`,\n content: result.body,\n contentType: 'text/csv',\n }],\n relatedObject: 'sys_report_schedule',\n relatedId: schedule.id,\n });\n } else {\n await this.email.send({\n to: recipients,\n subject,\n html: `<p>${escapeHtml(report.name)} — ${result.rowCount} row(s)</p>${result.body}`,\n text: `${report.name} — ${result.rowCount} row(s)`,\n relatedObject: 'sys_report_schedule',\n relatedId: schedule.id,\n });\n }\n } else if (!this.email) {\n this.logger.warn?.('ReportService.dispatchDue: no email service — schedule fired but mail not sent');\n }\n\n await this.advanceSchedule(schedule, ts);\n fired++;\n } catch (err: any) {\n failed++;\n await this.markSchedule(schedule.id, {\n last_status: 'failed',\n last_error: String(err?.message ?? err ?? 'unknown').slice(0, 500),\n });\n this.logger.error?.('ReportService.dispatchDue: schedule failed', err);\n }\n }\n return { fired, failed, skipped };\n }\n\n private async advanceSchedule(schedule: ReportSchedule, ranAt: string): Promise<void> {\n const interval = schedule.interval_minutes ?? DEFAULT_INTERVAL_MIN;\n const nextRun = new Date(this.clock.now().getTime() + interval * 60_000).toISOString();\n await this.engine.update('sys_report_schedule', {\n id: schedule.id,\n next_run_at: nextRun,\n last_sent_at: ranAt,\n last_status: 'ok',\n last_error: null,\n updated_at: ranAt,\n }, { context: SYSTEM_CTX });\n }\n\n private async markSchedule(id: string, patch: Record<string, unknown>): Promise<void> {\n try {\n await this.engine.update('sys_report_schedule', {\n id, ...patch, updated_at: this.clock.now().toISOString(),\n }, { context: SYSTEM_CTX });\n } catch (err) {\n this.logger.warn?.('ReportService: failed to mark schedule', err);\n }\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport {\n SysSavedReport,\n SysReportSchedule,\n} from '@objectstack/platform-objects/audit';\nimport { ReportService, type ReportEngine, type ReportEmail } from './report-service.js';\n\nexport interface ReportsPluginOptions {\n /**\n * How often the dispatcher should poll `sys_report_schedule` for\n * due rows. Defaults to 60 seconds — short enough to honour\n * minute-grained schedules without flooding the DB.\n */\n dispatchIntervalMs?: number;\n /** Cap rows per report. Mirrors ReportServiceOptions.maxRows. */\n maxRows?: number;\n /** Disable the dispatcher tick entirely. */\n disableDispatcher?: boolean;\n}\n\n/**\n * ReportsServicePlugin — registers `sys_saved_report` /\n * `sys_report_schedule`, the `reports` service, and the dispatcher\n * loop that emails due schedules.\n *\n * The dispatcher uses `IJobService.schedule` when one is registered;\n * otherwise it falls back to a plain `setInterval` so single-kernel\n * deployments work without `service-job`.\n *\n * @example\n * ```ts\n * import { ReportsServicePlugin } from '@objectstack/plugin-reports';\n *\n * kernel.use(new ReportsServicePlugin({ dispatchIntervalMs: 60_000 }));\n * ```\n */\nexport class ReportsServicePlugin implements Plugin {\n name = 'com.objectstack.service.reports';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.engine.objectql'];\n\n private readonly options: ReportsPluginOptions;\n private service?: ReportService;\n private intervalHandle?: ReturnType<typeof setInterval>;\n private jobName?: string;\n private jobService?: any;\n\n constructor(options: ReportsPluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n ctx.getService<{ register(m: any): void }>('manifest').register({\n id: 'com.objectstack.service.reports',\n name: 'Reports Service',\n version: '1.0.0',\n type: 'plugin',\n scope: 'system',\n defaultDatasource: 'cloud',\n namespace: 'sys',\n objects: [SysSavedReport, SysReportSchedule],\n });\n ctx.logger.info('ReportsServicePlugin: schemas registered');\n }\n\n async start(ctx: PluginContext): Promise<void> {\n ctx.hook('kernel:ready', async () => {\n let engine: any = null;\n try { engine = ctx.getService<any>('objectql'); }\n catch { try { engine = ctx.getService<any>('data'); } catch { /* ignore */ } }\n if (!engine) {\n ctx.logger.warn('ReportsServicePlugin: no ObjectQL engine — service NOT registered');\n return;\n }\n\n let email: ReportEmail | undefined;\n try { email = ctx.getService<any>('email'); } catch { /* email is optional */ }\n if (!email) {\n ctx.logger.warn('ReportsServicePlugin: no email service — schedules will fire without delivery');\n }\n\n this.service = new ReportService({\n engine: engine as ReportEngine,\n email,\n logger: ctx.logger,\n maxRows: this.options.maxRows,\n });\n ctx.registerService('reports', this.service);\n\n if (this.options.disableDispatcher) {\n ctx.logger.info('ReportsServicePlugin: dispatcher disabled (disableDispatcher=true)');\n return;\n }\n\n const intervalMs = Math.max(5_000, this.options.dispatchIntervalMs ?? 60_000);\n\n // Prefer the platform job service when available — it lets ops\n // see report dispatch alongside every other scheduled job.\n try {\n const job = ctx.getService<any>('job');\n if (job && typeof job.schedule === 'function') {\n this.jobService = job;\n this.jobName = 'reports.dispatch';\n await job.schedule(this.jobName, { type: 'interval', intervalMs }, async () => {\n try { await this.service?.dispatchDue(); }\n catch (err) { ctx.logger.warn('ReportsServicePlugin: dispatch tick failed', err as any); }\n });\n ctx.logger.info('ReportsServicePlugin: dispatcher registered with job service', { intervalMs });\n return;\n }\n } catch { /* fall through to setInterval */ }\n\n this.intervalHandle = setInterval(() => {\n this.service?.dispatchDue().catch(err => {\n ctx.logger.warn('ReportsServicePlugin: dispatch tick failed', err);\n });\n }, intervalMs);\n // Don't keep Node alive purely for the dispatcher — common\n // mistake in tests / serverless. unref is a no-op in some\n // runtimes which is fine.\n (this.intervalHandle as any)?.unref?.();\n ctx.logger.info('ReportsServicePlugin: dispatcher registered (setInterval fallback)', { intervalMs });\n });\n }\n\n async stop(ctx: PluginContext): Promise<void> {\n if (this.intervalHandle) clearInterval(this.intervalHandle);\n this.intervalHandle = undefined;\n if (this.jobService && this.jobName && typeof this.jobService.cancel === 'function') {\n try { await this.jobService.cancel(this.jobName); }\n catch (err) { ctx.logger.warn('ReportsServicePlugin: failed to cancel job', err as any); }\n }\n }\n}\n"],"mappings":"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","names":["SysSavedReport","SysReportSchedule"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/report-service.ts","../src/reports-plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * @objectstack/plugin-reports\n *\n * Saved reports + scheduled email digests for ObjectStack.\n * Persists `sys_saved_report` definitions and `sys_report_schedule`\n * rows, then drives a dispatcher that runs due schedules and emails\n * the rendered output via the configured `email` service.\n */\n\nexport { SysSavedReport, SysReportSchedule } from '@objectstack/platform-objects/audit';\nexport {\n ReportService,\n renderReport,\n type ReportEngine,\n type ReportEmail,\n type ReportClock,\n type ReportServiceOptions,\n} from './report-service.js';\nexport {\n ReportsServicePlugin,\n type ReportsPluginOptions,\n} from './reports-plugin.js';\nexport type {\n IReportService,\n SavedReport,\n ReportSchedule,\n ReportQuery,\n ReportRunResult,\n ReportFormat,\n SaveReportInput,\n ScheduleReportInput,\n} from '@objectstack/spec/contracts';\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type {\n IReportService,\n SavedReport,\n ReportSchedule,\n ReportQuery,\n ReportRunResult,\n ReportFormat,\n SaveReportInput,\n ScheduleReportInput,\n SharingExecutionContext,\n} from '@objectstack/spec/contracts';\nimport { Cron } from 'croner';\n\n/**\n * Narrow engine surface — keeps the service testable without booting\n * a real ObjectQL kernel.\n */\nexport interface ReportEngine {\n find(object: string, options?: any): Promise<any[]>;\n findOne?(object: string, options?: any): Promise<any>;\n insert(object: string, data: any, options?: any): Promise<any>;\n update(object: string, idOrData: any, dataOrOptions?: any, options?: any): Promise<any>;\n delete(object: string, options?: any): Promise<any>;\n}\n\n/**\n * Minimum email surface — implementations may pass the full\n * `IEmailService` instance straight through.\n */\nexport interface ReportEmail {\n send(input: {\n to: string | string[];\n subject: string;\n text?: string;\n html?: string;\n attachments?: Array<{ filename: string; content: string; contentType?: string }>;\n relatedObject?: string;\n relatedId?: string;\n }): Promise<{ status: 'sent' | 'queued' | 'failed' }>;\n}\n\n/** Stamped only in tests / specialised callers to make `now` deterministic. */\nexport interface ReportClock { now(): Date }\n\nconst SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;\n\nconst DEFAULT_FORMAT: ReportFormat = 'csv';\nconst DEFAULT_INTERVAL_MIN = 1440;\nconst DEFAULT_LIMIT = 1000;\n\nfunction uid(prefix: string): string {\n const g: any = globalThis as any;\n if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;\n return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction parseQuery(raw: unknown): ReportQuery {\n if (!raw) return {};\n if (typeof raw === 'string') {\n try { return JSON.parse(raw) as ReportQuery; }\n catch { return {}; }\n }\n if (typeof raw === 'object') return raw as ReportQuery;\n return {};\n}\n\nfunction rowFromSaved(row: any): SavedReport {\n return {\n id: String(row.id),\n name: String(row.name ?? ''),\n description: row.description ?? undefined,\n object_name: String(row.object_name ?? ''),\n query: parseQuery(row.query_json),\n format: (row.format as ReportFormat) ?? DEFAULT_FORMAT,\n owner_id: row.owner_id ?? undefined,\n last_run_at: row.last_run_at ?? undefined,\n last_row_count: row.last_row_count ?? undefined,\n created_at: row.created_at ?? undefined,\n updated_at: row.updated_at ?? undefined,\n };\n}\n\nfunction rowFromSchedule(row: any): ReportSchedule {\n return {\n id: String(row.id),\n report_id: String(row.report_id),\n name: row.name ?? undefined,\n interval_minutes: row.interval_minutes ?? undefined,\n cron_expression: row.cron_expression ?? undefined,\n timezone: row.timezone ?? undefined,\n active: row.active !== false,\n recipients: String(row.recipients ?? ''),\n format: row.format ?? undefined,\n subject_template: row.subject_template ?? undefined,\n owner_id: row.owner_id ?? undefined,\n next_run_at: row.next_run_at ?? undefined,\n last_sent_at: row.last_sent_at ?? undefined,\n last_status: row.last_status ?? undefined,\n last_error: row.last_error ?? undefined,\n };\n}\n\n// ─── Rendering ─────────────────────────────────────────────────────\n\nfunction escapeCsvCell(v: unknown): string {\n if (v == null) return '';\n const s = typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));\n if (/[\",\\r\\n]/.test(s)) return `\"${s.replace(/\"/g, '\"\"')}\"`;\n return s;\n}\n\nfunction pickFields(rows: any[], explicit?: string[]): string[] {\n if (explicit && explicit.length > 0) return explicit;\n const seen = new Set<string>();\n for (const r of rows.slice(0, 50)) {\n if (r && typeof r === 'object') for (const k of Object.keys(r)) seen.add(k);\n }\n return Array.from(seen);\n}\n\nfunction renderCsv(rows: any[], fields?: string[]): string {\n const cols = pickFields(rows, fields);\n const head = cols.join(',');\n const body = rows.map(r => cols.map(c => escapeCsvCell(r?.[c])).join(',')).join('\\r\\n');\n return body.length > 0 ? `${head}\\r\\n${body}` : head;\n}\n\nfunction renderJson(rows: any[]): string {\n return JSON.stringify(rows, null, 2);\n}\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({\n '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;',\n } as Record<string, string>)[c]);\n}\n\nfunction renderHtmlTable(rows: any[], fields?: string[]): string {\n const cols = pickFields(rows, fields);\n const th = cols.map(c => `<th style=\"text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;\">${escapeHtml(c)}</th>`).join('');\n const trs = rows.map(r => {\n const tds = cols.map(c => {\n const v = r?.[c];\n const s = v == null ? '' : (typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v)));\n return `<td style=\"padding:4px 8px;border-bottom:1px solid #eee;\">${escapeHtml(s)}</td>`;\n }).join('');\n return `<tr>${tds}</tr>`;\n }).join('');\n return `<table style=\"border-collapse:collapse;font-family:system-ui,Arial,sans-serif;font-size:13px;\">`\n + `<thead><tr>${th}</tr></thead><tbody>${trs}</tbody></table>`;\n}\n\nexport function renderReport(rows: any[], format: ReportFormat, fields?: string[]): string {\n switch (format) {\n case 'json': return renderJson(rows);\n case 'html_table': return renderHtmlTable(rows, fields);\n case 'csv':\n default: return renderCsv(rows, fields);\n }\n}\n\n// ─── Subject templating (minimal {{var}}) ─────────────────────────\n\nfunction renderSubject(template: string | undefined, vars: Record<string, string>): string {\n const tpl = template ?? '{{name}} — {{date}}';\n return tpl.replace(/\\{\\{\\s*(\\w+)\\s*\\}\\}/g, (_m, k) => vars[String(k)] ?? '');\n}\n\n// ─── Service ──────────────────────────────────────────────────────\n\nexport interface ReportServiceOptions {\n engine: ReportEngine;\n email?: ReportEmail;\n clock?: ReportClock;\n logger?: { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; error?: (msg: any, ...rest: any[]) => void };\n /** Cap rows per report to protect both DB and email size. */\n maxRows?: number;\n}\n\nexport class ReportService implements IReportService {\n private readonly engine: ReportEngine;\n private readonly email?: ReportEmail;\n private readonly clock: ReportClock;\n private readonly logger: NonNullable<ReportServiceOptions['logger']>;\n private readonly maxRows: number;\n\n constructor(opts: ReportServiceOptions) {\n this.engine = opts.engine;\n this.email = opts.email;\n this.clock = opts.clock ?? { now: () => new Date() };\n this.logger = opts.logger ?? {};\n this.maxRows = Math.max(1, opts.maxRows ?? 5000);\n }\n\n // ── Report CRUD ────────────────────────────────────────────────\n\n async saveReport(input: SaveReportInput, context: SharingExecutionContext): Promise<SavedReport> {\n if (!input.name) throw new Error('VALIDATION_FAILED: name is required');\n if (!input.object) throw new Error('VALIDATION_FAILED: object is required');\n if (!input.query) throw new Error('VALIDATION_FAILED: query is required');\n\n const now = this.clock.now().toISOString();\n const payload: any = {\n name: input.name,\n description: input.description ?? null,\n object_name: input.object,\n query_json: JSON.stringify(input.query ?? {}),\n format: input.format ?? DEFAULT_FORMAT,\n owner_id: input.ownerId ?? context.userId ?? null,\n updated_at: now,\n };\n\n if (input.id) {\n const existing = await this.engine.find('sys_saved_report', {\n filter: { id: input.id }, limit: 1, context: SYSTEM_CTX,\n });\n if (Array.isArray(existing) && existing[0]) {\n await this.engine.update('sys_saved_report', { id: input.id, ...payload }, { context: SYSTEM_CTX });\n return rowFromSaved({ ...existing[0], ...payload, id: input.id });\n }\n }\n\n const id = input.id ?? uid('rpt');\n const row = { id, ...payload, created_at: now };\n await this.engine.insert('sys_saved_report', row, { context: SYSTEM_CTX });\n return rowFromSaved(row);\n }\n\n async listReports(\n filter: { object?: string; ownerId?: string } | undefined,\n _context: SharingExecutionContext,\n ): Promise<SavedReport[]> {\n const f: any = {};\n if (filter?.object) f.object_name = filter.object;\n if (filter?.ownerId) f.owner_id = filter.ownerId;\n const rows = await this.engine.find('sys_saved_report', {\n filter: f, limit: 500, orderBy: [{ field: 'updated_at', order: 'desc' }], context: SYSTEM_CTX,\n });\n return Array.isArray(rows) ? rows.map(rowFromSaved) : [];\n }\n\n async getReport(reportId: string, _context: SharingExecutionContext): Promise<SavedReport | null> {\n const rows = await this.engine.find('sys_saved_report', {\n filter: { id: reportId }, limit: 1, context: SYSTEM_CTX,\n });\n return Array.isArray(rows) && rows[0] ? rowFromSaved(rows[0]) : null;\n }\n\n async deleteReport(reportId: string, _context: SharingExecutionContext): Promise<void> {\n if (!reportId) throw new Error('VALIDATION_FAILED: reportId is required');\n // Cascade — drop attached schedules first.\n const schedules = await this.engine.find('sys_report_schedule', {\n filter: { report_id: reportId }, limit: 500, context: SYSTEM_CTX,\n });\n for (const s of (schedules ?? [])) {\n await this.engine.delete('sys_report_schedule', { where: { id: (s as any).id }, context: SYSTEM_CTX });\n }\n await this.engine.delete('sys_saved_report', { where: { id: reportId }, context: SYSTEM_CTX });\n }\n\n // ── Execution ───────────────────────────────────────────────────\n\n async run(reportId: string, context: SharingExecutionContext): Promise<ReportRunResult> {\n const report = await this.getReport(reportId, context);\n if (!report) throw new Error(`REPORT_NOT_FOUND: ${reportId}`);\n return this.executeReport(report, context);\n }\n\n async runAdHoc(input: SaveReportInput, context: SharingExecutionContext): Promise<ReportRunResult> {\n if (!input.object) throw new Error('VALIDATION_FAILED: object is required');\n if (!input.query) throw new Error('VALIDATION_FAILED: query is required');\n const adhoc: SavedReport = {\n id: '__adhoc__',\n name: input.name ?? 'Ad-hoc report',\n object_name: input.object,\n query: input.query,\n format: input.format ?? DEFAULT_FORMAT,\n };\n return this.executeReport(adhoc, context, /* stamp */ false);\n }\n\n private async executeReport(\n report: SavedReport,\n context: SharingExecutionContext,\n stamp = true,\n ): Promise<ReportRunResult> {\n const q = report.query ?? {};\n const limit = Math.min(q.limit ?? DEFAULT_LIMIT, this.maxRows);\n const rows = await this.engine.find(report.object_name, {\n filter: q.filter,\n fields: q.fields,\n orderBy: q.orderBy,\n limit,\n // Reports execute with the caller's identity so sharing rules\n // (if installed) apply. Falls back to system bypass only when\n // the report definition was created by a system writer.\n context: {\n userId: context.userId,\n tenantId: context.tenantId,\n roles: context.roles ?? [],\n permissions: context.permissions ?? [],\n isSystem: context.isSystem ?? false,\n },\n });\n const list = Array.isArray(rows) ? rows : [];\n const body = renderReport(list, report.format, q.fields);\n const ranAt = this.clock.now().toISOString();\n\n if (stamp && report.id !== '__adhoc__') {\n try {\n await this.engine.update('sys_saved_report', {\n id: report.id,\n last_run_at: ranAt,\n last_row_count: list.length,\n updated_at: ranAt,\n }, { context: SYSTEM_CTX });\n } catch (err) {\n this.logger.warn?.('ReportService: failed to stamp last_run_at', err);\n }\n }\n\n return {\n reportId: report.id,\n rowCount: list.length,\n format: report.format,\n body,\n rows: list,\n ranAt,\n };\n }\n\n // ── Schedules ──────────────────────────────────────────────────\n\n async scheduleReport(input: ScheduleReportInput, context: SharingExecutionContext): Promise<ReportSchedule> {\n if (!input.reportId) throw new Error('VALIDATION_FAILED: reportId is required');\n if (!input.recipients || input.recipients.length === 0) {\n throw new Error('VALIDATION_FAILED: recipients must be a non-empty array');\n }\n const report = await this.getReport(input.reportId, context);\n if (!report) throw new Error(`REPORT_NOT_FOUND: ${input.reportId}`);\n\n const now = this.clock.now();\n const interval = input.intervalMinutes ?? DEFAULT_INTERVAL_MIN;\n const cron = input.cronExpression?.trim() || null;\n if (cron) {\n // Validate eagerly so an author gets a clear error at schedule time\n // instead of a schedule that silently falls back to interval on sweep.\n try {\n new Cron(cron, { timezone: input.timezone || 'UTC' });\n } catch (err) {\n throw new Error(`VALIDATION_FAILED: invalid cron_expression '${cron}': ${(err as Error).message}`);\n }\n }\n const nextRun = this.nextRunAt(\n { cron_expression: cron, interval_minutes: interval, timezone: input.timezone ?? 'UTC' },\n now,\n ).toISOString();\n const id = uid('rsch');\n const row: any = {\n id,\n report_id: input.reportId,\n name: input.name ?? null,\n interval_minutes: interval,\n cron_expression: cron,\n timezone: input.timezone ?? 'UTC',\n active: input.active !== false,\n recipients: input.recipients.join(','),\n format: input.format ?? 'html_table',\n subject_template: input.subjectTemplate ?? null,\n owner_id: input.ownerId ?? context.userId ?? null,\n next_run_at: nextRun,\n created_at: now.toISOString(),\n updated_at: now.toISOString(),\n };\n await this.engine.insert('sys_report_schedule', row, { context: SYSTEM_CTX });\n return rowFromSchedule(row);\n }\n\n async unscheduleReport(scheduleId: string, _context: SharingExecutionContext): Promise<void> {\n if (!scheduleId) throw new Error('VALIDATION_FAILED: scheduleId is required');\n await this.engine.delete('sys_report_schedule', { where: { id: scheduleId }, context: SYSTEM_CTX });\n }\n\n async listSchedules(\n filter: { reportId?: string } | undefined,\n _context: SharingExecutionContext,\n ): Promise<ReportSchedule[]> {\n const f: any = {};\n if (filter?.reportId) f.report_id = filter.reportId;\n const rows = await this.engine.find('sys_report_schedule', {\n filter: f, limit: 500, orderBy: [{ field: 'next_run_at', order: 'asc' }], context: SYSTEM_CTX,\n });\n return Array.isArray(rows) ? rows.map(rowFromSchedule) : [];\n }\n\n // ── Dispatcher ─────────────────────────────────────────────────\n\n async dispatchDue(now?: Date): Promise<{ fired: number; failed: number; skipped: number }> {\n const ts = (now ?? this.clock.now()).toISOString();\n const due = await this.engine.find('sys_report_schedule', {\n filter: { active: true },\n limit: 200,\n context: SYSTEM_CTX,\n });\n const list = (Array.isArray(due) ? due : []).map(rowFromSchedule)\n .filter(s => !s.next_run_at || s.next_run_at <= ts);\n\n let fired = 0, failed = 0, skipped = 0;\n for (const schedule of list) {\n try {\n const report = await this.getReport(schedule.report_id, { isSystem: true });\n if (!report) {\n skipped++;\n await this.markSchedule(schedule.id, {\n last_status: 'skipped',\n last_error: `report ${schedule.report_id} missing`,\n });\n continue;\n }\n // Force the schedule's own format so the recipient gets what\n // the admin configured (CSV attachment vs inline HTML table).\n const fmt: ReportFormat = (schedule.format ?? 'html_table') as ReportFormat;\n const result = await this.executeReport({ ...report, format: fmt }, { isSystem: true }, false);\n\n const recipients = schedule.recipients.split(',').map(s => s.trim()).filter(Boolean);\n const subject = renderSubject(schedule.subject_template, {\n name: schedule.name ?? report.name,\n date: ts.slice(0, 10),\n rows: String(result.rowCount),\n });\n\n if (this.email && recipients.length > 0) {\n if (fmt === 'csv') {\n await this.email.send({\n to: recipients,\n subject,\n text: `Attached: ${result.rowCount} row(s).`,\n attachments: [{\n filename: `${(schedule.name ?? report.name).replace(/[^\\w.-]+/g, '_')}-${ts.slice(0, 10)}.csv`,\n content: result.body,\n contentType: 'text/csv',\n }],\n relatedObject: 'sys_report_schedule',\n relatedId: schedule.id,\n });\n } else {\n await this.email.send({\n to: recipients,\n subject,\n html: `<p>${escapeHtml(report.name)} — ${result.rowCount} row(s)</p>${result.body}`,\n text: `${report.name} — ${result.rowCount} row(s)`,\n relatedObject: 'sys_report_schedule',\n relatedId: schedule.id,\n });\n }\n } else if (!this.email) {\n this.logger.warn?.('ReportService.dispatchDue: no email service — schedule fired but mail not sent');\n }\n\n await this.advanceSchedule(schedule, ts);\n fired++;\n } catch (err: any) {\n failed++;\n await this.markSchedule(schedule.id, {\n last_status: 'failed',\n last_error: String(err?.message ?? err ?? 'unknown').slice(0, 500),\n });\n this.logger.error?.('ReportService.dispatchDue: schedule failed', err);\n }\n }\n return { fired, failed, skipped };\n }\n\n /**\n * Compute the next fire time for a schedule. A `cron_expression` wins over\n * `interval_minutes` (the documented `sys_report_schedule` contract) and is\n * evaluated in the schedule's `timezone` (default UTC) via croner — the same\n * library the job scheduler uses. Falls back to `from + interval_minutes` for\n * interval schedules, and also if a cron expression is invalid or has no\n * future occurrence (logged; never throws into the sweep). `from` is the\n * reference instant (the injected clock), so `today()`-style boundaries honor\n * the test clock.\n */\n private nextRunAt(\n schedule: { cron_expression?: string | null; interval_minutes?: number | null; timezone?: string | null },\n from: Date,\n ): Date {\n const cron = (schedule.cron_expression ?? '').trim();\n if (cron) {\n try {\n const next = new Cron(cron, { timezone: schedule.timezone || 'UTC' }).nextRun(from);\n if (next) return next;\n this.logger.warn?.(`ReportService: cron '${cron}' has no next occurrence; falling back to interval`);\n } catch (err) {\n this.logger.warn?.(`ReportService: invalid cron '${cron}'; falling back to interval`, err);\n }\n }\n const interval = schedule.interval_minutes ?? DEFAULT_INTERVAL_MIN;\n return new Date(from.getTime() + interval * 60_000);\n }\n\n private async advanceSchedule(schedule: ReportSchedule, ranAt: string): Promise<void> {\n const nextRun = this.nextRunAt(schedule, this.clock.now()).toISOString();\n await this.engine.update('sys_report_schedule', {\n id: schedule.id,\n next_run_at: nextRun,\n last_sent_at: ranAt,\n last_status: 'ok',\n last_error: null,\n updated_at: ranAt,\n }, { context: SYSTEM_CTX });\n }\n\n private async markSchedule(id: string, patch: Record<string, unknown>): Promise<void> {\n try {\n await this.engine.update('sys_report_schedule', {\n id, ...patch, updated_at: this.clock.now().toISOString(),\n }, { context: SYSTEM_CTX });\n } catch (err) {\n this.logger.warn?.('ReportService: failed to mark schedule', err);\n }\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport {\n SysSavedReport,\n SysReportSchedule,\n} from '@objectstack/platform-objects/audit';\nimport { ReportService, type ReportEngine, type ReportEmail } from './report-service.js';\n\nexport interface ReportsPluginOptions {\n /**\n * How often the dispatcher should poll `sys_report_schedule` for\n * due rows. Defaults to 60 seconds — short enough to honour\n * minute-grained schedules without flooding the DB.\n */\n dispatchIntervalMs?: number;\n /** Cap rows per report. Mirrors ReportServiceOptions.maxRows. */\n maxRows?: number;\n /** Disable the dispatcher tick entirely. */\n disableDispatcher?: boolean;\n}\n\n/**\n * ReportsServicePlugin — registers `sys_saved_report` /\n * `sys_report_schedule`, the `reports` service, and the dispatcher\n * loop that emails due schedules.\n *\n * The dispatcher uses `IJobService.schedule` when one is registered;\n * otherwise it falls back to a plain `setInterval` so single-kernel\n * deployments work without `service-job`.\n *\n * @example\n * ```ts\n * import { ReportsServicePlugin } from '@objectstack/plugin-reports';\n *\n * kernel.use(new ReportsServicePlugin({ dispatchIntervalMs: 60_000 }));\n * ```\n */\nexport class ReportsServicePlugin implements Plugin {\n name = 'com.objectstack.service.reports';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.engine.objectql'];\n\n private readonly options: ReportsPluginOptions;\n private service?: ReportService;\n private intervalHandle?: ReturnType<typeof setInterval>;\n private jobName?: string;\n private jobService?: any;\n\n constructor(options: ReportsPluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n ctx.getService<{ register(m: any): void }>('manifest').register({\n id: 'com.objectstack.service.reports',\n name: 'Reports Service',\n version: '1.0.0',\n type: 'plugin',\n scope: 'system',\n defaultDatasource: 'cloud',\n namespace: 'sys',\n objects: [SysSavedReport, SysReportSchedule],\n });\n ctx.logger.info('ReportsServicePlugin: schemas registered');\n }\n\n async start(ctx: PluginContext): Promise<void> {\n ctx.hook('kernel:ready', async () => {\n let engine: any = null;\n try { engine = ctx.getService<any>('objectql'); }\n catch { try { engine = ctx.getService<any>('data'); } catch { /* ignore */ } }\n if (!engine) {\n ctx.logger.warn('ReportsServicePlugin: no ObjectQL engine — service NOT registered');\n return;\n }\n\n let email: ReportEmail | undefined;\n try { email = ctx.getService<any>('email'); } catch { /* email is optional */ }\n if (!email) {\n ctx.logger.warn('ReportsServicePlugin: no email service — schedules will fire without delivery');\n }\n\n this.service = new ReportService({\n engine: engine as ReportEngine,\n email,\n logger: ctx.logger,\n maxRows: this.options.maxRows,\n });\n ctx.registerService('reports', this.service);\n\n if (this.options.disableDispatcher) {\n ctx.logger.info('ReportsServicePlugin: dispatcher disabled (disableDispatcher=true)');\n return;\n }\n\n const intervalMs = Math.max(5_000, this.options.dispatchIntervalMs ?? 60_000);\n\n // Prefer the platform job service when available — it lets ops\n // see report dispatch alongside every other scheduled job.\n try {\n const job = ctx.getService<any>('job');\n if (job && typeof job.schedule === 'function') {\n this.jobService = job;\n this.jobName = 'reports.dispatch';\n await job.schedule(this.jobName, { type: 'interval', intervalMs }, async () => {\n try { await this.service?.dispatchDue(); }\n catch (err) { ctx.logger.warn('ReportsServicePlugin: dispatch tick failed', err as any); }\n });\n ctx.logger.info('ReportsServicePlugin: dispatcher registered with job service', { intervalMs });\n return;\n }\n } catch { /* fall through to setInterval */ }\n\n this.intervalHandle = setInterval(() => {\n this.service?.dispatchDue().catch(err => {\n ctx.logger.warn('ReportsServicePlugin: dispatch tick failed', err);\n });\n }, intervalMs);\n // Don't keep Node alive purely for the dispatcher — common\n // mistake in tests / serverless. unref is a no-op in some\n // runtimes which is fine.\n (this.intervalHandle as any)?.unref?.();\n ctx.logger.info('ReportsServicePlugin: dispatcher registered (setInterval fallback)', { intervalMs });\n });\n }\n\n async stop(ctx: PluginContext): Promise<void> {\n if (this.intervalHandle) clearInterval(this.intervalHandle);\n this.intervalHandle = undefined;\n if (this.jobService && this.jobName && typeof this.jobService.cancel === 'function') {\n try { await this.jobService.cancel(this.jobName); }\n catch (err) { ctx.logger.warn('ReportsServicePlugin: failed to cancel job', err as any); }\n }\n }\n}\n"],"mappings":"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","names":["SysSavedReport","SysReportSchedule"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-reports",
3
- "version": "9.8.0",
3
+ "version": "9.9.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Saved reports + scheduled email digests for ObjectStack — sys_saved_report + sys_report_schedule + IReportService.",
6
6
  "main": "dist/index.js",
@@ -13,9 +13,10 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@objectstack/core": "9.8.0",
17
- "@objectstack/platform-objects": "9.8.0",
18
- "@objectstack/spec": "9.8.0"
16
+ "croner": "^10.0.1",
17
+ "@objectstack/core": "9.9.1",
18
+ "@objectstack/platform-objects": "9.9.1",
19
+ "@objectstack/spec": "9.9.1"
19
20
  },
20
21
  "devDependencies": {
21
22
  "@types/node": "^25.9.3",
@@ -237,6 +237,49 @@ describe('ReportService', () => {
237
237
  expect(s.recipients).toBe('x@t');
238
238
  });
239
239
 
240
+ it('scheduleReport: cron_expression drives next_run_at and overrides interval (#1983)', async () => {
241
+ const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
242
+ // now = 2026-01-15T10:00:00Z (past 09:00); daily-9am cron → tomorrow 09:00Z.
243
+ const s = await svc.scheduleReport({
244
+ reportId: r.id, recipients: ['x@t'], intervalMinutes: 60, cronExpression: '0 9 * * *',
245
+ }, CTX);
246
+ expect(s.cron_expression).toBe('0 9 * * *');
247
+ expect(s.next_run_at).toBe('2026-01-16T09:00:00.000Z');
248
+ // …not the interval's now + 60m.
249
+ expect(s.next_run_at).not.toBe(new Date(now.getTime() + 60 * 60_000).toISOString());
250
+ });
251
+
252
+ it('scheduleReport: cron honors the schedule timezone (#1983)', async () => {
253
+ const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
254
+ // 09:00 America/New_York (UTC-5 in January) = 14:00Z; now=05:00 ET → same day.
255
+ const s = await svc.scheduleReport({
256
+ reportId: r.id, recipients: ['x@t'], cronExpression: '0 9 * * *', timezone: 'America/New_York',
257
+ }, CTX);
258
+ expect(s.next_run_at).toBe('2026-01-15T14:00:00.000Z');
259
+ });
260
+
261
+ it('scheduleReport: rejects an invalid cron_expression (#1983)', async () => {
262
+ const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
263
+ await expect(svc.scheduleReport({
264
+ reportId: r.id, recipients: ['x@t'], cronExpression: 'not a cron',
265
+ }, CTX)).rejects.toThrow(/VALIDATION_FAILED/);
266
+ });
267
+
268
+ it('dispatchDue: advances a cron schedule to the next cron occurrence (#1983)', async () => {
269
+ const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
270
+ await svc.scheduleReport({
271
+ reportId: r.id, recipients: ['x@t'], cronExpression: '0 9 * * *', format: 'csv',
272
+ }, CTX);
273
+ // Force due, then dispatch at `now`.
274
+ engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1000).toISOString();
275
+ const result = await svc.dispatchDue();
276
+ expect(result.fired).toBe(1);
277
+ const advanced = engine._tables['sys_report_schedule'][0];
278
+ expect(advanced.last_status).toBe('ok');
279
+ // Advanced via cron (tomorrow 09:00Z), not now + interval.
280
+ expect(advanced.next_run_at).toBe('2026-01-16T09:00:00.000Z');
281
+ });
282
+
240
283
  it('listSchedules + unscheduleReport', async () => {
241
284
  const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
242
285
  const s = await svc.scheduleReport({ reportId: r.id, recipients: ['x@t'] }, CTX);
@@ -11,6 +11,7 @@ import type {
11
11
  ScheduleReportInput,
12
12
  SharingExecutionContext,
13
13
  } from '@objectstack/spec/contracts';
14
+ import { Cron } from 'croner';
14
15
 
15
16
  /**
16
17
  * Narrow engine surface — keeps the service testable without booting
@@ -342,14 +343,27 @@ export class ReportService implements IReportService {
342
343
 
343
344
  const now = this.clock.now();
344
345
  const interval = input.intervalMinutes ?? DEFAULT_INTERVAL_MIN;
345
- const nextRun = new Date(now.getTime() + interval * 60_000).toISOString();
346
+ const cron = input.cronExpression?.trim() || null;
347
+ if (cron) {
348
+ // Validate eagerly so an author gets a clear error at schedule time
349
+ // instead of a schedule that silently falls back to interval on sweep.
350
+ try {
351
+ new Cron(cron, { timezone: input.timezone || 'UTC' });
352
+ } catch (err) {
353
+ throw new Error(`VALIDATION_FAILED: invalid cron_expression '${cron}': ${(err as Error).message}`);
354
+ }
355
+ }
356
+ const nextRun = this.nextRunAt(
357
+ { cron_expression: cron, interval_minutes: interval, timezone: input.timezone ?? 'UTC' },
358
+ now,
359
+ ).toISOString();
346
360
  const id = uid('rsch');
347
361
  const row: any = {
348
362
  id,
349
363
  report_id: input.reportId,
350
364
  name: input.name ?? null,
351
365
  interval_minutes: interval,
352
- cron_expression: input.cronExpression ?? null,
366
+ cron_expression: cron,
353
367
  timezone: input.timezone ?? 'UTC',
354
368
  active: input.active !== false,
355
369
  recipients: input.recipients.join(','),
@@ -459,9 +473,36 @@ export class ReportService implements IReportService {
459
473
  return { fired, failed, skipped };
460
474
  }
461
475
 
462
- private async advanceSchedule(schedule: ReportSchedule, ranAt: string): Promise<void> {
476
+ /**
477
+ * Compute the next fire time for a schedule. A `cron_expression` wins over
478
+ * `interval_minutes` (the documented `sys_report_schedule` contract) and is
479
+ * evaluated in the schedule's `timezone` (default UTC) via croner — the same
480
+ * library the job scheduler uses. Falls back to `from + interval_minutes` for
481
+ * interval schedules, and also if a cron expression is invalid or has no
482
+ * future occurrence (logged; never throws into the sweep). `from` is the
483
+ * reference instant (the injected clock), so `today()`-style boundaries honor
484
+ * the test clock.
485
+ */
486
+ private nextRunAt(
487
+ schedule: { cron_expression?: string | null; interval_minutes?: number | null; timezone?: string | null },
488
+ from: Date,
489
+ ): Date {
490
+ const cron = (schedule.cron_expression ?? '').trim();
491
+ if (cron) {
492
+ try {
493
+ const next = new Cron(cron, { timezone: schedule.timezone || 'UTC' }).nextRun(from);
494
+ if (next) return next;
495
+ this.logger.warn?.(`ReportService: cron '${cron}' has no next occurrence; falling back to interval`);
496
+ } catch (err) {
497
+ this.logger.warn?.(`ReportService: invalid cron '${cron}'; falling back to interval`, err);
498
+ }
499
+ }
463
500
  const interval = schedule.interval_minutes ?? DEFAULT_INTERVAL_MIN;
464
- const nextRun = new Date(this.clock.now().getTime() + interval * 60_000).toISOString();
501
+ return new Date(from.getTime() + interval * 60_000);
502
+ }
503
+
504
+ private async advanceSchedule(schedule: ReportSchedule, ranAt: string): Promise<void> {
505
+ const nextRun = this.nextRunAt(schedule, this.clock.now()).toISOString();
465
506
  await this.engine.update('sys_report_schedule', {
466
507
  id: schedule.id,
467
508
  next_run_at: nextRun,