@objectstack/plugin-reports 4.0.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.
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +202 -0
- package/dist/index.d.mts +130 -0
- package/dist/index.d.ts +130 -0
- package/dist/index.js +530 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +504 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +36 -0
- package/src/index.ts +34 -0
- package/src/report-service.test.ts +322 -0
- package/src/report-service.ts +484 -0
- package/src/reports-plugin.ts +137 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
IReportService,
|
|
5
|
+
SavedReport,
|
|
6
|
+
ReportSchedule,
|
|
7
|
+
ReportQuery,
|
|
8
|
+
ReportRunResult,
|
|
9
|
+
ReportFormat,
|
|
10
|
+
SaveReportInput,
|
|
11
|
+
ScheduleReportInput,
|
|
12
|
+
SharingExecutionContext,
|
|
13
|
+
} from '@objectstack/spec/contracts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Narrow engine surface — keeps the service testable without booting
|
|
17
|
+
* a real ObjectQL kernel.
|
|
18
|
+
*/
|
|
19
|
+
export interface ReportEngine {
|
|
20
|
+
find(object: string, options?: any): Promise<any[]>;
|
|
21
|
+
findOne?(object: string, options?: any): Promise<any>;
|
|
22
|
+
insert(object: string, data: any, options?: any): Promise<any>;
|
|
23
|
+
update(object: string, idOrData: any, dataOrOptions?: any, options?: any): Promise<any>;
|
|
24
|
+
delete(object: string, options?: any): Promise<any>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Minimum email surface — implementations may pass the full
|
|
29
|
+
* `IEmailService` instance straight through.
|
|
30
|
+
*/
|
|
31
|
+
export interface ReportEmail {
|
|
32
|
+
send(input: {
|
|
33
|
+
to: string | string[];
|
|
34
|
+
subject: string;
|
|
35
|
+
text?: string;
|
|
36
|
+
html?: string;
|
|
37
|
+
attachments?: Array<{ filename: string; content: string; contentType?: string }>;
|
|
38
|
+
relatedObject?: string;
|
|
39
|
+
relatedId?: string;
|
|
40
|
+
}): Promise<{ status: 'sent' | 'queued' | 'failed' }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Stamped only in tests / specialised callers to make `now` deterministic. */
|
|
44
|
+
export interface ReportClock { now(): Date }
|
|
45
|
+
|
|
46
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
|
|
47
|
+
|
|
48
|
+
const DEFAULT_FORMAT: ReportFormat = 'csv';
|
|
49
|
+
const DEFAULT_INTERVAL_MIN = 1440;
|
|
50
|
+
const DEFAULT_LIMIT = 1000;
|
|
51
|
+
|
|
52
|
+
function uid(prefix: string): string {
|
|
53
|
+
const g: any = globalThis as any;
|
|
54
|
+
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
55
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseQuery(raw: unknown): ReportQuery {
|
|
59
|
+
if (!raw) return {};
|
|
60
|
+
if (typeof raw === 'string') {
|
|
61
|
+
try { return JSON.parse(raw) as ReportQuery; }
|
|
62
|
+
catch { return {}; }
|
|
63
|
+
}
|
|
64
|
+
if (typeof raw === 'object') return raw as ReportQuery;
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function rowFromSaved(row: any): SavedReport {
|
|
69
|
+
return {
|
|
70
|
+
id: String(row.id),
|
|
71
|
+
name: String(row.name ?? ''),
|
|
72
|
+
description: row.description ?? undefined,
|
|
73
|
+
object_name: String(row.object_name ?? ''),
|
|
74
|
+
query: parseQuery(row.query_json),
|
|
75
|
+
format: (row.format as ReportFormat) ?? DEFAULT_FORMAT,
|
|
76
|
+
owner_id: row.owner_id ?? undefined,
|
|
77
|
+
last_run_at: row.last_run_at ?? undefined,
|
|
78
|
+
last_row_count: row.last_row_count ?? undefined,
|
|
79
|
+
created_at: row.created_at ?? undefined,
|
|
80
|
+
updated_at: row.updated_at ?? undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rowFromSchedule(row: any): ReportSchedule {
|
|
85
|
+
return {
|
|
86
|
+
id: String(row.id),
|
|
87
|
+
report_id: String(row.report_id),
|
|
88
|
+
name: row.name ?? undefined,
|
|
89
|
+
interval_minutes: row.interval_minutes ?? undefined,
|
|
90
|
+
cron_expression: row.cron_expression ?? undefined,
|
|
91
|
+
timezone: row.timezone ?? undefined,
|
|
92
|
+
active: row.active !== false,
|
|
93
|
+
recipients: String(row.recipients ?? ''),
|
|
94
|
+
format: row.format ?? undefined,
|
|
95
|
+
subject_template: row.subject_template ?? undefined,
|
|
96
|
+
owner_id: row.owner_id ?? undefined,
|
|
97
|
+
next_run_at: row.next_run_at ?? undefined,
|
|
98
|
+
last_sent_at: row.last_sent_at ?? undefined,
|
|
99
|
+
last_status: row.last_status ?? undefined,
|
|
100
|
+
last_error: row.last_error ?? undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Rendering ─────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
function escapeCsvCell(v: unknown): string {
|
|
107
|
+
if (v == null) return '';
|
|
108
|
+
const s = typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
109
|
+
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
|
110
|
+
return s;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function pickFields(rows: any[], explicit?: string[]): string[] {
|
|
114
|
+
if (explicit && explicit.length > 0) return explicit;
|
|
115
|
+
const seen = new Set<string>();
|
|
116
|
+
for (const r of rows.slice(0, 50)) {
|
|
117
|
+
if (r && typeof r === 'object') for (const k of Object.keys(r)) seen.add(k);
|
|
118
|
+
}
|
|
119
|
+
return Array.from(seen);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderCsv(rows: any[], fields?: string[]): string {
|
|
123
|
+
const cols = pickFields(rows, fields);
|
|
124
|
+
const head = cols.join(',');
|
|
125
|
+
const body = rows.map(r => cols.map(c => escapeCsvCell(r?.[c])).join(',')).join('\r\n');
|
|
126
|
+
return body.length > 0 ? `${head}\r\n${body}` : head;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderJson(rows: any[]): string {
|
|
130
|
+
return JSON.stringify(rows, null, 2);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function escapeHtml(s: string): string {
|
|
134
|
+
return s.replace(/[&<>"']/g, c => ({
|
|
135
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
136
|
+
} as Record<string, string>)[c]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderHtmlTable(rows: any[], fields?: string[]): string {
|
|
140
|
+
const cols = pickFields(rows, fields);
|
|
141
|
+
const th = cols.map(c => `<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;">${escapeHtml(c)}</th>`).join('');
|
|
142
|
+
const trs = rows.map(r => {
|
|
143
|
+
const tds = cols.map(c => {
|
|
144
|
+
const v = r?.[c];
|
|
145
|
+
const s = v == null ? '' : (typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v)));
|
|
146
|
+
return `<td style="padding:4px 8px;border-bottom:1px solid #eee;">${escapeHtml(s)}</td>`;
|
|
147
|
+
}).join('');
|
|
148
|
+
return `<tr>${tds}</tr>`;
|
|
149
|
+
}).join('');
|
|
150
|
+
return `<table style="border-collapse:collapse;font-family:system-ui,Arial,sans-serif;font-size:13px;">`
|
|
151
|
+
+ `<thead><tr>${th}</tr></thead><tbody>${trs}</tbody></table>`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function renderReport(rows: any[], format: ReportFormat, fields?: string[]): string {
|
|
155
|
+
switch (format) {
|
|
156
|
+
case 'json': return renderJson(rows);
|
|
157
|
+
case 'html_table': return renderHtmlTable(rows, fields);
|
|
158
|
+
case 'csv':
|
|
159
|
+
default: return renderCsv(rows, fields);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Subject templating (minimal {{var}}) ─────────────────────────
|
|
164
|
+
|
|
165
|
+
function renderSubject(template: string | undefined, vars: Record<string, string>): string {
|
|
166
|
+
const tpl = template ?? '{{name}} — {{date}}';
|
|
167
|
+
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, k) => vars[String(k)] ?? '');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Service ──────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
export interface ReportServiceOptions {
|
|
173
|
+
engine: ReportEngine;
|
|
174
|
+
email?: ReportEmail;
|
|
175
|
+
clock?: ReportClock;
|
|
176
|
+
logger?: { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; error?: (msg: any, ...rest: any[]) => void };
|
|
177
|
+
/** Cap rows per report to protect both DB and email size. */
|
|
178
|
+
maxRows?: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export class ReportService implements IReportService {
|
|
182
|
+
private readonly engine: ReportEngine;
|
|
183
|
+
private readonly email?: ReportEmail;
|
|
184
|
+
private readonly clock: ReportClock;
|
|
185
|
+
private readonly logger: NonNullable<ReportServiceOptions['logger']>;
|
|
186
|
+
private readonly maxRows: number;
|
|
187
|
+
|
|
188
|
+
constructor(opts: ReportServiceOptions) {
|
|
189
|
+
this.engine = opts.engine;
|
|
190
|
+
this.email = opts.email;
|
|
191
|
+
this.clock = opts.clock ?? { now: () => new Date() };
|
|
192
|
+
this.logger = opts.logger ?? {};
|
|
193
|
+
this.maxRows = Math.max(1, opts.maxRows ?? 5000);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Report CRUD ────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
async saveReport(input: SaveReportInput, context: SharingExecutionContext): Promise<SavedReport> {
|
|
199
|
+
if (!input.name) throw new Error('VALIDATION_FAILED: name is required');
|
|
200
|
+
if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
|
|
201
|
+
if (!input.query) throw new Error('VALIDATION_FAILED: query is required');
|
|
202
|
+
|
|
203
|
+
const now = this.clock.now().toISOString();
|
|
204
|
+
const payload: any = {
|
|
205
|
+
name: input.name,
|
|
206
|
+
description: input.description ?? null,
|
|
207
|
+
object_name: input.object,
|
|
208
|
+
query_json: JSON.stringify(input.query ?? {}),
|
|
209
|
+
format: input.format ?? DEFAULT_FORMAT,
|
|
210
|
+
owner_id: input.ownerId ?? context.userId ?? null,
|
|
211
|
+
updated_at: now,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (input.id) {
|
|
215
|
+
const existing = await this.engine.find('sys_saved_report', {
|
|
216
|
+
filter: { id: input.id }, limit: 1, context: SYSTEM_CTX,
|
|
217
|
+
});
|
|
218
|
+
if (Array.isArray(existing) && existing[0]) {
|
|
219
|
+
await this.engine.update('sys_saved_report', { id: input.id, ...payload }, { context: SYSTEM_CTX });
|
|
220
|
+
return rowFromSaved({ ...existing[0], ...payload, id: input.id });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const id = input.id ?? uid('rpt');
|
|
225
|
+
const row = { id, ...payload, created_at: now };
|
|
226
|
+
await this.engine.insert('sys_saved_report', row, { context: SYSTEM_CTX });
|
|
227
|
+
return rowFromSaved(row);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async listReports(
|
|
231
|
+
filter: { object?: string; ownerId?: string } | undefined,
|
|
232
|
+
_context: SharingExecutionContext,
|
|
233
|
+
): Promise<SavedReport[]> {
|
|
234
|
+
const f: any = {};
|
|
235
|
+
if (filter?.object) f.object_name = filter.object;
|
|
236
|
+
if (filter?.ownerId) f.owner_id = filter.ownerId;
|
|
237
|
+
const rows = await this.engine.find('sys_saved_report', {
|
|
238
|
+
filter: f, limit: 500, orderBy: [{ field: 'updated_at', direction: 'desc' }], context: SYSTEM_CTX,
|
|
239
|
+
});
|
|
240
|
+
return Array.isArray(rows) ? rows.map(rowFromSaved) : [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async getReport(reportId: string, _context: SharingExecutionContext): Promise<SavedReport | null> {
|
|
244
|
+
const rows = await this.engine.find('sys_saved_report', {
|
|
245
|
+
filter: { id: reportId }, limit: 1, context: SYSTEM_CTX,
|
|
246
|
+
});
|
|
247
|
+
return Array.isArray(rows) && rows[0] ? rowFromSaved(rows[0]) : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async deleteReport(reportId: string, _context: SharingExecutionContext): Promise<void> {
|
|
251
|
+
if (!reportId) throw new Error('VALIDATION_FAILED: reportId is required');
|
|
252
|
+
// Cascade — drop attached schedules first.
|
|
253
|
+
const schedules = await this.engine.find('sys_report_schedule', {
|
|
254
|
+
filter: { report_id: reportId }, limit: 500, context: SYSTEM_CTX,
|
|
255
|
+
});
|
|
256
|
+
for (const s of (schedules ?? [])) {
|
|
257
|
+
await this.engine.delete('sys_report_schedule', { where: { id: (s as any).id }, context: SYSTEM_CTX });
|
|
258
|
+
}
|
|
259
|
+
await this.engine.delete('sys_saved_report', { where: { id: reportId }, context: SYSTEM_CTX });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Execution ───────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
async run(reportId: string, context: SharingExecutionContext): Promise<ReportRunResult> {
|
|
265
|
+
const report = await this.getReport(reportId, context);
|
|
266
|
+
if (!report) throw new Error(`REPORT_NOT_FOUND: ${reportId}`);
|
|
267
|
+
return this.executeReport(report, context);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async runAdHoc(input: SaveReportInput, context: SharingExecutionContext): Promise<ReportRunResult> {
|
|
271
|
+
if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
|
|
272
|
+
if (!input.query) throw new Error('VALIDATION_FAILED: query is required');
|
|
273
|
+
const adhoc: SavedReport = {
|
|
274
|
+
id: '__adhoc__',
|
|
275
|
+
name: input.name ?? 'Ad-hoc report',
|
|
276
|
+
object_name: input.object,
|
|
277
|
+
query: input.query,
|
|
278
|
+
format: input.format ?? DEFAULT_FORMAT,
|
|
279
|
+
};
|
|
280
|
+
return this.executeReport(adhoc, context, /* stamp */ false);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private async executeReport(
|
|
284
|
+
report: SavedReport,
|
|
285
|
+
context: SharingExecutionContext,
|
|
286
|
+
stamp = true,
|
|
287
|
+
): Promise<ReportRunResult> {
|
|
288
|
+
const q = report.query ?? {};
|
|
289
|
+
const limit = Math.min(q.limit ?? DEFAULT_LIMIT, this.maxRows);
|
|
290
|
+
const rows = await this.engine.find(report.object_name, {
|
|
291
|
+
filter: q.filter,
|
|
292
|
+
fields: q.fields,
|
|
293
|
+
orderBy: q.orderBy,
|
|
294
|
+
limit,
|
|
295
|
+
// Reports execute with the caller's identity so sharing rules
|
|
296
|
+
// (if installed) apply. Falls back to system bypass only when
|
|
297
|
+
// the report definition was created by a system writer.
|
|
298
|
+
context: {
|
|
299
|
+
userId: context.userId,
|
|
300
|
+
tenantId: context.tenantId,
|
|
301
|
+
roles: context.roles ?? [],
|
|
302
|
+
permissions: context.permissions ?? [],
|
|
303
|
+
isSystem: context.isSystem ?? false,
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
307
|
+
const body = renderReport(list, report.format, q.fields);
|
|
308
|
+
const ranAt = this.clock.now().toISOString();
|
|
309
|
+
|
|
310
|
+
if (stamp && report.id !== '__adhoc__') {
|
|
311
|
+
try {
|
|
312
|
+
await this.engine.update('sys_saved_report', {
|
|
313
|
+
id: report.id,
|
|
314
|
+
last_run_at: ranAt,
|
|
315
|
+
last_row_count: list.length,
|
|
316
|
+
updated_at: ranAt,
|
|
317
|
+
}, { context: SYSTEM_CTX });
|
|
318
|
+
} catch (err) {
|
|
319
|
+
this.logger.warn?.('ReportService: failed to stamp last_run_at', err);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
reportId: report.id,
|
|
325
|
+
rowCount: list.length,
|
|
326
|
+
format: report.format,
|
|
327
|
+
body,
|
|
328
|
+
rows: list,
|
|
329
|
+
ranAt,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Schedules ──────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
async scheduleReport(input: ScheduleReportInput, context: SharingExecutionContext): Promise<ReportSchedule> {
|
|
336
|
+
if (!input.reportId) throw new Error('VALIDATION_FAILED: reportId is required');
|
|
337
|
+
if (!input.recipients || input.recipients.length === 0) {
|
|
338
|
+
throw new Error('VALIDATION_FAILED: recipients must be a non-empty array');
|
|
339
|
+
}
|
|
340
|
+
const report = await this.getReport(input.reportId, context);
|
|
341
|
+
if (!report) throw new Error(`REPORT_NOT_FOUND: ${input.reportId}`);
|
|
342
|
+
|
|
343
|
+
const now = this.clock.now();
|
|
344
|
+
const interval = input.intervalMinutes ?? DEFAULT_INTERVAL_MIN;
|
|
345
|
+
const nextRun = new Date(now.getTime() + interval * 60_000).toISOString();
|
|
346
|
+
const id = uid('rsch');
|
|
347
|
+
const row: any = {
|
|
348
|
+
id,
|
|
349
|
+
report_id: input.reportId,
|
|
350
|
+
name: input.name ?? null,
|
|
351
|
+
interval_minutes: interval,
|
|
352
|
+
cron_expression: input.cronExpression ?? null,
|
|
353
|
+
timezone: input.timezone ?? 'UTC',
|
|
354
|
+
active: input.active !== false,
|
|
355
|
+
recipients: input.recipients.join(','),
|
|
356
|
+
format: input.format ?? 'html_table',
|
|
357
|
+
subject_template: input.subjectTemplate ?? null,
|
|
358
|
+
owner_id: input.ownerId ?? context.userId ?? null,
|
|
359
|
+
next_run_at: nextRun,
|
|
360
|
+
created_at: now.toISOString(),
|
|
361
|
+
updated_at: now.toISOString(),
|
|
362
|
+
};
|
|
363
|
+
await this.engine.insert('sys_report_schedule', row, { context: SYSTEM_CTX });
|
|
364
|
+
return rowFromSchedule(row);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async unscheduleReport(scheduleId: string, _context: SharingExecutionContext): Promise<void> {
|
|
368
|
+
if (!scheduleId) throw new Error('VALIDATION_FAILED: scheduleId is required');
|
|
369
|
+
await this.engine.delete('sys_report_schedule', { where: { id: scheduleId }, context: SYSTEM_CTX });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async listSchedules(
|
|
373
|
+
filter: { reportId?: string } | undefined,
|
|
374
|
+
_context: SharingExecutionContext,
|
|
375
|
+
): Promise<ReportSchedule[]> {
|
|
376
|
+
const f: any = {};
|
|
377
|
+
if (filter?.reportId) f.report_id = filter.reportId;
|
|
378
|
+
const rows = await this.engine.find('sys_report_schedule', {
|
|
379
|
+
filter: f, limit: 500, orderBy: [{ field: 'next_run_at', direction: 'asc' }], context: SYSTEM_CTX,
|
|
380
|
+
});
|
|
381
|
+
return Array.isArray(rows) ? rows.map(rowFromSchedule) : [];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── Dispatcher ─────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
async dispatchDue(now?: Date): Promise<{ fired: number; failed: number; skipped: number }> {
|
|
387
|
+
const ts = (now ?? this.clock.now()).toISOString();
|
|
388
|
+
const due = await this.engine.find('sys_report_schedule', {
|
|
389
|
+
filter: { active: true },
|
|
390
|
+
limit: 200,
|
|
391
|
+
context: SYSTEM_CTX,
|
|
392
|
+
});
|
|
393
|
+
const list = (Array.isArray(due) ? due : []).map(rowFromSchedule)
|
|
394
|
+
.filter(s => !s.next_run_at || s.next_run_at <= ts);
|
|
395
|
+
|
|
396
|
+
let fired = 0, failed = 0, skipped = 0;
|
|
397
|
+
for (const schedule of list) {
|
|
398
|
+
try {
|
|
399
|
+
const report = await this.getReport(schedule.report_id, { isSystem: true });
|
|
400
|
+
if (!report) {
|
|
401
|
+
skipped++;
|
|
402
|
+
await this.markSchedule(schedule.id, {
|
|
403
|
+
last_status: 'skipped',
|
|
404
|
+
last_error: `report ${schedule.report_id} missing`,
|
|
405
|
+
});
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
// Force the schedule's own format so the recipient gets what
|
|
409
|
+
// the admin configured (CSV attachment vs inline HTML table).
|
|
410
|
+
const fmt: ReportFormat = (schedule.format ?? 'html_table') as ReportFormat;
|
|
411
|
+
const result = await this.executeReport({ ...report, format: fmt }, { isSystem: true }, false);
|
|
412
|
+
|
|
413
|
+
const recipients = schedule.recipients.split(',').map(s => s.trim()).filter(Boolean);
|
|
414
|
+
const subject = renderSubject(schedule.subject_template, {
|
|
415
|
+
name: schedule.name ?? report.name,
|
|
416
|
+
date: ts.slice(0, 10),
|
|
417
|
+
rows: String(result.rowCount),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
if (this.email && recipients.length > 0) {
|
|
421
|
+
if (fmt === 'csv') {
|
|
422
|
+
await this.email.send({
|
|
423
|
+
to: recipients,
|
|
424
|
+
subject,
|
|
425
|
+
text: `Attached: ${result.rowCount} row(s).`,
|
|
426
|
+
attachments: [{
|
|
427
|
+
filename: `${(schedule.name ?? report.name).replace(/[^\w.-]+/g, '_')}-${ts.slice(0, 10)}.csv`,
|
|
428
|
+
content: result.body,
|
|
429
|
+
contentType: 'text/csv',
|
|
430
|
+
}],
|
|
431
|
+
relatedObject: 'sys_report_schedule',
|
|
432
|
+
relatedId: schedule.id,
|
|
433
|
+
});
|
|
434
|
+
} else {
|
|
435
|
+
await this.email.send({
|
|
436
|
+
to: recipients,
|
|
437
|
+
subject,
|
|
438
|
+
html: `<p>${escapeHtml(report.name)} — ${result.rowCount} row(s)</p>${result.body}`,
|
|
439
|
+
text: `${report.name} — ${result.rowCount} row(s)`,
|
|
440
|
+
relatedObject: 'sys_report_schedule',
|
|
441
|
+
relatedId: schedule.id,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
} else if (!this.email) {
|
|
445
|
+
this.logger.warn?.('ReportService.dispatchDue: no email service — schedule fired but mail not sent');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
await this.advanceSchedule(schedule, ts);
|
|
449
|
+
fired++;
|
|
450
|
+
} catch (err: any) {
|
|
451
|
+
failed++;
|
|
452
|
+
await this.markSchedule(schedule.id, {
|
|
453
|
+
last_status: 'failed',
|
|
454
|
+
last_error: String(err?.message ?? err ?? 'unknown').slice(0, 500),
|
|
455
|
+
});
|
|
456
|
+
this.logger.error?.('ReportService.dispatchDue: schedule failed', err);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return { fired, failed, skipped };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private async advanceSchedule(schedule: ReportSchedule, ranAt: string): Promise<void> {
|
|
463
|
+
const interval = schedule.interval_minutes ?? DEFAULT_INTERVAL_MIN;
|
|
464
|
+
const nextRun = new Date(this.clock.now().getTime() + interval * 60_000).toISOString();
|
|
465
|
+
await this.engine.update('sys_report_schedule', {
|
|
466
|
+
id: schedule.id,
|
|
467
|
+
next_run_at: nextRun,
|
|
468
|
+
last_sent_at: ranAt,
|
|
469
|
+
last_status: 'ok',
|
|
470
|
+
last_error: null,
|
|
471
|
+
updated_at: ranAt,
|
|
472
|
+
}, { context: SYSTEM_CTX });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private async markSchedule(id: string, patch: Record<string, unknown>): Promise<void> {
|
|
476
|
+
try {
|
|
477
|
+
await this.engine.update('sys_report_schedule', {
|
|
478
|
+
id, ...patch, updated_at: this.clock.now().toISOString(),
|
|
479
|
+
}, { context: SYSTEM_CTX });
|
|
480
|
+
} catch (err) {
|
|
481
|
+
this.logger.warn?.('ReportService: failed to mark schedule', err);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
+
import {
|
|
5
|
+
SysSavedReport,
|
|
6
|
+
SysReportSchedule,
|
|
7
|
+
} from '@objectstack/platform-objects/audit';
|
|
8
|
+
import { ReportService, type ReportEngine, type ReportEmail } from './report-service.js';
|
|
9
|
+
|
|
10
|
+
export interface ReportsPluginOptions {
|
|
11
|
+
/**
|
|
12
|
+
* How often the dispatcher should poll `sys_report_schedule` for
|
|
13
|
+
* due rows. Defaults to 60 seconds — short enough to honour
|
|
14
|
+
* minute-grained schedules without flooding the DB.
|
|
15
|
+
*/
|
|
16
|
+
dispatchIntervalMs?: number;
|
|
17
|
+
/** Cap rows per report. Mirrors ReportServiceOptions.maxRows. */
|
|
18
|
+
maxRows?: number;
|
|
19
|
+
/** Disable the dispatcher tick entirely. */
|
|
20
|
+
disableDispatcher?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ReportsServicePlugin — registers `sys_saved_report` /
|
|
25
|
+
* `sys_report_schedule`, the `reports` service, and the dispatcher
|
|
26
|
+
* loop that emails due schedules.
|
|
27
|
+
*
|
|
28
|
+
* The dispatcher uses `IJobService.schedule` when one is registered;
|
|
29
|
+
* otherwise it falls back to a plain `setInterval` so single-kernel
|
|
30
|
+
* deployments work without `service-job`.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* import { ReportsServicePlugin } from '@objectstack/plugin-reports';
|
|
35
|
+
*
|
|
36
|
+
* kernel.use(new ReportsServicePlugin({ dispatchIntervalMs: 60_000 }));
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export class ReportsServicePlugin implements Plugin {
|
|
40
|
+
name = 'com.objectstack.service.reports';
|
|
41
|
+
version = '1.0.0';
|
|
42
|
+
type = 'standard';
|
|
43
|
+
dependencies = ['com.objectstack.engine.objectql'];
|
|
44
|
+
|
|
45
|
+
private readonly options: ReportsPluginOptions;
|
|
46
|
+
private service?: ReportService;
|
|
47
|
+
private intervalHandle?: ReturnType<typeof setInterval>;
|
|
48
|
+
private jobName?: string;
|
|
49
|
+
private jobService?: any;
|
|
50
|
+
|
|
51
|
+
constructor(options: ReportsPluginOptions = {}) {
|
|
52
|
+
this.options = options;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async init(ctx: PluginContext): Promise<void> {
|
|
56
|
+
ctx.getService<{ register(m: any): void }>('manifest').register({
|
|
57
|
+
id: 'com.objectstack.service.reports',
|
|
58
|
+
name: 'Reports Service',
|
|
59
|
+
version: '1.0.0',
|
|
60
|
+
type: 'plugin',
|
|
61
|
+
scope: 'system',
|
|
62
|
+
defaultDatasource: 'cloud',
|
|
63
|
+
namespace: 'sys',
|
|
64
|
+
objects: [SysSavedReport, SysReportSchedule],
|
|
65
|
+
});
|
|
66
|
+
ctx.logger.info('ReportsServicePlugin: schemas registered');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async start(ctx: PluginContext): Promise<void> {
|
|
70
|
+
ctx.hook('kernel:ready', async () => {
|
|
71
|
+
let engine: any = null;
|
|
72
|
+
try { engine = ctx.getService<any>('objectql'); }
|
|
73
|
+
catch { try { engine = ctx.getService<any>('data'); } catch { /* ignore */ } }
|
|
74
|
+
if (!engine) {
|
|
75
|
+
ctx.logger.warn('ReportsServicePlugin: no ObjectQL engine — service NOT registered');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let email: ReportEmail | undefined;
|
|
80
|
+
try { email = ctx.getService<any>('email'); } catch { /* email is optional */ }
|
|
81
|
+
if (!email) {
|
|
82
|
+
ctx.logger.warn('ReportsServicePlugin: no email service — schedules will fire without delivery');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.service = new ReportService({
|
|
86
|
+
engine: engine as ReportEngine,
|
|
87
|
+
email,
|
|
88
|
+
logger: ctx.logger,
|
|
89
|
+
maxRows: this.options.maxRows,
|
|
90
|
+
});
|
|
91
|
+
ctx.registerService('reports', this.service);
|
|
92
|
+
|
|
93
|
+
if (this.options.disableDispatcher) {
|
|
94
|
+
ctx.logger.info('ReportsServicePlugin: dispatcher disabled (disableDispatcher=true)');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const intervalMs = Math.max(5_000, this.options.dispatchIntervalMs ?? 60_000);
|
|
99
|
+
|
|
100
|
+
// Prefer the platform job service when available — it lets ops
|
|
101
|
+
// see report dispatch alongside every other scheduled job.
|
|
102
|
+
try {
|
|
103
|
+
const job = ctx.getService<any>('job');
|
|
104
|
+
if (job && typeof job.schedule === 'function') {
|
|
105
|
+
this.jobService = job;
|
|
106
|
+
this.jobName = 'reports.dispatch';
|
|
107
|
+
await job.schedule(this.jobName, { type: 'interval', intervalMs }, async () => {
|
|
108
|
+
try { await this.service?.dispatchDue(); }
|
|
109
|
+
catch (err) { ctx.logger.warn('ReportsServicePlugin: dispatch tick failed', err as any); }
|
|
110
|
+
});
|
|
111
|
+
ctx.logger.info('ReportsServicePlugin: dispatcher registered with job service', { intervalMs });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
} catch { /* fall through to setInterval */ }
|
|
115
|
+
|
|
116
|
+
this.intervalHandle = setInterval(() => {
|
|
117
|
+
this.service?.dispatchDue().catch(err => {
|
|
118
|
+
ctx.logger.warn('ReportsServicePlugin: dispatch tick failed', err);
|
|
119
|
+
});
|
|
120
|
+
}, intervalMs);
|
|
121
|
+
// Don't keep Node alive purely for the dispatcher — common
|
|
122
|
+
// mistake in tests / serverless. unref is a no-op in some
|
|
123
|
+
// runtimes which is fine.
|
|
124
|
+
(this.intervalHandle as any)?.unref?.();
|
|
125
|
+
ctx.logger.info('ReportsServicePlugin: dispatcher registered (setInterval fallback)', { intervalMs });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async stop(ctx: PluginContext): Promise<void> {
|
|
130
|
+
if (this.intervalHandle) clearInterval(this.intervalHandle);
|
|
131
|
+
this.intervalHandle = undefined;
|
|
132
|
+
if (this.jobService && this.jobName && typeof this.jobService.cancel === 'function') {
|
|
133
|
+
try { await this.jobService.cancel(this.jobName); }
|
|
134
|
+
catch (err) { ctx.logger.warn('ReportsServicePlugin: failed to cancel job', err as any); }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|