@voyant-travel/reporting 0.2.3 → 0.3.0
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/dist/export.d.ts +8 -0
- package/dist/export.js +242 -0
- package/dist/registry.d.ts +1 -0
- package/dist/registry.js +30 -2
- package/dist/routes.js +37 -38
- package/dist/schema.d.ts +0 -513
- package/dist/schema.js +2 -36
- package/dist/service.d.ts +35 -116
- package/dist/service.js +59 -175
- package/migrations/20260719170000_drop_report_versioning.sql +3 -0
- package/migrations/meta/_journal.json +7 -0
- package/openapi/admin/reporting.json +283 -129
- package/package.json +5 -3
package/dist/service.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { createReportDefinitionSchema, ReportParameters, updateReportDefini
|
|
|
2
2
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
3
3
|
import type { z } from "zod";
|
|
4
4
|
import type { ReportingRegistry } from "./registry.js";
|
|
5
|
-
import { type ReportDefinitionRow
|
|
5
|
+
import { type ReportDefinitionRow } from "./schema.js";
|
|
6
6
|
export type CreateReportDefinitionInput = z.infer<typeof createReportDefinitionSchema>;
|
|
7
7
|
export type UpdateReportDefinitionInput = z.infer<typeof updateReportDefinitionSchema>;
|
|
8
8
|
export declare class ReportDefinitionRevisionConflictError extends Error {
|
|
@@ -11,9 +11,6 @@ export declare class ReportDefinitionRevisionConflictError extends Error {
|
|
|
11
11
|
export declare class ReportingRecordNotFoundError extends Error {
|
|
12
12
|
constructor(record: string);
|
|
13
13
|
}
|
|
14
|
-
export declare class ReportDefinitionRetentionConflictError extends Error {
|
|
15
|
-
constructor();
|
|
16
|
-
}
|
|
17
14
|
export declare function createReportingService(registry: ReportingRegistry): {
|
|
18
15
|
list(db: PostgresJsDatabase, input: {
|
|
19
16
|
limit: number;
|
|
@@ -390,120 +387,42 @@ export declare function createReportingService(registry: ReportingRegistry): {
|
|
|
390
387
|
updatedAt: Date;
|
|
391
388
|
}>;
|
|
392
389
|
remove(db: PostgresJsDatabase, id: string): Promise<boolean>;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
reportDefinitionId: string;
|
|
401
|
-
definitionRevision: number;
|
|
402
|
-
snapshot: {
|
|
403
|
-
parameters: Record<string, string | number | boolean | (string | number | boolean | null)[] | null>;
|
|
404
|
-
widgets: {
|
|
405
|
-
id: string;
|
|
406
|
-
source: {
|
|
407
|
-
kind: "preset";
|
|
408
|
-
widgetId: string;
|
|
409
|
-
version?: number | undefined;
|
|
410
|
-
} | {
|
|
411
|
-
kind: "custom";
|
|
412
|
-
definition: {
|
|
413
|
-
id: string;
|
|
414
|
-
version: number;
|
|
415
|
-
label: string;
|
|
416
|
-
query: {
|
|
417
|
-
dataset: {
|
|
418
|
-
id: string;
|
|
419
|
-
version?: number | undefined;
|
|
420
|
-
};
|
|
421
|
-
select: ({
|
|
422
|
-
kind: "field";
|
|
423
|
-
field: string;
|
|
424
|
-
as?: string | undefined;
|
|
425
|
-
} | {
|
|
426
|
-
kind: "aggregate";
|
|
427
|
-
operation: "minimum" | "maximum" | "count" | "countDistinct" | "sum" | "average";
|
|
428
|
-
as: string;
|
|
429
|
-
field?: string | undefined;
|
|
430
|
-
})[];
|
|
431
|
-
filters: {
|
|
432
|
-
field: string;
|
|
433
|
-
operator: "in" | "equal" | "notEqual" | "notIn" | "greaterThan" | "greaterThanOrEqual" | "lessThan" | "lessThanOrEqual" | "between" | "contains" | "isNull" | "isNotNull";
|
|
434
|
-
value?: {
|
|
435
|
-
kind: "literal";
|
|
436
|
-
value: string | number | boolean | (string | number | boolean | null)[] | null;
|
|
437
|
-
} | {
|
|
438
|
-
kind: "parameter";
|
|
439
|
-
name: string;
|
|
440
|
-
} | undefined;
|
|
441
|
-
}[];
|
|
442
|
-
groupBy: {
|
|
443
|
-
field: string;
|
|
444
|
-
timeGrain?: "day" | "week" | "month" | "quarter" | "year" | undefined;
|
|
445
|
-
}[];
|
|
446
|
-
orderBy: {
|
|
447
|
-
by: string;
|
|
448
|
-
direction: "ascending" | "descending";
|
|
449
|
-
}[];
|
|
450
|
-
limit?: number | undefined;
|
|
451
|
-
};
|
|
452
|
-
visualization: {
|
|
453
|
-
type: "kpi" | "table" | "line" | "bar" | "pie";
|
|
454
|
-
options: Record<string, unknown>;
|
|
455
|
-
};
|
|
456
|
-
defaultSize: {
|
|
457
|
-
width: number;
|
|
458
|
-
height: number;
|
|
459
|
-
};
|
|
460
|
-
description?: string | undefined;
|
|
461
|
-
minimumSize?: {
|
|
462
|
-
width: number;
|
|
463
|
-
height: number;
|
|
464
|
-
} | undefined;
|
|
465
|
-
maximumSize?: {
|
|
466
|
-
width: number;
|
|
467
|
-
height: number;
|
|
468
|
-
} | undefined;
|
|
469
|
-
};
|
|
470
|
-
};
|
|
471
|
-
layout: {
|
|
472
|
-
width: number;
|
|
473
|
-
height: number;
|
|
474
|
-
x: number;
|
|
475
|
-
y: number;
|
|
476
|
-
};
|
|
477
|
-
title?: string | undefined;
|
|
478
|
-
}[];
|
|
479
|
-
};
|
|
480
|
-
}>;
|
|
481
|
-
runVersion(db: PostgresJsDatabase, versionId: string, parameters: ReportParameters, context: {
|
|
390
|
+
/**
|
|
391
|
+
* Execute every widget in a report and return its tabular data, ready to be
|
|
392
|
+
* serialized to CSV / XLSX / PDF. Widgets that fail (unavailable, scope, or
|
|
393
|
+
* query error) become a section carrying the error message rather than
|
|
394
|
+
* aborting the whole export.
|
|
395
|
+
*/
|
|
396
|
+
exportReport(db: PostgresJsDatabase, id: string, parameters: ReportParameters, context: {
|
|
482
397
|
actorId?: string;
|
|
483
398
|
grantedScopes: readonly string[];
|
|
484
399
|
signal?: AbortSignal;
|
|
485
|
-
}): Promise<
|
|
486
|
-
|
|
487
|
-
|
|
400
|
+
}): Promise<ReportExport | null>;
|
|
401
|
+
};
|
|
402
|
+
/** Presentation hints carried from a widget's visualization options into export. */
|
|
403
|
+
export interface ReportExportSectionFormat {
|
|
404
|
+
/** Currency values are stored in minor units (cents) and must be divided by 100. */
|
|
405
|
+
readonly minorUnit: boolean;
|
|
406
|
+
/** Column whose value holds the ISO currency code for each row. */
|
|
407
|
+
readonly currencyField?: string;
|
|
408
|
+
/** Fallback ISO currency when a row has no {@link currencyField}. */
|
|
409
|
+
readonly currency?: string;
|
|
410
|
+
}
|
|
411
|
+
/** A single widget's tabular data within an exported report. */
|
|
412
|
+
export interface ReportExportSection {
|
|
413
|
+
readonly title: string;
|
|
414
|
+
readonly columns: ReadonlyArray<{
|
|
488
415
|
id: string;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
status: "succeeded" | "failed" | "running";
|
|
492
|
-
error: string | null;
|
|
493
|
-
triggeredByUserId: string | null;
|
|
494
|
-
startedAt: Date;
|
|
495
|
-
completedAt: Date | null;
|
|
416
|
+
label: string;
|
|
417
|
+
valueType: string;
|
|
496
418
|
}>;
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
createdAt: Date;
|
|
508
|
-
} | null>;
|
|
509
|
-
};
|
|
419
|
+
readonly rows: ReadonlyArray<Record<string, unknown>>;
|
|
420
|
+
readonly format?: ReportExportSectionFormat;
|
|
421
|
+
readonly error?: string;
|
|
422
|
+
}
|
|
423
|
+
/** A report resolved to plain tabular sections for file export. */
|
|
424
|
+
export interface ReportExport {
|
|
425
|
+
readonly name: string;
|
|
426
|
+
readonly description: string | null;
|
|
427
|
+
readonly sections: readonly ReportExportSection[];
|
|
428
|
+
}
|
package/dist/service.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { and, desc, eq, sql } from "drizzle-orm";
|
|
2
|
-
import { reportDefinitions
|
|
2
|
+
import { reportDefinitions } from "./schema.js";
|
|
3
3
|
export class ReportDefinitionRevisionConflictError extends Error {
|
|
4
4
|
constructor() {
|
|
5
5
|
super("The report draft changed since it was loaded.");
|
|
@@ -12,12 +12,6 @@ export class ReportingRecordNotFoundError extends Error {
|
|
|
12
12
|
this.name = "ReportingRecordNotFoundError";
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
-
export class ReportDefinitionRetentionConflictError extends Error {
|
|
16
|
-
constructor() {
|
|
17
|
-
super("Reports with retained execution history cannot be deleted.");
|
|
18
|
-
this.name = "ReportDefinitionRetentionConflictError";
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
15
|
export function createReportingService(registry) {
|
|
22
16
|
return {
|
|
23
17
|
async list(db, input) {
|
|
@@ -83,177 +77,67 @@ export function createReportingService(registry) {
|
|
|
83
77
|
throw new ReportDefinitionRevisionConflictError();
|
|
84
78
|
},
|
|
85
79
|
async remove(db, id) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
.limit(1)
|
|
92
|
-
.for("update");
|
|
93
|
-
if (!definition)
|
|
94
|
-
return false;
|
|
95
|
-
await transaction
|
|
96
|
-
.select({ id: reportVersions.id })
|
|
97
|
-
.from(reportVersions)
|
|
98
|
-
.where(eq(reportVersions.reportDefinitionId, id))
|
|
99
|
-
.for("update");
|
|
100
|
-
const [retainedRun] = await transaction
|
|
101
|
-
.select({ id: reportRuns.id })
|
|
102
|
-
.from(reportRuns)
|
|
103
|
-
.innerJoin(reportVersions, eq(reportRuns.reportVersionId, reportVersions.id))
|
|
104
|
-
.where(eq(reportVersions.reportDefinitionId, id))
|
|
105
|
-
.limit(1);
|
|
106
|
-
if (retainedRun)
|
|
107
|
-
throw new ReportDefinitionRetentionConflictError();
|
|
108
|
-
await transaction.delete(reportDefinitions).where(eq(reportDefinitions.id, id));
|
|
109
|
-
return true;
|
|
110
|
-
});
|
|
111
|
-
},
|
|
112
|
-
async createVersion(db, reportDefinitionId, expectedRevision, actorId) {
|
|
113
|
-
return db.transaction(async (transaction) => {
|
|
114
|
-
const [definition] = await transaction
|
|
115
|
-
.select()
|
|
116
|
-
.from(reportDefinitions)
|
|
117
|
-
.where(eq(reportDefinitions.id, reportDefinitionId))
|
|
118
|
-
.limit(1)
|
|
119
|
-
.for("update");
|
|
120
|
-
if (!definition)
|
|
121
|
-
throw new ReportingRecordNotFoundError("Report definition");
|
|
122
|
-
if (definition.revision !== expectedRevision)
|
|
123
|
-
throw new ReportDefinitionRevisionConflictError();
|
|
124
|
-
const [latest] = await transaction
|
|
125
|
-
.select({ version: reportVersions.version })
|
|
126
|
-
.from(reportVersions)
|
|
127
|
-
.where(eq(reportVersions.reportDefinitionId, reportDefinitionId))
|
|
128
|
-
.orderBy(desc(reportVersions.version))
|
|
129
|
-
.limit(1);
|
|
130
|
-
const [version] = await transaction
|
|
131
|
-
.insert(reportVersions)
|
|
132
|
-
.values({
|
|
133
|
-
reportDefinitionId,
|
|
134
|
-
version: (latest?.version ?? 0) + 1,
|
|
135
|
-
name: definition.name,
|
|
136
|
-
description: definition.description,
|
|
137
|
-
definitionRevision: definition.revision,
|
|
138
|
-
snapshot: registry.snapshotDraft(definition.draft),
|
|
139
|
-
createdByUserId: actorId,
|
|
140
|
-
})
|
|
141
|
-
.returning();
|
|
142
|
-
if (!version)
|
|
143
|
-
throw new Error("Report version insert returned no row.");
|
|
144
|
-
return version;
|
|
145
|
-
});
|
|
80
|
+
const deleted = await db
|
|
81
|
+
.delete(reportDefinitions)
|
|
82
|
+
.where(eq(reportDefinitions.id, id))
|
|
83
|
+
.returning({ id: reportDefinitions.id });
|
|
84
|
+
return deleted.length > 0;
|
|
146
85
|
},
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Execute every widget in a report and return its tabular data, ready to be
|
|
88
|
+
* serialized to CSV / XLSX / PDF. Widgets that fail (unavailable, scope, or
|
|
89
|
+
* query error) become a section carrying the error message rather than
|
|
90
|
+
* aborting the whole export.
|
|
91
|
+
*/
|
|
92
|
+
async exportReport(db, id, parameters, context) {
|
|
93
|
+
const [definition] = await db
|
|
94
|
+
.select()
|
|
95
|
+
.from(reportDefinitions)
|
|
96
|
+
.where(eq(reportDefinitions.id, id))
|
|
97
|
+
.limit(1);
|
|
98
|
+
if (!definition)
|
|
99
|
+
return null;
|
|
100
|
+
const merged = { ...definition.draft.parameters, ...parameters };
|
|
101
|
+
const sections = [];
|
|
102
|
+
for (const resolved of registry.resolveDraft(definition.draft, "view")) {
|
|
103
|
+
const title = resolved.definition
|
|
104
|
+
? (resolved.instance.title ?? resolved.definition.label)
|
|
105
|
+
: (resolved.instance.title ?? resolved.instance.id);
|
|
106
|
+
if (resolved.status === "missing" || !resolved.definition) {
|
|
107
|
+
sections.push({ title, columns: [], rows: [], error: resolved.missingReason });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const result = await registry.executeQuery({
|
|
112
|
+
db,
|
|
113
|
+
actorId: context.actorId,
|
|
114
|
+
grantedScopes: context.grantedScopes,
|
|
115
|
+
query: resolved.definition.query,
|
|
116
|
+
parameters: merged,
|
|
117
|
+
signal: context.signal,
|
|
118
|
+
});
|
|
119
|
+
const options = resolved.definition.visualization.options ?? {};
|
|
120
|
+
sections.push({
|
|
121
|
+
title,
|
|
122
|
+
columns: result.columns,
|
|
123
|
+
rows: result.rows,
|
|
124
|
+
format: {
|
|
125
|
+
minorUnit: options.minorUnit === true,
|
|
126
|
+
currencyField: typeof options.currencyField === "string" ? options.currencyField : undefined,
|
|
127
|
+
currency: typeof options.currency === "string" ? options.currency : undefined,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
sections.push({
|
|
133
|
+
title,
|
|
134
|
+
columns: [],
|
|
135
|
+
rows: [],
|
|
136
|
+
error: error instanceof Error ? error.message : "Widget failed to export.",
|
|
137
|
+
});
|
|
161
138
|
}
|
|
162
|
-
const [run] = await transaction
|
|
163
|
-
.insert(reportRuns)
|
|
164
|
-
.values({ reportVersionId: versionId, parameters, triggeredByUserId: context.actorId })
|
|
165
|
-
.returning();
|
|
166
|
-
if (!run)
|
|
167
|
-
throw new Error("Report run insert returned no row.");
|
|
168
|
-
return { run, version };
|
|
169
|
-
});
|
|
170
|
-
const { run, version } = execution;
|
|
171
|
-
try {
|
|
172
|
-
const executionSignal = context.signal
|
|
173
|
-
? AbortSignal.any([context.signal, AbortSignal.timeout(60_000)])
|
|
174
|
-
: AbortSignal.timeout(60_000);
|
|
175
|
-
const output = await executeDraft(registry, db, version.snapshot, parameters, {
|
|
176
|
-
...context,
|
|
177
|
-
signal: executionSignal,
|
|
178
|
-
});
|
|
179
|
-
const failed = output.widgets.some((widget) => widget.status === "failed");
|
|
180
|
-
const [completed] = await db
|
|
181
|
-
.update(reportRuns)
|
|
182
|
-
.set({
|
|
183
|
-
status: failed ? "failed" : "succeeded",
|
|
184
|
-
output,
|
|
185
|
-
error: failed ? "One or more widgets failed." : null,
|
|
186
|
-
completedAt: new Date(),
|
|
187
|
-
})
|
|
188
|
-
.where(eq(reportRuns.id, run.id))
|
|
189
|
-
.returning();
|
|
190
|
-
return completed ?? run;
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
const message = error instanceof Error ? error.message : "Report execution failed.";
|
|
194
|
-
const [failed] = await db
|
|
195
|
-
.update(reportRuns)
|
|
196
|
-
.set({ status: "failed", error: message, completedAt: new Date() })
|
|
197
|
-
.where(eq(reportRuns.id, run.id))
|
|
198
|
-
.returning();
|
|
199
|
-
return failed ?? run;
|
|
200
|
-
}
|
|
201
|
-
},
|
|
202
|
-
async getRun(db, id, grantedScopes) {
|
|
203
|
-
const [run] = await db.select().from(reportRuns).where(eq(reportRuns.id, id)).limit(1);
|
|
204
|
-
if (run) {
|
|
205
|
-
const requiredScopes = [
|
|
206
|
-
...new Set(run.output?.widgets.flatMap((widget) => widget.requiredScopes ?? []) ?? []),
|
|
207
|
-
];
|
|
208
|
-
registry.requireScopes(requiredScopes, grantedScopes);
|
|
209
139
|
}
|
|
210
|
-
return
|
|
140
|
+
return { name: definition.name, description: definition.description, sections };
|
|
211
141
|
},
|
|
212
142
|
};
|
|
213
143
|
}
|
|
214
|
-
async function executeDraft(registry, db, draft, runParameters, context) {
|
|
215
|
-
const parameters = { ...draft.parameters, ...runParameters };
|
|
216
|
-
const widgets = [];
|
|
217
|
-
for (const resolved of registry.resolveDraft(draft, "edit")) {
|
|
218
|
-
if (resolved.status === "missing" || !resolved.definition) {
|
|
219
|
-
widgets.push({
|
|
220
|
-
widgetInstanceId: resolved.instance.id,
|
|
221
|
-
status: "missing",
|
|
222
|
-
reason: resolved.missingReason ?? "Widget is unavailable.",
|
|
223
|
-
});
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
let provenance;
|
|
227
|
-
try {
|
|
228
|
-
const validation = registry.validateQuery(resolved.definition.query, context.grantedScopes);
|
|
229
|
-
provenance = {
|
|
230
|
-
datasetId: validation.dataset.definition.id,
|
|
231
|
-
datasetVersion: validation.dataset.definition.version,
|
|
232
|
-
requiredScopes: validation.requiredScopes,
|
|
233
|
-
};
|
|
234
|
-
const result = await registry.executeQuery({
|
|
235
|
-
db,
|
|
236
|
-
actorId: context.actorId,
|
|
237
|
-
grantedScopes: context.grantedScopes,
|
|
238
|
-
query: resolved.definition.query,
|
|
239
|
-
parameters,
|
|
240
|
-
signal: context.signal,
|
|
241
|
-
});
|
|
242
|
-
widgets.push({
|
|
243
|
-
widgetInstanceId: resolved.instance.id,
|
|
244
|
-
status: "succeeded",
|
|
245
|
-
...provenance,
|
|
246
|
-
result,
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
catch (error) {
|
|
250
|
-
widgets.push({
|
|
251
|
-
widgetInstanceId: resolved.instance.id,
|
|
252
|
-
status: "failed",
|
|
253
|
-
...provenance,
|
|
254
|
-
reason: error instanceof Error ? error.message : "Widget execution failed.",
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
return { widgets };
|
|
259
|
-
}
|