@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.
@@ -0,0 +1,8 @@
1
+ import type { ReportExport } from "./service.js";
2
+ export type ReportExportFormat = "csv" | "xlsx" | "pdf";
3
+ export declare const REPORT_EXPORT_CONTENT_TYPES: Record<ReportExportFormat, string>;
4
+ /** A filesystem-safe base name for the downloaded file (no extension). */
5
+ export declare function reportExportFileBase(report: ReportExport): string;
6
+ export declare function reportToCsv(report: ReportExport): string;
7
+ export declare function reportToXlsx(report: ReportExport): Promise<Uint8Array>;
8
+ export declare function reportToPdf(report: ReportExport): Promise<Uint8Array>;
package/dist/export.js ADDED
@@ -0,0 +1,242 @@
1
+ import ExcelJS from "exceljs";
2
+ import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
3
+ export const REPORT_EXPORT_CONTENT_TYPES = {
4
+ csv: "text/csv; charset=utf-8",
5
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
6
+ pdf: "application/pdf",
7
+ };
8
+ /** A filesystem-safe base name for the downloaded file (no extension). */
9
+ export function reportExportFileBase(report) {
10
+ const slug = report.name
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, "-")
13
+ .replace(/^-+|-+$/g, "");
14
+ return slug.length > 0 ? slug : "report";
15
+ }
16
+ function cellText(value) {
17
+ if (value === null || value === undefined)
18
+ return "";
19
+ if (typeof value === "object")
20
+ return JSON.stringify(value);
21
+ return String(value);
22
+ }
23
+ function toMajorUnits(value, format) {
24
+ const numeric = typeof value === "number" ? value : Number(value);
25
+ if (!Number.isFinite(numeric))
26
+ return null;
27
+ return format?.minorUnit ? numeric / 100 : numeric;
28
+ }
29
+ /**
30
+ * Spreadsheet cell: currency amounts become major-unit NUMBERS (so Excel can sum
31
+ * them), everything else stays raw. The currency code lives in its own column, so
32
+ * no symbol is embedded here.
33
+ */
34
+ function numericCell(value, column, format) {
35
+ if (column.valueType === "currency") {
36
+ const major = toMajorUnits(value, format);
37
+ return major ?? cellText(value);
38
+ }
39
+ if (typeof value === "number" || typeof value === "boolean")
40
+ return value;
41
+ return cellText(value);
42
+ }
43
+ /** Presentation cell (PDF): currency formatted with the row's own currency symbol. */
44
+ function displayCell(value, column, format, row) {
45
+ if (value === null || value === undefined)
46
+ return "";
47
+ if (column.valueType === "currency") {
48
+ const major = toMajorUnits(value, format);
49
+ if (major === null)
50
+ return cellText(value);
51
+ const perRow = format?.currencyField ? row[format.currencyField] : undefined;
52
+ const currency = (typeof perRow === "string" && perRow) || format?.currency || "USD";
53
+ try {
54
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(major);
55
+ }
56
+ catch {
57
+ return major.toLocaleString("en-US");
58
+ }
59
+ }
60
+ return cellText(value);
61
+ }
62
+ // ── CSV ─────────────────────────────────────────────────────────────────────
63
+ function csvField(value) {
64
+ const text = cellText(value);
65
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
66
+ }
67
+ export function reportToCsv(report) {
68
+ const lines = [];
69
+ report.sections.forEach((section, index) => {
70
+ if (index > 0)
71
+ lines.push("");
72
+ lines.push(csvField(section.title));
73
+ if (section.error) {
74
+ lines.push(csvField(`No data — ${section.error}`));
75
+ return;
76
+ }
77
+ lines.push(section.columns.map((column) => csvField(column.label)).join(","));
78
+ for (const row of section.rows) {
79
+ lines.push(section.columns
80
+ .map((column) => csvField(numericCell(row[column.id], column, section.format)))
81
+ .join(","));
82
+ }
83
+ });
84
+ // Excel opens UTF-8 CSV correctly only with a BOM.
85
+ return `${lines.join("\r\n")}\r\n`;
86
+ }
87
+ // ── XLSX ────────────────────────────────────────────────────────────────────
88
+ function worksheetName(title, index) {
89
+ // Excel sheet names: ≤31 chars, none of : \ / ? * [ ].
90
+ const cleaned = title.replace(/[:\\/?*[\]]/g, " ").trim() || `Sheet ${index + 1}`;
91
+ return cleaned.slice(0, 31);
92
+ }
93
+ export async function reportToXlsx(report) {
94
+ const workbook = new ExcelJS.Workbook();
95
+ workbook.creator = "Voyant Reporting";
96
+ const usedNames = new Set();
97
+ report.sections.forEach((section, index) => {
98
+ let name = worksheetName(section.title, index);
99
+ while (usedNames.has(name))
100
+ name = worksheetName(`${section.title} ${index + 1}`, index);
101
+ usedNames.add(name);
102
+ const sheet = workbook.addWorksheet(name);
103
+ if (section.error) {
104
+ sheet.addRow([`No data — ${section.error}`]);
105
+ return;
106
+ }
107
+ const header = sheet.addRow(section.columns.map((column) => column.label));
108
+ header.font = { bold: true };
109
+ for (const row of section.rows) {
110
+ sheet.addRow(section.columns.map((column) => numericCell(row[column.id], column, section.format)));
111
+ }
112
+ sheet.columns.forEach((column) => {
113
+ column.width = 22;
114
+ });
115
+ });
116
+ if (report.sections.length === 0)
117
+ workbook.addWorksheet("Report");
118
+ const buffer = await workbook.xlsx.writeBuffer();
119
+ return new Uint8Array(buffer);
120
+ }
121
+ // ── PDF ─────────────────────────────────────────────────────────────────────
122
+ const PDF_MARGIN = 48;
123
+ const PDF_ROW_HEIGHT = 18;
124
+ const PDF_FONT_SIZE = 9;
125
+ export async function reportToPdf(report) {
126
+ const doc = await PDFDocument.create();
127
+ const font = await doc.embedFont(StandardFonts.Helvetica);
128
+ const bold = await doc.embedFont(StandardFonts.HelveticaBold);
129
+ const muted = rgb(0.42, 0.42, 0.45);
130
+ const ink = rgb(0.1, 0.1, 0.12);
131
+ let page = doc.addPage();
132
+ let { width, height } = page.getSize();
133
+ let cursorY = height - PDF_MARGIN;
134
+ const ensureSpace = (needed) => {
135
+ if (cursorY - needed < PDF_MARGIN) {
136
+ page = doc.addPage();
137
+ ({ width, height } = page.getSize());
138
+ cursorY = height - PDF_MARGIN;
139
+ }
140
+ };
141
+ const truncate = (text, cellFont, size, maxWidth) => {
142
+ if (cellFont.widthOfTextAtSize(text, size) <= maxWidth)
143
+ return text;
144
+ let out = text;
145
+ while (out.length > 1 && cellFont.widthOfTextAtSize(`${out}…`, size) > maxWidth) {
146
+ out = out.slice(0, -1);
147
+ }
148
+ return `${out}…`;
149
+ };
150
+ page.drawText(truncate(report.name, bold, 18, width - PDF_MARGIN * 2), {
151
+ x: PDF_MARGIN,
152
+ y: cursorY,
153
+ size: 18,
154
+ font: bold,
155
+ color: ink,
156
+ });
157
+ cursorY -= 26;
158
+ if (report.description) {
159
+ page.drawText(truncate(report.description, font, 10, width - PDF_MARGIN * 2), {
160
+ x: PDF_MARGIN,
161
+ y: cursorY,
162
+ size: 10,
163
+ font,
164
+ color: muted,
165
+ });
166
+ cursorY -= 20;
167
+ }
168
+ for (const section of report.sections) {
169
+ ensureSpace(PDF_ROW_HEIGHT * 2);
170
+ cursorY -= 12;
171
+ page.drawText(truncate(section.title, bold, 12, width - PDF_MARGIN * 2), {
172
+ x: PDF_MARGIN,
173
+ y: cursorY,
174
+ size: 12,
175
+ font: bold,
176
+ color: ink,
177
+ });
178
+ cursorY -= PDF_ROW_HEIGHT;
179
+ drawSectionTable(section, {
180
+ page,
181
+ addPage: () => {
182
+ page = doc.addPage();
183
+ ({ width, height } = page.getSize());
184
+ cursorY = height - PDF_MARGIN;
185
+ return page;
186
+ },
187
+ font,
188
+ bold,
189
+ muted,
190
+ ink,
191
+ widthOf: () => width,
192
+ getY: () => cursorY,
193
+ setY: (y) => {
194
+ cursorY = y;
195
+ },
196
+ truncate,
197
+ });
198
+ }
199
+ return doc.save();
200
+ }
201
+ function drawSectionTable(section, ctx) {
202
+ let page = ctx.page;
203
+ const width = ctx.widthOf();
204
+ const usableWidth = width - PDF_MARGIN * 2;
205
+ if (section.error) {
206
+ ctx.page.drawText(ctx.truncate(`No data — ${section.error}`, ctx.font, 9, usableWidth), {
207
+ x: PDF_MARGIN,
208
+ y: ctx.getY(),
209
+ size: 9,
210
+ font: ctx.font,
211
+ color: ctx.muted,
212
+ });
213
+ ctx.setY(ctx.getY() - PDF_ROW_HEIGHT);
214
+ return;
215
+ }
216
+ if (section.columns.length === 0)
217
+ return;
218
+ const colWidth = usableWidth / section.columns.length;
219
+ const drawRow = (values, rowFont, color) => {
220
+ if (ctx.getY() - PDF_ROW_HEIGHT < PDF_MARGIN) {
221
+ page = ctx.addPage();
222
+ }
223
+ const y = ctx.getY();
224
+ values.forEach((value, index) => {
225
+ page.drawText(ctx.truncate(value, rowFont, PDF_FONT_SIZE, colWidth - 6), {
226
+ x: PDF_MARGIN + index * colWidth,
227
+ y,
228
+ size: PDF_FONT_SIZE,
229
+ font: rowFont,
230
+ color,
231
+ });
232
+ });
233
+ ctx.setY(y - PDF_ROW_HEIGHT);
234
+ };
235
+ drawRow(section.columns.map((column) => column.label), ctx.bold, ctx.ink);
236
+ for (const row of section.rows) {
237
+ drawRow(section.columns.map((column) => displayCell(row[column.id], column, section.format, row)), ctx.font, ctx.ink);
238
+ }
239
+ if (section.rows.length === 0) {
240
+ drawRow(["No rows for the selected period."], ctx.font, ctx.muted);
241
+ }
242
+ }
@@ -43,6 +43,7 @@ export declare class ReportingRegistry {
43
43
  defaultLimit: number;
44
44
  maximumLimit: number;
45
45
  description?: string | undefined;
46
+ defaultDateField?: string | undefined;
46
47
  }[];
