Reports & Custom Reports API
Eviworx has two separate report systems: (1) predefined standard reports (cost/budget/invest analyses, executed synchronously in the backend) and (2) a custom report builder executed asynchronously via the dedicated report-generator container with READ-ONLY database access (CSV/XLSX/PDF export, scheduling, live preview).
🔐 Auth: All report endpoints are for signed-in users only: they accept a cookie session but no X-API-Key. Permissions are checked against the caller's role matrix. See RBAC →.
Standard Reports
Predefined cost/budget analyses over contracts, licenses and assets. All endpoints are GET and return JSON synchronously. Each cost source is filtered by the permission of its OWN entity (viewAll / viewOwn / reporting on contracts, licenses, assets), following the same rules as the list, the search and custom reports: restricted asset types without a grant stay out, a viewOwn manager sees the contracts of their direct reports, and viewOwn on licenses follows the assignment. A report therefore shows the same costs the caller finds in the corresponding list.
| Method | Endpoint | Description | Permission |
|---|---|---|---|
GET | /api/reports/yearly-overview | Yearly cost overview | reports.viewReports |
GET | /api/reports/category-breakdown | Breakdown by categories | reports.viewReports |
GET | /api/reports/renewal-calendar | Renewal calendar (contracts/licenses) | reports.viewReports |
GET | /api/reports/budget-plan | Budget planning | reports.viewReports + reports.viewBudget |
GET | /api/reports/cost-center-budget | Budget vs. actual per cost center | reports.viewReports + reports.viewBudget |
GET | /api/reports/invest-plan | Multi-year investment plan (assets) | reports.viewReports + reports.viewInvestPlan |
GET | /api/reports/export | Export standard report (XLSX/CSV/PDF) | reports.viewReports + reports.exportReports |
Query Parameters
Every parameter is validated against a schema. An unknown or unsuitable value returns 400 VALIDATION_ERROR with a field path. Parameter sets differ per route: the renewal calendar knows neither year nor assetCostMode, and the invest plan takes years instead of year.
| Parameter | Routes | Description |
|---|---|---|
year | all except renewal-calendar and invest-plan | Reference year, integer within ±20 years of the current year (default: current year) |
months | renewal-calendar, export | Preview window in months, 1–24 (default: 12) |
years | invest-plan, export | Number of years in the multi-year plan, 1–10 (default: 5) |
sources | all | Comma list of cost sources: contracts, licenses, assets. Without the parameter all three apply; an unknown token returns 400. |
assetCostMode | all except renewal-calendar | depreciation (default: the price is spread over the useful life) | capex (full purchase price in the acquisition month) |
type | export | yearly | category | renewal | budget | invest | costcenterbudget |
format | export | xlsx | csv | pdf |
Export language: Exports follow the profile language of the caller, then the system language (general-settings.defaultLanguage), then English — the same chain as license, asset and contract exports. The responses themselves carry no translated text: months are keys (2026-01), and unassigned buckets carry name: null. The file name comes from the Content-Disposition header of the response.
Structure of the cost figures
- Three named sources on every level: month, quarter and year each carry contracts, licenses and assets separately; their sum is total.
- Invest plan: baseline (existing stock, = q1+q2+q3+q4), forecast (of which projected: the projected renewals of that year, 0 in non-forecast years) and total = baseline + forecast. Projections cover contract and license renewals, not new asset purchases.
- Item counter: month and quarter nodes carry itemCount (number of individual items). Detailed rows are only returned by the renewal calendar and the cost-center budget.
# Yearly overview for contracts+licenses only, capex mode
GET /api/reports/yearly-overview?year=2026&sources=contracts,licenses&assetCostMode=capex
# Export budget plan as XLSX
GET /api/reports/export?type=budget&format=xlsx&year=2026
Custom Report Builder
Freely definable reports over one or more data sources with columns, filters, grouping, sorting and visualization. Execute, export and preview are ASYNCHRONOUS: the backend creates an execution with status PENDING and answers 202; the report-generator processes the query with read-only access. The client polls the status endpoint for progress.
ASYNC EXECUTION FLOW: 1. POST /api/custom-reports/:id/execute|export (or /preview) 2. Backend creates ReportExecution (status: PENDING) → 202 3. Backend enqueues BullMQ job → Queue "report-execution" (+ _trace) 4. report-generator picks up job, runs query READ-ONLY5. report-generator reports the status to the backend (RUNNING/COMPLETED/FAILED) 6. On export: file is stored as an attachment (virusScanStatus PENDING) 7. Frontend polls GET .../executions/:execId/status → COMPLETED 8. Frontend GET .../executions/:execId/download
Report Management
| Method | Endpoint | Description | Permission |
|---|---|---|---|
GET | /api/custom-reports | List own + shared reports — response { data, pagination }; per default 50, maximum 100, always sorted by last change | viewOwn ‖ viewShared ‖ viewAll |
GET | /api/custom-reports/schema | Available entities & fields (dynamic by right + visibility) | create ‖ editOwn ‖ editAll |
GET | /api/custom-reports/share-targets | Shareable roles with derived flags (for the share dialog). reportId is required; response { data } | share |
GET | /api/custom-reports/:id/recipient-candidates | Recipient suggestions for scheduling (only report-visible users). Search via q or lookup via ids (comma list, max 50), limit 1–25; response { data } | schedule + edit on the report |
GET | /api/custom-reports/:id | Get single report — without execution history, which comes from /:id/executions | view (see above) |
POST | /api/custom-reports | Create new report (201) | create (+ share) |
PUT | /api/custom-reports/:id | Update report | editOwn ‖ editAll (+ share) |
POST | /api/custom-reports/:id/duplicate | Duplicate report, optional { name } (201) | create (+ view on the source) |
DELETE | /api/custom-reports/:id | Delete report (204, no body) | deleteOwn ‖ deleteAll |
The copy always belongs to the caller and deliberately starts private: createdById = caller, isShared=false, sharedWithRoles=[], isScheduled=false, recipients=[]. Without a name in the body, "(copy)" is appended. A shared report therefore never accidentally becomes a second shared or scheduled report.
Only holders of customReports.share may set or change isShared, sharedWithRoles and sharedWithAllRoles on create or edit — so nobody without that right discloses data. customReports.deleteAll is a critical action: when deleting a FOREIGN report or a foreign generation, the permission is checked directly against the database, so a right just revoked takes effect immediately. PUT is a true partial update: fields not sent stay untouched, and description can be cleared with null.
Sharing with roles
A report is shared with ROLES, not individual users. Three fields control it:
| Field | Effect |
|---|---|
isShared | Turns sharing on at all. |
sharedWithRoles | List of role names that see the report via viewShared. Names are stable (immutable after creation). |
sharedWithAllRoles | Explicit "share with all roles". Only with this flag is the report visible to every viewShared role. |
Empty role list: An empty sharedWithRoles list means NOBODY. "Share with all roles" requires the explicit flag sharedWithAllRoles=true. This way an accidentally empty distribution list cannot grant anyone unintended access.
For the role selection in the share dialog, GET /share-targets returns the active roles with derived hints — canViewShared (does the role hold the viewShared right?) and missingSources (which of the report's data sources may the role not evaluate?). The response contains only these flags, not the roles’ permissions themselves. The endpoint requires customReports.share, so non-admins holding that right can share as well.
What "sharing" means — use it deliberately: Sharing covers not only the definition but also the RESULT view. Whoever may see the report AND holds customReports.export sees the results and downloads of OTHER people's runs — in the data scope of whoever triggered that run. If an administrator runs the report with their full visibility, everyone with export rights sees that result, not their own narrower one. The export right applies uniformly to all three result paths: the inline data (/status), the file download and the generic attachment path. Whoever may only view the report sees status and row count, but no payload data.
This is intentional and consistent with scheduled reports, whose emails likewise deliver the creator's scope to authorized recipients: sharing IS the data disclosure; what counts is who may see the report. DELETING results, by contrast, is reserved for whoever triggered the run, owns the report, or holds deleteAll — viewing and cleaning up are two different things.
Execution & Export (async)
| Method | Endpoint | Description | Permission |
|---|---|---|---|
POST | /api/custom-reports/preview | Live preview without saved report (202) | create ‖ editOwn ‖ editAll |
POST | /api/custom-reports/:id/execute | Execute report, data result (202) | view (read) |
POST | /api/custom-reports/:id/export | Export in one format, body { format } (202) | export |
POST | /api/custom-reports/:id/generate | Generate in ALL configured exportFormats (202) | export |
generate creates one execution per configured format and is capped at 25 stored generations per report → 409 EXECUTION_LIMIT_REACHED (delete old versions first).
Scheduling & Execution History
| Method | Endpoint | Description | Permission |
|---|---|---|---|
POST | /api/custom-reports/:id/schedule | Set scheduling { isScheduled, cronExpression, exportFormats, recipients } | schedule |
GET | /api/custom-reports/:id/executions | Execution history of a report — deliberately metadata ONLY (status, recordCount, timestamps). The result data itself comes from /executions/:execId/status, so a history list does not transfer payload data of every run unasked. Response { data }, at most 25 rows; each row carries id, executedById, status, format, recordCount, errorMessage, createdAt and attachment{originalName} or null. | view |
GET | /api/custom-reports/executions/:execId/status | Poll execution status | view |
GET | /api/custom-reports/executions/:execId/download | Download generated file (files detected as infected are blocked) | export |
DELETE | /api/custom-reports/executions/:execId | Delete execution + attachment (204, no body) | creator of the run ‖ report owner ‖ deleteAll |
Data Sources & Report Definition
GET /schema returns the data sources available to the caller with their permitted fields (permission: customReports.create, editOwn or editAll — editing also needs the schema for field labels and filters). The offering is DYNAMIC and depends on two conditions at once: the caller holds the domain’s reporting right (assets additionally viewAllHandovers ‖ viewOwnHandovers) AND their visibility in that domain is not empty from the outset. Someone with reporting on changes but no right to see changes is therefore not offered changes — such a source could only return 0 rows on execution. Fields and report rows always follow the caller’s permissions; without a matching permission nothing is shown.
| Data source | Required right |
|---|---|
tickets | tickets.reporting |
problems | problems.reporting |
changes | changes.reporting |
incidents | incidents.reporting |
assets | assets.reporting + (viewAllHandovers ‖ viewOwnHandovers) |
contracts | contracts.reporting |
licenses | licenses.reporting |
Sub-entities inherit their domain's reporting permission: changes.reporting unlocks changes AND changeTasks, assets.reporting unlocks assets AND handovers (the latter additionally with the handover view right). There is no separate reporting permission for changeTasks/handovers.
Besides the label, every field also carries a translation key (labelKey); enum fields additionally carry their enum group (enumKey) — the builder shows fields, values and joins in the UI language, while the stored column label remains the text chosen when it was added.
Fields with an additional permission
A few fields require an additional domain permission beyond the reporting right. They do not appear in the schema offering at all; referencing them anyway via the API yields 400 with issue code FIELD_NOT_ALLOWED, and at run time such a definition fails as well (execution FAILED). Affected:
| Field(s) | additionally required | Why |
|---|---|---|
tickets.participantEmails | tickets.viewAll | Concatenates the email addresses of all participants (incl. external CC) — an own-scope role should not export address lists. participantCount (just the number) stays free. |
incidents.isDataBreach, dsbNotifiedAt, dsbAcknowledgedAt, affectedDataSubjects | incidents.viewPIR | The GDPR/data-breach block of an incident. |
Deliberate asymmetry: this makes the report STRICTER here than the incident detail, which shows the GDPR block to anyone who can view the incident. That is intended — a mass-export channel (CSV/XLSX, schedules, mail) justifies a higher bar than the single view. Roles without incidents.viewPIR do not see the GDPR columns in their reports until an admin grants the role that right.
Aggregation & row counters
Aggregates (COUNT/SUM/AVG/MIN/MAX) work with AND without groupBy: without grouping, all rows form ONE total group — the result is a single row with the totals (which is what makes the KPI visualization useful). Aggregation runs over the loaded rows, capped at 10,000.
| Field | Meaning |
|---|---|
totalCount | Real database count of the primary entity for the filters — the total set, not just the delivered rows. |
rowCount | Number of result rows actually delivered. |
countIsExact | false when totalCount is only an estimate due to the row cap or post-query filtering (computed fields) — then rowCount is what counts. |
SLA fields (on the tickets entity)
SLA data is available as additional columns of the tickets entity. As a result, the same visibility rules and joins apply to them as to the ticket itself.
| Field | Type | Meaning |
|---|---|---|
slaStatus | enum | OK · WARNING · BREACH · CRITICAL · CANCELLED |
slaResponseMet / slaResolutionMet | boolean | Response/resolution target met |
slaResponseDeadline / slaResolutionDeadline | date | Deadlines (business-hours calculated) |
slaResponseAt / slaResolvedAt | date | Time of reaction/resolution |
slaBreachAt | date | Time of breach |
slaIsPaused / slaPausedMinutes | boolean / number | Clock currently paused / cumulative paused time |
slaEscalationLevel | number | Escalation level reached |
slaPercentUsed | number | Target time used, in percent |
slaExcludedFromReporting | boolean | Excluded from compliance evaluations |
slaPolicyName | string | Applied SLA policy |
A report over tickets shows ALL tickets — the SLA columns are an extra dimension. For compliance numbers, filter on slaExcludedFromReporting = false and slaStatus ≠ CANCELLED; the shipped template "SLA compliance by priority" does exactly that as a visible filter. Two builder templates are included: "SLA breaches last month" and "SLA compliance by priority". Cross-check for numbers: GET /api/sla/report.
Sub-ticket fields (on the tickets entity)
| Field | Type | Meaning |
|---|---|---|
parentTicket.ticketNumber | relation | Number of the parent ticket — column "Parent ticket". Filterable and sortable. |
childTicketCount | computed | Number of sub-tickets — column "Sub-tickets". Deleted sub-tickets are not counted. Filterable and sortable. |
This makes the parent/child relationship directly reportable: parentTicket.ticketNumber with isNull returns all tickets without a parent ticket, with isNotNull only the sub-tickets; childTicketCount > 0 returns the parent tickets. As a computed field, childTicketCount follows the rule for all computed fields — filterable on the primary entity only, and filtering runs after the query (see countIsExact).
Definition (CustomReport):
dataSources (entity/alias/joinOn/joinType inner|left, max 5),
columns (source/field/label, aggregate COUNT|SUM|AVG|MIN|MAX, max 50),
filters (see below, max 20 conditions),
sorting, groupBy (max 5 each),
visualization (table|bar|line|pie|kpi), chartConfig, pdfOptions,
exportLocale (de|en|fr|es|it),
isShared/sharedWithRoles/sharedWithAllRoles, exportFormats (csv|xlsx|pdf), recipients.
Filters: operators & OR groups
| Operator | Use |
|---|---|
eq · neq | Equal / not equal |
gt · gte · lt · lte · between | Number and date comparisons |
contains · startsWith | Text search |
in · notIn | Value list (array, not a comma string) |
isNull · isNotNull | Field set / unset. On a relation field (e.g. agent, category, parent ticket) both operators ask about the link itself: present / absent. |
relative | DATE fields only: a named period instead of a set date (today, yesterday, this_week, last_week, last_7_days, last_30_days, last_90_days, this_month, last_month, this_quarter, last_quarter, this_year, last_year, next_7_days, next_30_days …). Resolved at EXECUTION TIME — which keeps scheduled reports permanently current instead of freezing them to one date. |
The filters field has EXACTLY ONE shape: the group form with "version": 2, even for a report with a single AND group. Any other shape in the request (such as a flat array) is rejected with 400 VALIDATION_ERROR — so a malformed filter can never quietly become an unrestricted report. The field is required on create; "no filters" is groups: [].
// (status=OPEN UND prio=CRITICAL) ODER (älter als 30 Tage)
"filters": {
"version": 2,
"groupLogic": "OR", // Verknüpfung ZWISCHEN Gruppen — Default OR
"conditionLogic": "AND", // Verknüpfung INNERHALB Gruppen — Default AND
"groups": [
{ "conditions": [
{ "source": "t", "field": "status", "operator": "eq", "value": "OPEN" },
{ "source": "t", "field": "priority", "operator": "eq", "value": "CRITICAL" }
] },
{ "conditions": [
{ "source": "t", "field": "createdAt", "operator": "relative", "value": "last_30_days" }
] }
]
}
// Ein einfacher Report = genau eine UND-Gruppe:
"filters": { "version": 2, "groups": [ { "conditions": [
{ "source": "t", "field": "status", "operator": "in", "value": ["OPEN", "IN_PROGRESS"] }
] } ] }
| Rule | Value |
|---|---|
| Groups | 1–5, each with at least one condition |
| Conditions | max 20 in TOTAL across all groups |
groupLogic / conditionLogic | OR|AND resp. AND|OR — both freely selectable, so that (A or B) AND (C or D) is expressible without duplicating conditions |
Security: permission scoping (deleted records, ownership, visibility rights) is ALWAYS applied additionally with AND, outside the groups — so an OR group cannot widen visibility. Two conditions on the same field are merged in AND mode (e.g. into a range); in OR mode each stays its own branch, so switching conditionLogic yields the expected result.
One quirk of AND mode is worth mentioning: two conditions with the same operator on the same field (status = A AND status = B) overwrite each other — the last one wins instead of producing an empty set. If you mean "A or B", use the in operator or an OR group.
Conditions on joined sources: rows vs. items
A condition on a joined source (e.g. "asset status = IN_USE") normally acts at the level of the PRIMARY entity: it decides whether a ticket enters the report at all ("has at least one matching asset") — which of its assets then appear as rows is a separate question. Once a computed condition joins in (e.g. an SLA field), the engine evaluates the whole predicate per EXPANDED row instead. The set of tickets stays identical, the set of rows can differ:
Filter: (assets.status = IN_USE) OR (priority = CRITICAL) Ticket matches via group 1 only and carries assets [IN_USE, RETIRED] without a computed condition → 2 rows (IN_USE + RETIRED)with a computed condition → 1 row (IN_USE only)
Special case: under item-precise evaluation, isNull on a joined field also catches records with no linked items at all.
Joins are INNER joins: A report "A + linked B" shows only A rows with at least ONE B entry visible to the caller. If all linked entries lie outside their visibility (e.g. exclusively assets of locked types without a grant), the A row drops out of the report entirely. This is intended, but not always obvious. The data source picker points this out in the UI.
API Examples
Create Custom Report
POST /api/custom-reports
{
"name": "Open Tickets by Category",
"description": "Number of open tickets per category and priority",
"dataSources": [{ "entity": "tickets", "alias": "t" }],
"columns": [
{ "source": "t", "field": "category.name", "label": "Category" },
{ "source": "t", "field": "priority", "label": "Priority" },
{ "source": "t", "field": "ticketNumber", "label": "Count", "aggregate": "COUNT" }
],
"filters": {
"version": 2,
"groups": [ { "conditions": [
{ "source": "t", "field": "status", "operator": "in", "value": ["OPEN", "IN_PROGRESS"] }
] } ]
},
"groupBy": [
{ "source": "t", "field": "category.name" },
{ "source": "t", "field": "priority" }
],
"sorting": [{ "source": "t", "field": "priority", "direction": "desc" }],
"visualization": "bar",
"exportFormats": ["csv", "xlsx"],
"exportLocale": "en",
"isShared": true,
"sharedWithRoles": ["AGENT"]
}
Export language (exportLocale)
A report has ONE export language (de, en, fr, es, it — default en, selectable in the builder below the format tiles; a new report adopts the UI language of its creator). It controls everything the worker writes into the file: framing texts (cover page, "generated at", footer, chart legend), yes/no values, and number and date formats (de-DE, en-GB, fr-FR, es-ES, it-IT — English therefore writes dates as day/month/year).
| Area | Behavior |
|---|---|
| Column headers | Standard labels are translated into the export language; a label the user wrote themselves stays untouched — user text wins. |
| Cell values | stay raw in ALL formats (e.g. IN_PROGRESS) — a deliberate decision so exports remain machine-processable. |
| Preview | not affected — the preview renders in the frontend and follows the UI language. |
| File name | stays language-neutral (name + date). |
If a text has no translation in the chosen language, English applies (chain: chosen language → English → key). The preview without a saved report also runs in English. Exports use the same translations as the user interface.
Execute Report (async)
POST /api/custom-reports/:id/execute
// Response 202 Accepted — async!{
"executionId": "clx...",
"status": "PENDING"
}
Check Status (Polling)
GET /api/custom-reports/executions/:execId/status
// execute/preview after COMPLETED — data inline:{
"executionId": "clx...",
"status": "COMPLETED",
"errorMessage": null,
"result": { "...": "resultData" }
}
// export after COMPLETED — fetch the file via /executions/:execId/download:{
"executionId": "clx...",
"status": "COMPLETED",
"errorMessage": null
}
The status endpoint is deliberately lean — it gets polled. Row count, format and timestamps live in the history (/:id/executions); file metadata comes from the download via its headers.
Export & Download
POST /api/custom-reports/:id/export
{ "format": "csv" } // csv | xlsx | pdf
// Response 202{ "executionId": "clx...", "status": "PENDING" }
# After COMPLETED:GET /api/custom-reports/executions/:execId/download
Configure Scheduling
POST /api/custom-reports/:id/schedule
{
"isScheduled": true,
"cronExpression": "0 8 * * 1",
"exportFormats": ["csv", "pdf"],
"recipients": ["userId-1", "userId-2"]
}
// Response: saved report + the next 3 due dates (UTC){
"id": "clr...",
"isScheduled": true,
"cronExpression": "0 8 * * 1",
"nextRuns": ["2026-07-20T08:00:00.000Z", "2026-07-27T08:00:00.000Z", "2026-08-03T08:00:00.000Z"]
}
The job system sets the pace: the action report_schedule_check runs every minute, starts due schedules (one execution per configured export format) and finalises finished runs with the completion mail. ⚠ Without this job, scheduled reports do NOT run; the report-generator only executes. Running or exporting manually does not shift the schedule, and a newly enabled schedule first fires at the next cron time. On completion the recipients receive ONE email with all format attachments plus a note on formats that could not be produced. Recipients are individual users; the suggestion list in the dialog (GET /:id/recipient-candidates) contains ONLY users who may see the report. Dispatch checks this again (RECIPIENTS_NOT_ALLOWED).
| Check | Behavior |
|---|---|
| Cron expression | With isScheduled=true, cronExpression is required and is parsed — missing or unparseable ⇒ 400 VALIDATION_ERROR with the field path cronExpression. A broken expression is not stored (otherwise the report would never run and nobody would notice). |
| Recipients | Every recipient must be allowed to see the report (owner ∨ shared with their role ∨ customReports.viewAll) — otherwise 403 RECIPIENTS_NOT_ALLOWED with names. On dispatch the check runs AGAIN and only authorized recipients are served (the report may have been made private afterwards); filtered-out recipients are recorded in the audit log. |
Execution Status
| Status | Description |
|---|---|
PENDING | In queue, waiting for report-generator |
RUNNING | Currently executing |
COMPLETED | Successful, result/download available |
FAILED | Failed (errorMessage set) |
format = pdf | csv | xlsx | PREVIEW (null for a plain execute); the row count is recordCount. Both live in the history, not in the status response.
Retention
Report retention is part of the bundled retention_purge job (job-worker, nightly 03:00) — it keeps the execution history small, otherwise it would grow without bound:
- Stuck runs first: A run stuck on RUNNING for more than 30 minutes has seen a worker crash; one PENDING for more than 24 hours has lost its queue event. Both are set to FAILED — otherwise they would permanently block the 25-run limit, since nothing else would ever touch them again.
- Preview executions (without a report) older than 24 hours are deleted.
- Stored result data of completed runs older than 30 days is cleared — metadata (status, recordCount, timestamps) is kept.
- FAILED executions without a file older than 30 days are deleted, including the stuck runs set to FAILED in the step above.
- Export FILES (the generated attachments) older than 30 days are soft-deleted — an export is a pick-up artifact, not an archive. The physical deletion is then handled by the attachment_cleanup job.
The periods are parameters of the retention_purge-job; it runs ENABLED by default.
Error Codes
| errorCode | HTTP | Meaning |
|---|---|---|
REPORT_DEFINITION_INVALID |
400 | The definition is checked synchronously — on save, on preview AND on every run (execute/export/generate). details[] names each error with its path and code (see the table below). Checking on every run catches reports that became invalid through later schema changes (e.g. a renamed field) without anyone touching them. |
COMPUTED_FILTER_ON_JOINED_SOURCE |
400 | Issue code in details[] of REPORT_DEFINITION_INVALID: computed fields (e.g. the SLA columns) can only be filtered on the primary entity, not on a joined source. |
RECIPIENTS_NOT_ALLOWED |
403 | At least one recipient is not allowed to see the report. |
EXECUTION_LIMIT_REACHED |
409 | More than 25 stored generations per report — delete old versions first. |
REPORT_ARCHIVED |
409 | An archived report can neither be run nor scheduled. Unarchiving of course remains possible — archiving is not deletion. |
FILTERS_FORMAT_INVALID |
400 | Issue code in details[] of REPORT_DEFINITION_INVALID: a stored definition is not in the group form (version 2). It is rejected so that a malformed filter never becomes an unrestricted report. A request with a flat array already fails the schema check (400 VALIDATION_ERROR). |
REPORT_NOT_FOUND |
404 | The report does not exist — or the caller may not see it. Both cases answer alike so it cannot be inferred whether someone else’s report id exists; this applies to every route that loads the report. Visibility of a foreign EXECUTION (/status, /download) stays 403 — there the execution id is the key, not the report id. |
EXECUTION_NOT_FOUND |
404 | The requested execution does not exist. |
EXPORT_NOT_FOUND |
404 | The execution has no export file (e.g. a plain execute without a format). |
EXPORT_FILE_UNAVAILABLE |
404 | The file record exists but the file itself is no longer in storage (e.g. after retention expiry). |
ATTACHMENT_INFECTED |
403 | The virus scan flagged the export file as infected — the download stays blocked. |
VALIDATION_ERROR |
400 | Schema violation (limits, enums, required fields, query parameters) with a field path in details[]. This includes an unusable between value — the operator requires EXACTLY two filled values — and the schedule: a missing or unparseable cronExpression with isScheduled=true. |
Issue codes of the definition check
Every entry in details[] carries the path of the offending spot (e.g. columns[2].aggregate or filters.groups[1].conditions[0].operator) and one of these codes:
| Code | Meaning |
|---|---|
UNKNOWN_SOURCE · UNKNOWN_FIELD | Data source or field does not exist |
ENTITY_NOT_ALLOWED | The caller lacks the data source's reporting right — or may not see it. |
FIELD_NOT_ALLOWED | A field requires an additional permission the caller lacks (see "Fields with an additional permission" above). |
INVALID_NUMBER_VALUE | A number filter has no usable value (empty or text). Numeric strings are read as numbers — "5" counts as 5. |
DUPLICATE_AGGREGATE_FIELD | The same field aggregated twice (e.g. SUM and AVG on amount): both columns would carry the same key — the second would silently overwrite the first. |
AGGREGATE_ON_GROUPBY_FIELD | Grouping and aggregating the same field — the same key collision, only between grouping and aggregate. |
COMPUTED_FILTER_ON_JOINED_SOURCE | Computed field (e.g. an SLA column) filtered on a joined source — only possible on the primary entity. |
Both aggregate rules are already blocked by the builder (selection disabled with an explanation) — the backend check is the second line of defense for definitions built via the API.
Ineffective filter conditions abort
A filter condition that cannot be resolved (e.g. because its field no longer exists after a schema change) produces a named error — a 400 when run via the API, and a failed execution naming the affected condition for scheduled runs.
Why: a condition that silently drops out makes a report return too many rows (the group becomes true) or too few rows (the empty branch drops out of the OR). Both look plausible — especially for scheduled reports nobody checks any more. A named abort gets noticed.
Permissions (customReports)
| Permission | Description |
|---|---|
customReports.viewOwn | View own reports |
customReports.viewShared | View shared reports (read-only; never grants edit or delete) |
customReports.viewAll | View all reports (admin) |
customReports.create | Create reports, preview, get schema |
customReports.editOwn | Edit own reports |
customReports.editAll | Edit all reports (admin) |
customReports.deleteOwn | Delete own reports |
customReports.deleteAll | Delete foreign reports (critical; checked directly against the database) |
customReports.export | Export/generate and download (including via the generic attachment path). DELETING an execution does NOT depend on this right but on the run: creator ‖ report owner ‖ deleteAll. |
customReports.schedule | Configure scheduling |
customReports.share | Share reports with other roles |
Standard reports use their own feature: reports.viewReports / reports.viewBudget / reports.viewInvestPlan / reports.exportReports. Report ROWS are additionally filtered by the reporting/viewAll/viewOwn rights of the respective data source.
🔔 Notifications: Report runs trigger REPORT_READY, REPORT_FAILED (user-triggered) or REPORT_SCHEDULED_COMPLETE (scheduled, email with attachments). For channels/templates see Notifications →.
Report Generator Container
Custom reports are executed not in the backend but in the dedicated report-generator container. It has READ-ONLY database access and processes jobs via the BullMQ queue report-execution.
| Property | Value |
|---|---|
| Container | report-generator |
| Database access | READ-ONLY |
| Queue | BullMQ: report-execution (attempts 3, exponential backoff) |
| Scheduling | not in this container — the pace comes from the job action report_schedule_check (every minute) |
| CSV Delimiter | CSV_DELIMITER: ; (default), , or tab |
| Scaling | Multi-instance via BullMQ (automatic job distribution) |
| Tracing | traceId/spanId/correlationId are passed from the backend to the report-generator |
📘 Details: See Container Architecture → for report-generator container details and Environment Variables → for configuration (COMPANY_NAME, CSV_DELIMITER, BACKEND_URL).
REPORT_READY / REPORT_FAILED / REPORT_SCHEDULED_COMPLETE
report-generator container in detail
reports.* and customReports.* rights matrix
Cost centers for cost-center-budget
Reopen analytics (/api/analytics/reopen) + lifecycle dashboard
SLA fields on the tickets entity + /sla/report as a cross-check