Eviworx
Docs

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).

📊
Features
✓ Standard reports (cost, budget, invest)
✓ Custom report builder (7 data sources)
✓ SLA columns on tickets (14 fields)
✓ Async report generator (READ-ONLY DB)
✓ Export as CSV, XLSX and PDF
✓ Export language per report (de/en/fr/es/it)
✓ OR filter groups (up to 5)
✓ Relative date filters (last_30_days)
✓ Scheduling via cron expression
✓ Permission-filtered sources, fields and rows

🔐 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-overviewYearly cost overviewreports.viewReports
GET/api/reports/category-breakdownBreakdown by categoriesreports.viewReports
GET/api/reports/renewal-calendarRenewal calendar (contracts/licenses)reports.viewReports
GET/api/reports/budget-planBudget planningreports.viewReports + reports.viewBudget
GET/api/reports/cost-center-budgetBudget vs. actual per cost centerreports.viewReports + reports.viewBudget
GET/api/reports/invest-planMulti-year investment plan (assets)reports.viewReports + reports.viewInvestPlan
GET/api/reports/exportExport 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
yearall except renewal-calendar and invest-planReference year, integer within ±20 years of the current year (default: current year)
monthsrenewal-calendar, exportPreview window in months, 1–24 (default: 12)
yearsinvest-plan, exportNumber of years in the multi-year plan, 1–10 (default: 5)
sourcesallComma list of cost sources: contracts, licenses, assets. Without the parameter all three apply; an unknown token returns 400.
assetCostModeall except renewal-calendardepreciation (default: the price is spread over the useful life) | capex (full purchase price in the acquisition month)
typeexportyearly | category | renewal | budget | invest | costcenterbudget
formatexportxlsx | 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-reportsList own + shared reports — response { data, pagination }; per default 50, maximum 100, always sorted by last changeviewOwnviewSharedviewAll
GET/api/custom-reports/schemaAvailable entities & fields (dynamic by right + visibility)createeditOwneditAll
GET/api/custom-reports/share-targetsShareable roles with derived flags (for the share dialog). reportId is required; response { data }share
GET/api/custom-reports/:id/recipient-candidatesRecipient 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/:idGet single report — without execution history, which comes from /:id/executionsview (see above)
POST/api/custom-reportsCreate new report (201)create (+ share)
PUT/api/custom-reports/:idUpdate reporteditOwneditAll (+ share)
POST/api/custom-reports/:id/duplicateDuplicate report, optional { name } (201)create (+ view on the source)
DELETE/api/custom-reports/:idDelete report (204, no body)deleteOwndeleteAll

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:

FieldEffect
isSharedTurns sharing on at all.
sharedWithRolesList of role names that see the report via viewShared. Names are stable (immutable after creation).
sharedWithAllRolesExplicit "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/previewLive preview without saved report (202)createeditOwneditAll
POST/api/custom-reports/:id/executeExecute report, data result (202)view (read)
POST/api/custom-reports/:id/exportExport in one format, body { format } (202)export
POST/api/custom-reports/:id/generateGenerate 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/scheduleSet scheduling { isScheduled, cronExpression, exportFormats, recipients }schedule
GET/api/custom-reports/:id/executionsExecution 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/statusPoll execution statusview
GET/api/custom-reports/executions/:execId/downloadDownload generated file (files detected as infected are blocked)export
DELETE/api/custom-reports/executions/:execIdDelete 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
ticketstickets.reporting
problemsproblems.reporting
changeschanges.reporting
incidentsincidents.reporting
assetsassets.reporting + (viewAllHandoversviewOwnHandovers)
contractscontracts.reporting
licenseslicenses.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 requiredWhy
tickets.participantEmailstickets.viewAllConcatenates 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, affectedDataSubjectsincidents.viewPIRThe 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.

FieldMeaning
totalCountReal database count of the primary entity for the filters — the total set, not just the delivered rows.
rowCountNumber of result rows actually delivered.
countIsExactfalse 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
slaStatusenumOK · WARNING · BREACH · CRITICAL · CANCELLED
slaResponseMet / slaResolutionMetbooleanResponse/resolution target met
slaResponseDeadline / slaResolutionDeadlinedateDeadlines (business-hours calculated)
slaResponseAt / slaResolvedAtdateTime of reaction/resolution
slaBreachAtdateTime of breach
slaIsPaused / slaPausedMinutesboolean / numberClock currently paused / cumulative paused time
slaEscalationLevelnumberEscalation level reached
slaPercentUsednumberTarget time used, in percent
slaExcludedFromReportingbooleanExcluded from compliance evaluations
slaPolicyNamestringApplied 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.ticketNumberrelationNumber of the parent ticket — column "Parent ticket". Filterable and sortable.
childTicketCountcomputedNumber 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

OperatorUse
eq · neqEqual / not equal
gt · gte · lt · lte · betweenNumber and date comparisons
contains · startsWithText search
in · notInValue list (array, not a comma string)
isNull · isNotNullField set / unset. On a relation field (e.g. agent, category, parent ticket) both operators ask about the link itself: present / absent.
relativeDATE 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"] }
] } ] }
RuleValue
Groups1–5, each with at least one condition
Conditionsmax 20 in TOTAL across all groups
groupLogic / conditionLogicOR|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).

AreaBehavior
Column headersStandard labels are translated into the export language; a label the user wrote themselves stays untouched — user text wins.
Cell valuesstay raw in ALL formats (e.g. IN_PROGRESS) — a deliberate decision so exports remain machine-processable.
Previewnot affected — the preview renders in the frontend and follows the UI language.
File namestays 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).

CheckBehavior
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
PENDINGIn queue, waiting for report-generator
RUNNINGCurrently executing
COMPLETEDSuccessful, result/download available
FAILEDFailed (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

errorCodeHTTPMeaning
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:

CodeMeaning
UNKNOWN_SOURCE · UNKNOWN_FIELDData source or field does not exist
ENTITY_NOT_ALLOWEDThe caller lacks the data source's reporting right — or may not see it.
FIELD_NOT_ALLOWEDA field requires an additional permission the caller lacks (see "Fields with an additional permission" above).
INVALID_NUMBER_VALUEA number filter has no usable value (empty or text). Numeric strings are read as numbers — "5" counts as 5.
DUPLICATE_AGGREGATE_FIELDThe 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_FIELDGrouping and aggregating the same field — the same key collision, only between grouping and aggregate.
COMPUTED_FILTER_ON_JOINED_SOURCEComputed 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.viewOwnView own reports
customReports.viewSharedView shared reports (read-only; never grants edit or delete)
customReports.viewAllView all reports (admin)
customReports.createCreate reports, preview, get schema
customReports.editOwnEdit own reports
customReports.editAllEdit all reports (admin)
customReports.deleteOwnDelete own reports
customReports.deleteAllDelete foreign reports (critical; checked directly against the database)
customReports.exportExport/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.scheduleConfigure scheduling
customReports.shareShare 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
Containerreport-generator
Database accessREAD-ONLY
QueueBullMQ: report-execution (attempts 3, exponential backoff)
Schedulingnot in this container — the pace comes from the job action report_schedule_check (every minute)
CSV DelimiterCSV_DELIMITER: ; (default), , or tab
ScalingMulti-instance via BullMQ (automatic job distribution)
TracingtraceId/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).

Related Pages
Notifications →

REPORT_READY / REPORT_FAILED / REPORT_SCHEDULED_COMPLETE

Container Architecture →

report-generator container in detail

RBAC →

reports.* and customReports.* rights matrix

Cost Centers API →

Cost centers for cost-center-budget

Reopen & Lifecycle →

Reopen analytics (/api/analytics/reopen) + lifecycle dashboard

SLA Management API →

SLA fields on the tickets entity + /sla/report as a cross-check