47
48
  listWidgets(): readonly ReportWidgetDefinition[];
48
49
  listTemplates(): readonly ReportTemplateDefinition[];
package/dist/registry.js CHANGED
@@ -14,6 +14,33 @@ export class ReportingAuthorizationError extends ReportingRegistryError {
14
14
  this.missingScopes = missingScopes;
15
15
  }
16
16
  }
17
+ /** The reserved report parameters a page-level date range writes into. */
18
+ const PAGE_DATE_FROM_PARAM = "dateFrom";
19
+ const PAGE_DATE_TO_PARAM = "dateTo";
20
+ /**
21
+ * Apply a page-level date range to a query. When the dataset declares a
22
+ * {@link ReportDatasetContribution.definition.defaultDateField} and the caller
23
+ * supplies the reserved `dateFrom`/`dateTo` parameters, an inclusive window is
24
+ * appended as extra filters — so a single header control filters every widget
25
+ * (preset and custom) without touching individual queries. Absent parameters
26
+ * mean "all time"; the query is returned unchanged.
27
+ */
28
+ function applyPageDateWindow(query, dataset, parameters) {
29
+ const field = dataset.definition.defaultDateField;
30
+ if (!field)
31
+ return query;
32
+ const params = parameters ?? {};
33
+ const from = params[PAGE_DATE_FROM_PARAM];
34
+ const to = params[PAGE_DATE_TO_PARAM];
35
+ const extra = [];
36
+ if (typeof from === "string" && from.length > 0) {
37
+ extra.push({ field, operator: "greaterThanOrEqual", value: { kind: "literal", value: from } });
38
+ }
39
+ if (typeof to === "string" && to.length > 0) {
40
+ extra.push({ field, operator: "lessThanOrEqual", value: { kind: "literal", value: to } });
41
+ }
42
+ return extra.length === 0 ? query : { ...query, filters: [...query.filters, ...extra] };
43
+ }
17
44
  /** Immutable registry assembled from graph-selected module contributions. */
18
45
  export class ReportingRegistry {
19
46
  #datasets = new Map();
@@ -167,7 +194,8 @@ export class ReportingRegistry {
167
194
  }
168
195
  async executeQuery(input) {
169
196
  const { query, dataset, maximumRows } = this.validateQuery(input.query, input.grantedScopes);
170
- for (const filter of query.filters) {
197
+ const executable = applyPageDateWindow(query, dataset, input.parameters);
198
+ for (const filter of executable.filters) {
171
199
  if (filter.value?.kind === "parameter" && !(filter.value.name in (input.parameters ?? {}))) {
172
200
  throw new ReportingRegistryError(`Missing query parameter ${JSON.stringify(filter.value.name)}.`);
173
201
  }
@@ -177,7 +205,7 @@ export class ReportingRegistry {
177
205
  actorId: input.actorId,
178
206
  grantedScopes: input.grantedScopes,
179
207
  signal: input.signal,
180
- }, { query, parameters: input.parameters ?? {}, maximumRows }));
208
+ }, { query: executable, parameters: input.parameters ?? {}, maximumRows }));
181
209
  if (result.rows.length <= maximumRows)
182
210
  return result;
183
211
  return { ...result, rows: result.rows.slice(0, maximumRows), truncated: true };
package/dist/routes.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
- import { ForbiddenApiError, openApiValidationHook, parseJsonBody, parseQuery, RequestValidationError, } from "@voyant-travel/hono";
3
- import { createReportDefinitionSchema, createReportVersionSchema, executeReportVersionSchema, instantiateReportTemplateSchema, listReportDefinitionsQuerySchema, parseReportQuery, parseReportQuerySourceSchema, previewReportQuerySchema, ReportQuerySyntaxError, updateReportDefinitionSchema, } from "@voyant-travel/reporting-contracts";
2
+ import { ForbiddenApiError, openApiValidationHook, parseJsonBody, parseQuery, } from "@voyant-travel/hono";
3
+ import { createReportDefinitionSchema, instantiateReportTemplateSchema, listReportDefinitionsQuerySchema, parseReportQuery, parseReportQuerySourceSchema, previewReportQuerySchema, ReportDatasetQueryError, ReportQuerySyntaxError, updateReportDefinitionSchema, } from "@voyant-travel/reporting-contracts";
4
4
  import { hasApiKeyPermission, permissionStringsToPermissions } from "@voyant-travel/types/api-keys";
