@voyant-travel/reporting 0.1.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.
@@ -0,0 +1,259 @@
1
+ import { and, desc, eq, sql } from "drizzle-orm";
2
+ import { reportDefinitions, reportRuns, reportVersions, } from "./schema.js";
3
+ export class ReportDefinitionRevisionConflictError extends Error {
4
+ constructor() {
5
+ super("The report draft changed since it was loaded.");
6
+ this.name = "ReportDefinitionRevisionConflictError";
7
+ }
8
+ }
9
+ export class ReportingRecordNotFoundError extends Error {
10
+ constructor(record) {
11
+ super(`${record} was not found.`);
12
+ this.name = "ReportingRecordNotFoundError";
13
+ }
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
+ export function createReportingService(registry) {
22
+ return {
23
+ async list(db, input) {
24
+ const [data, count] = await Promise.all([
25
+ db
26
+ .select()
27
+ .from(reportDefinitions)
28
+ .orderBy(desc(reportDefinitions.updatedAt))
29
+ .limit(input.limit)
30
+ .offset(input.offset),
31
+ db.select({ count: sql `count(*)::int` }).from(reportDefinitions),
32
+ ]);
33
+ return { data, total: count[0]?.count ?? 0, ...input };
34
+ },
35
+ async get(db, id) {
36
+ const [row] = await db
37
+ .select()
38
+ .from(reportDefinitions)
39
+ .where(eq(reportDefinitions.id, id))
40
+ .limit(1);
41
+ return row ?? null;
42
+ },
43
+ async create(db, input, actorId) {
44
+ const [row] = await db
45
+ .insert(reportDefinitions)
46
+ .values({
47
+ ...input,
48
+ description: input.description ?? null,
49
+ sourceTemplateId: input.sourceTemplateId ?? null,
50
+ sourceTemplateVersion: input.sourceTemplateVersion ?? null,
51
+ createdByUserId: actorId,
52
+ updatedByUserId: actorId,
53
+ })
54
+ .returning();
55
+ if (!row)
56
+ throw new Error("Report definition insert returned no row.");
57
+ return row;
58
+ },
59
+ async instantiateTemplate(db, input, actorId) {
60
+ const template = registry.getTemplate(input.templateId, input.version);
61
+ if (!template)
62
+ throw new ReportingRecordNotFoundError("Report template");
63
+ return this.create(db, {
64
+ name: input.name,
65
+ description: input.description,
66
+ sourceTemplateId: template.id,
67
+ sourceTemplateVersion: template.version,
68
+ draft: { parameters: {}, widgets: template.widgets },
69
+ }, actorId);
70
+ },
71
+ async update(db, id, input, actorId) {
72
+ const { revision, ...patch } = input;
73
+ const [row] = await db
74
+ .update(reportDefinitions)
75
+ .set({ ...patch, revision: revision + 1, updatedByUserId: actorId, updatedAt: new Date() })
76
+ .where(and(eq(reportDefinitions.id, id), eq(reportDefinitions.revision, revision)))
77
+ .returning();
78
+ if (row)
79
+ return row;
80
+ const existing = await this.get(db, id);
81
+ if (!existing)
82
+ throw new ReportingRecordNotFoundError("Report definition");
83
+ throw new ReportDefinitionRevisionConflictError();
84
+ },
85
+ async remove(db, id) {
86
+ return db.transaction(async (transaction) => {
87
+ const [definition] = await transaction
88
+ .select({ id: reportDefinitions.id })
89
+ .from(reportDefinitions)
90
+ .where(eq(reportDefinitions.id, id))
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
+ });
146
+ },
147
+ async runVersion(db, versionId, parameters, context) {
148
+ const execution = await db.transaction(async (transaction) => {
149
+ const [version] = await transaction
150
+ .select()
151
+ .from(reportVersions)
152
+ .where(eq(reportVersions.id, versionId))
153
+ .limit(1)
154
+ .for("share");
155
+ if (!version)
156
+ throw new ReportingRecordNotFoundError("Report version");
157
+ for (const resolved of registry.resolveDraft(version.snapshot, "edit")) {
158
+ if (resolved.status === "missing" || !resolved.definition)
159
+ continue;
160
+ registry.validateQuery(resolved.definition.query, context.grantedScopes);
161
+ }
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
+ }
210
+ return run ?? null;
211
+ },
212
+ };
213
+ }
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
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Static admin contribution metadata used by the Reporting deployment manifest.
3
+ * The selected factory contributes the operator navigation group and both
4
+ * routes; the per-route runtime carries the neutral factory the host binds.
5
+ */
6
+ export declare const reportingVoyantAdmin: {
7
+ readonly compositionOrder: 60;
8
+ readonly runtime: {
9
+ readonly entry: "@voyant-travel/reporting-react/admin";
10
+ readonly export: "createSelectedReportingAdminExtension";
11
+ };
12
+ readonly routes: readonly [{
13
+ readonly id: "@voyant-travel/reporting#admin.route.index";
14
+ readonly path: "/reporting";
15
+ readonly requiredScopes: readonly ["reports:read"];
16
+ readonly runtime: {
17
+ readonly entry: "@voyant-travel/reporting-react/admin";
18
+ readonly export: "createReportingAdminExtension";
19
+ };
20
+ }, {
21
+ readonly id: "@voyant-travel/reporting#admin.route.detail";
22
+ readonly path: "/reporting/$id";
23
+ readonly requiredScopes: readonly ["reports:read"];
24
+ readonly runtime: {
25
+ readonly entry: "@voyant-travel/reporting-react/admin";
26
+ readonly export: "createReportingAdminExtension";
27
+ };
28
+ }];
29
+ };
@@ -0,0 +1,30 @@
1
+ const reportingAdminRuntime = {
2
+ entry: "@voyant-travel/reporting-react/admin",
3
+ export: "createReportingAdminExtension",
4
+ };
5
+ /**
6
+ * Static admin contribution metadata used by the Reporting deployment manifest.
7
+ * The selected factory contributes the operator navigation group and both
8
+ * routes; the per-route runtime carries the neutral factory the host binds.
9
+ */
10
+ export const reportingVoyantAdmin = {
11
+ compositionOrder: 60,
12
+ runtime: {
13
+ entry: "@voyant-travel/reporting-react/admin",
14
+ export: "createSelectedReportingAdminExtension",
15
+ },
16
+ routes: [
17
+ {
18
+ id: "@voyant-travel/reporting#admin.route.index",
19
+ path: "/reporting",
20
+ requiredScopes: ["reports:read"],
21
+ runtime: reportingAdminRuntime,
22
+ },
23
+ {
24
+ id: "@voyant-travel/reporting#admin.route.detail",
25
+ path: "/reporting/$id",
26
+ requiredScopes: ["reports:read"],
27
+ runtime: reportingAdminRuntime,
28
+ },
29
+ ],
30
+ };
@@ -0,0 +1,2 @@
1
+ export declare const reportingVoyantModule: import("@voyant-travel/core").VoyantGraphUnitManifest;
2
+ export default reportingVoyantModule;
package/dist/voyant.js ADDED
@@ -0,0 +1,116 @@
1
+ import { defineModule, requirePort } from "@voyant-travel/core/project";
2
+ import { reportingContributionRuntimePort } from "@voyant-travel/reporting-contracts/runtime-port";
3
+ import { reportingVoyantAdmin } from "./voyant-admin.js";
4
+ export const reportingVoyantModule = defineModule({
5
+ id: "@voyant-travel/reporting",
6
+ packageName: "@voyant-travel/reporting",
7
+ localId: "reporting",
8
+ runtimePorts: [
9
+ requirePort(reportingContributionRuntimePort, { optional: true, cardinality: "many" }),
10
+ ],
11
+ reporting: {
12
+ templates: [
13
+ {
14
+ id: "reporting.template.operator-overview",
15
+ version: 1,
16
+ label: "Operator overview",
17
+ description: "A cross-module overview of booking activity and final-invoice receivables.",
18
+ requirements: [
19
+ { kind: "widget", id: "bookings.widget.total" },
20
+ { kind: "widget", id: "bookings.widget.monthly-trend" },
21
+ { kind: "widget", id: "finance.outstanding-by-currency" },
22
+ { kind: "widget", id: "finance.net-issued-trend" },
23
+ { kind: "widget", id: "finance.invoice-status-breakdown" },
24
+ ],
25
+ widgets: [
26
+ {
27
+ id: "total-bookings",
28
+ widgetId: "bookings.widget.total",
29
+ widgetVersion: 1,
30
+ layout: { x: 0, y: 0, width: 3, height: 2 },
31
+ },
32
+ {
33
+ id: "outstanding-receivables",
34
+ widgetId: "finance.outstanding-by-currency",
35
+ widgetVersion: 1,
36
+ layout: { x: 3, y: 0, width: 4, height: 3 },
37
+ },
38
+ {
39
+ id: "invoice-status",
40
+ widgetId: "finance.invoice-status-breakdown",
41
+ widgetVersion: 1,
42
+ layout: { x: 7, y: 0, width: 5, height: 3 },
43
+ },
44
+ {
45
+ id: "monthly-bookings",
46
+ widgetId: "bookings.widget.monthly-trend",
47
+ widgetVersion: 1,
48
+ layout: { x: 0, y: 3, width: 6, height: 4 },
49
+ },
50
+ {
51
+ id: "net-issued",
52
+ widgetId: "finance.net-issued-trend",
53
+ widgetVersion: 1,
54
+ layout: { x: 6, y: 3, width: 6, height: 4 },
55
+ },
56
+ ],
57
+ },
58
+ ],
59
+ },
60
+ api: [
61
+ {
62
+ id: "@voyant-travel/reporting#api.admin",
63
+ surface: "admin",
64
+ mount: "reporting",
65
+ resource: "reports",
66
+ authorization: "route",
67
+ openapi: { document: "reporting" },
68
+ runtime: {
69
+ entry: "@voyant-travel/reporting/api-runtime",
70
+ export: "createReportingApiModule",
71
+ },
72
+ },
73
+ ],
74
+ schema: [{ id: "@voyant-travel/reporting#schema", source: "@voyant-travel/reporting/schema" }],
75
+ migrations: [{ id: "@voyant-travel/reporting#migrations", source: "./migrations" }],
76
+ resources: [
77
+ {
78
+ id: "@voyant-travel/reporting#resource.database",
79
+ kind: "database",
80
+ required: true,
81
+ config: { engine: "postgres" },
82
+ },
83
+ ],
84
+ access: {
85
+ resources: [
86
+ {
87
+ id: "@voyant-travel/reporting#access.reports",
88
+ resource: "reports",
89
+ label: "Reports",
90
+ description: "Build, view, publish, and export reports.",
91
+ actions: [
92
+ {
93
+ action: "read",
94
+ label: "View reports",
95
+ description: "View reports and preview queries.",
96
+ },
97
+ {
98
+ action: "write",
99
+ label: "Manage reports",
100
+ description: "Create, edit, remove, and publish reports.",
101
+ },
102
+ {
103
+ action: "export",
104
+ label: "Run and export reports",
105
+ description: "Execute immutable report versions and access their output.",
106
+ sensitive: true,
107
+ },
108
+ ],
109
+ },
110
+ ],
111
+ },
112
+ admin: reportingVoyantAdmin,
113
+ lifecycle: { uninstall: { default: "retain-data", purge: "not-supported" } },
114
+ meta: { ownership: "package" },
115
+ });
116
+ export default reportingVoyantModule;
@@ -0,0 +1,54 @@
1
+ CREATE TYPE "public"."report_run_status" AS ENUM('running', 'succeeded', 'failed');
2
+ --> statement-breakpoint
3
+ CREATE TABLE "report_definitions" (
4
+ "id" text PRIMARY KEY NOT NULL,
5
+ "name" text NOT NULL,
6
+ "description" text,
7
+ "source_template_id" text,
8
+ "source_template_version" integer,
9
+ "draft" jsonb NOT NULL,
10
+ "revision" integer DEFAULT 1 NOT NULL,
11
+ "created_by_user_id" text,
12
+ "updated_by_user_id" text,
13
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
14
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
15
+ );
16
+ --> statement-breakpoint
17
+ CREATE TABLE "report_versions" (
18
+ "id" text PRIMARY KEY NOT NULL,
19
+ "report_definition_id" text NOT NULL,
20
+ "version" integer NOT NULL,
21
+ "name" text NOT NULL,
22
+ "description" text,
23
+ "definition_revision" integer NOT NULL,
24
+ "snapshot" jsonb NOT NULL,
25
+ "created_by_user_id" text,
26
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
27
+ );
28
+ --> statement-breakpoint
29
+ CREATE TABLE "report_runs" (
30
+ "id" text PRIMARY KEY NOT NULL,
31
+ "report_version_id" text NOT NULL,
32
+ "status" "report_run_status" DEFAULT 'running' NOT NULL,
33
+ "parameters" jsonb NOT NULL,
34
+ "output" jsonb,
35
+ "error" text,
36
+ "triggered_by_user_id" text,
37
+ "started_at" timestamp with time zone DEFAULT now() NOT NULL,
38
+ "completed_at" timestamp with time zone,
39
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
40
+ );
41
+ --> statement-breakpoint
42
+ ALTER TABLE "report_versions" ADD CONSTRAINT "report_versions_report_definition_id_report_definitions_id_fk" FOREIGN KEY ("report_definition_id") REFERENCES "public"."report_definitions"("id") ON DELETE cascade ON UPDATE no action;
43
+ --> statement-breakpoint
44
+ ALTER TABLE "report_runs" ADD CONSTRAINT "report_runs_report_version_id_report_versions_id_fk" FOREIGN KEY ("report_version_id") REFERENCES "public"."report_versions"("id") ON DELETE restrict ON UPDATE no action;
45
+ --> statement-breakpoint
46
+ CREATE INDEX "idx_report_definitions_updated" ON "report_definitions" USING btree ("updated_at");
47
+ --> statement-breakpoint
48
+ CREATE UNIQUE INDEX "uidx_report_versions_definition_version" ON "report_versions" USING btree ("report_definition_id","version");
49
+ --> statement-breakpoint
50
+ CREATE INDEX "idx_report_versions_definition_created" ON "report_versions" USING btree ("report_definition_id","created_at");
51
+ --> statement-breakpoint
52
+ CREATE INDEX "idx_report_runs_version_created" ON "report_runs" USING btree ("report_version_id","created_at");
53
+ --> statement-breakpoint
54
+ CREATE INDEX "idx_report_runs_status_started" ON "report_runs" USING btree ("status","started_at");
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1784365200000,
9
+ "tag": "20260718090000_reporting_baseline",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }