Reports Module — Admin
Pluggable, registry-driven operational reports (orders, discount redemptions, customers) with date-ranged paginated views and async CSV/XLSX file exports.
A pluggable, registry-driven reporting surface: date-ranged, row-level operational reports (orders, discount redemptions, customers) with an on-screen paginated view and async CSV/XLSX file exports.
Source:
api-modules/reports/src/controllers/admin-report.controller.ts+admin-report-export.controller.ts.Optional plugin. Removing
ReportsModule.forRoot({ reports: [...] })fromapp.module.tsdisables every report + export route, stops the export worker and its retention cron, and leaves thereport_exporttable inert.
How it works
Each report is a ReportDefinition provider (key, title, description, dateField, columns, filters, filtersSchema, run(), and optionally exportColumns + runExport() for a file wider than the screen) registered via ReportsModule.forRoot({ reports: [...] }). The HTTP surface is generic — a report is addressed by its key; columns and filters are discovered from the metadata endpoint. Adding a report = add a definition class to the forRoot array; removing one = delete it (an external plugin exposes its report class for the host app to include).
- View is synchronous + paginated (
buildPaginatedResponse), filtered to a[from, to)window. Reports may also expose summary KPI cards (kpis+summary()) and a trend chart (trendSeries+trend()), served byGET .../summaryandGET .../trend— the metadata advertises which a report supports. - Export is asynchronous:
POST .../exportenqueues a BullMQ job; a worker runs the report in batches, writes a CSV/XLSX file to object storage, and flips thereport_exportrow toready. The admin polls the exports list and downloads the file. Files auto-expire after 7 days (daily retention sweep); the row remains asexpired.
A date range is required for exports and capped at 366 days. Export files are capped at 200,000 rows — beyond that the file is marked truncated (never silently cut).
Built-in reports
| Key | Row granularity | Date field | Filters |
|---|---|---|---|
orders | one order | order placed date | status, paymentStatus, vendorId (filter type vendor — UI renders a vendor-name picker; value is the vendor id) |
discounts | one discount redemption | redemption date | code |
customers | one customer (ordered in range) | order placed date | email |
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching permission.
| Endpoint | Permission |
|---|---|
GET /admin/reports, GET /admin/reports/:key, GET /admin/reports/:key/{meta,summary,trend} | reports: view |
GET /admin/report-exports, GET /admin/report-exports/:id | reports: view |
POST /admin/reports/:key/export | reports: export |
GET /admin/report-exports/:id/download | reports: export |
Response envelope
Successful responses are wrapped by ResponseInterceptor into { data, message, statusCode, metadata? }. List/run endpoints use the canonical metadata: { total, limit, offset, hasMore }. The download endpoint streams the raw file (Content-Disposition: attachment) and bypasses the envelope.
Endpoints
GET /admin/reports
List registered reports with their metadata.
Response data is an array of:
{
"key": "orders",
"title": "Orders",
"description": "Every order placed in the range with totals, status, and customer.",
"dateField": "Order placed date",
"columns": [{ "key": "orderNumber", "label": "Order #", "type": "string" }, /* … */],
"filters": [{ "key": "status", "label": "Order status", "type": "enum", "options": [/* … */] }],
"exportNote": "The file carries every order column … one row per item …" // only when the file differs from the table
}Column type is one of string | number | money | date | datetime | boolean (money is integer subunits).
Column type is one of string | number | money | date | datetime | boolean. When present, kpis ({key,label,type}) and trendSeries ({key,label,type:number|money}) tell the client to render summary cards and a trend chart. exportNote (string?) is present when the exported file differs from the on-screen table — render it beside the export buttons.
GET /admin/reports/:key/meta
Metadata for a single report (same shape as one element above).
GET /admin/reports/:key/summary
Summary KPI values for the range. Same query params as the run endpoint (from, to, filters). Returns:
{ "kpis": [{ "key": "orders", "label": "Orders", "type": "number", "value": 1240 }, /* … */] }value is null when not computable; empty kpis when the report has no summary.
GET /admin/reports/:key/trend
Time-bucketed series for the range (bucketed by day, or by month for ranges over ~62 days). Returns:
{
"granularity": "day",
"series": [{ "key": "orders", "label": "Orders", "type": "number" }, /* … */],
"points": [{ "bucket": "<ISO>", "orders": 42, "revenue": 1839900 }, /* … */]
}GET /admin/reports/:key
Run a report (paginated, on-screen).
Query params:
| Param | Type | Notes |
|---|---|---|
from | ISO datetime | inclusive; omitted → last 30 days |
to | ISO datetime | exclusive; omitted → now |
filters | JSON string | report-specific, validated against filtersSchema |
limit | int (1–500) | default 100 |
offset | int | default 0 |
Response: paginated array of column-keyed row objects (metadata carries totals).
POST /admin/reports/:key/export
Queue an async export. Body:
{ "format": "csv" | "xlsx", "from": "<ISO>", "to": "<ISO>", "filters": { /* optional */ } }from/to are required. Returns the created report_export row (status: "pending").
A report may export more than it shows. A definition can declare exportColumns + runExport() alongside columns + run(); the worker then writes that wider column set to the file and leaves the on-screen table alone. runExport() may also expand one source record into several file rows, reporting the records it consumed as consumed so the worker keeps paging on records rather than on rows.
The orders report does exactly this: its file is the admin orders-table export — every order column plus one row per item — rather than the twelve summary columns the report shows. See Orders → export for the column list and the caveat that order-level money columns repeat per item row and must not be summed. It honours the report's own scope (date range on the order's placed date, order status, payment status, vendor), not the order table's filters.
GET /admin/report-exports
List export history (newest first). Query: reportKey?, status?, limit (default 50), offset. Each row:
{
"id": "…",
"reportKey": "orders",
"format": "csv",
"status": "pending | processing | ready | failed | expired",
"params": { "from": "<ISO>", "to": "<ISO>", "filters": {} },
"rowCount": 1240,
"truncated": false,
"fileName": "orders-20260101-20260201.csv",
"fileSize": 84213,
"error": null,
"createdAt": "<ISO>",
"completedAt": "<ISO>",
"expiresAt": "<ISO>",
"downloadUrl": "/admin/report-exports/…/download" // only when ready
}GET /admin/report-exports/:id
Status of a single export — poll this (or the list) until status: "ready".
GET /admin/report-exports/:id/download
Stream a ready export file as an attachment. 404 if not ready / expired / unknown.
Product Sold Metrics Module — Admin surface
Admin-facing HTTP surface for the per-product "units sold last month" metric: a monthly BullMQ recompute over confirmed orders, a manual re-trigger, and admin-configured display-override rules.
Reviews Module — Admin
HTTP surface for platform-admin moderation of product reviews: cross-vendor list with filters, full-field edit, lifecycle transitions (approve / reject / mark-spam), admin-create…