5
+ import { REPORT_EXPORT_CONTENT_TYPES, reportExportFileBase, reportToCsv, reportToPdf, reportToXlsx, } from "./export.js";
5
6
  import { ReportingAuthorizationError, ReportingRegistryError, } from "./registry.js";
6
- import { createReportingService, ReportDefinitionRetentionConflictError, ReportDefinitionRevisionConflictError, ReportingRecordNotFoundError, } from "./service.js";
7
+ import { createReportingService, ReportDefinitionRevisionConflictError, ReportingRecordNotFoundError, } from "./service.js";
7
8
  export function createReportingRoutes(registry) {
8
9
  const routes = new OpenAPIHono({ defaultHook: openApiValidationHook });
9
10
  const service = createReportingService(registry);
@@ -15,12 +16,21 @@ export function createReportingRoutes(registry) {
15
16
  requireReportsPermission(c.get("scopes"), "read");
16
17
  const input = await parseJsonBody(c, parseReportQuerySourceSchema);
17
18
  try {
18
- return c.json({ data: parseReportQuery(input.source) });
19
+ const query = parseReportQuery(input.source);
20
+ // Validate against the live registry so unknown datasets/fields, unsupported
21
+ // aggregations, and grouping mistakes fail here — where the author clicked
22
+ // "Parse" to check their query — instead of only when the preview executes.
23
+ registry.validateQuery(query, c.get("scopes") ?? []);
24
+ return c.json({ data: query });
19
25
  }
20
26
  catch (error) {
21
- if (error instanceof ReportQuerySyntaxError)
22
- throw new RequestValidationError(error.message);
23
- throw error;
27
+ // Both syntax errors and registry validation failures (unknown dataset/field,
28
+ // bad grouping, out-of-range limit) are author mistakes → 400 with a message,
29
+ // never a 500.
30
+ if (error instanceof ReportQuerySyntaxError) {
31
+ return c.json({ error: "invalid_report_query", message: error.message }, 400);
32
+ }
33
+ return reportingErrorResponse(c, error);
24
34
  }
25
35
  });
26
36
  routes.post("/queries/preview", async (c) => {
@@ -89,39 +99,31 @@ export function createReportingRoutes(registry) {
89
99
  return reportingErrorResponse(c, error);
90
100
  }
91
101
  });
92
- routes.post("/reports/:id/versions", async (c) => {
93
- requireReportsPermission(c.get("scopes"), "write");
94
- const input = await parseJsonBody(c, createReportVersionSchema);
95
- try {
96
- const version = await service.createVersion(c.get("db"), c.req.param("id"), input.expectedRevision, c.get("userId"));
97
- return c.json({ data: version }, 201);
98
- }
99
- catch (error) {
100
- return reportingErrorResponse(c, error);
101
- }
102
- });
103
- routes.post("/versions/:id/runs", async (c) => {
102
+ routes.get("/reports/:id/export", async (c) => {
104
103
  requireReportsPermission(c.get("scopes"), "export");
105
- const input = await parseJsonBody(c, executeReportVersionSchema);
104
+ const format = (c.req.query("format") ?? "csv").toLowerCase();
105
+ if (format !== "csv" && format !== "xlsx" && format !== "pdf") {
106
+ return c.json({ error: "invalid_format", message: "format must be csv, xlsx, or pdf." }, 400);
107
+ }
106
108
  try {
107
- const run = await service.runVersion(c.get("db"), c.req.param("id"), input.parameters, {
109
+ const report = await service.exportReport(c.get("db"), c.req.param("id"), {}, {
108
110
  actorId: c.get("userId"),
109
111
  grantedScopes: c.get("scopes") ?? [],
110
112
  signal: c.req.raw.signal,
111
113
  });
112
- return c.json({ data: run }, 201);
113
- }
114
- catch (error) {
115
- return reportingErrorResponse(c, error);
116
- }
117
- });
118
- routes.get("/runs/:id", async (c) => {
119
- requireReportsPermission(c.get("scopes"), "export");
120
- try {
121
- const run = await service.getRun(c.get("db"), c.req.param("id"), c.get("scopes") ?? []);
122
- if (!run)
123
- return c.json({ error: "report_run_not_found" }, 404);
124
- return c.json({ data: run });
114
+ if (!report)
115
+ return c.json({ error: "report_not_found" }, 404);
116
+ const typed = format;
117
+ c.header("Content-Type", REPORT_EXPORT_CONTENT_TYPES[typed]);
118
+ c.header("Content-Disposition", `attachment; filename="${reportExportFileBase(report)}.${typed}"`);
119
+ if (typed === "csv") {
120
+ return c.body(reportToCsv(report));
121
+ }
122
+ // xlsx/pdf return raw bytes; hand Hono a standalone ArrayBuffer (a
123
+ // Uint8Array is not part of its accepted body `Data` union, and the view
124
+ // may be backed by a larger buffer).
125
+ const bytes = typed === "xlsx" ? await reportToXlsx(report) : await reportToPdf(report);
126
+ return c.body(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
125
127
  }
126
128
  catch (error) {
127
129
  return reportingErrorResponse(c, error);
@@ -140,13 +142,10 @@ function reportingErrorResponse(c, error) {
140
142
  if (error instanceof ReportDefinitionRevisionConflictError) {
141
143
  return c.json({ error: "revision_conflict" }, 409);
142
144
  }
143
- if (error instanceof ReportDefinitionRetentionConflictError) {
144
- return c.json({ error: "report_has_retained_runs" }, 409);
145
- }
146
145
  if (error instanceof ReportingAuthorizationError) {
147
146
  return c.json({ error: "forbidden", missingScopes: error.missingScopes }, 403);
148
147
  }
149
- if (error instanceof ReportingRegistryError) {
148
+ if (error instanceof ReportingRegistryError || error instanceof ReportDatasetQueryError) {
150
149
  return c.json({ error: "invalid_report_query", message: error.message }, 400);
151
150
  }
152
151
  throw error;