Reports API Reference
Complete API contracts for Reports & Analytics, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Reports & Analytics - API Reference
Audience: Admin panel engineers, mobile/panel engineers, backend engineers, QA.
Scope: Every admin route under /api/reports/... (catalog, live run, builder, dashboards, saved reports, templates, schedules, settings, overview/year-closing, profiles, export jobs) plus the two pupil/guardian portal routes mounted under /api/mobile.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | All twelve controller files under apps/api/src/modules/reports/ | Every route, guard, decorator and status code, via `grep -nE "@(Get |
| DTOs | shared/dto/report-result.dto.ts, shared/dto/report-config.dto.ts, jobs/dto/report-job.dto.ts, builder/dto/builder-query.dto.ts, dashboards/dto/dashboard.dto.ts, saved/dto/saved-report.dto.ts, schedules/dto/report-schedule.dto.ts, settings/dto/report-settings.dto.ts, profiles/dto/profile-query.dto.ts, profiles/dto/profile-result.dto.ts, overview/overview.dto.ts, portal/dto/portal-reports.dto.ts | Every field, validator, default |
| Services | One service per controller (see the backend doc §6) | Behavior, side effects, response mapping |
| Schema | packages/db/src/schema/school/reports.ts | Enums surfaced on the wire (REPORT_FORMATS, REPORT_JOB_STATUSES, REPORT_SCHEDULE_STATUSES, REPORT_SCHEDULE_FREQUENCIES, REPORT_SCHEDULE_ATTENTION_REASONS) |
| Errors | apps/api/src/common/types/error-codes.ts | Every REPORT_* code this surface can throw — all are live |
| Permissions | packages/db/src/authorization/permission-catalog.ts | Every Reports* / Report* code |
| Rate limits | apps/api/src/common/guards/ip-throttler.config.ts | ADMIN_REPORT_RUN, ADMIN_REPORT_EXPORT |
2. Module Summary
| Field | Value |
|---|---|
| Module name | Reports & Analytics |
| Module slug | reports |
| Primary actors | Any authenticated admin-panel user holding Reports_READ plus the relevant domain/CRUD code(s); superadmin bypasses every permission check; pupils and guardians via the portal routes (identity only, no permission code) |
| API surfaces | Admin (/api/reports/...) and mobile/portal (/api/mobile/student/reports, /api/mobile/guardian/children/:studentId/reports) |
| Base route prefixes | /api/reports, /api/reports/run, /api/reports/builder, /api/reports/dashboards, /api/reports/saved, /api/reports/templates, /api/reports/schedules, /api/reports/settings, /api/reports/jobs, /api/mobile/student/reports, /api/mobile/guardian/children |
| Auth model | JwtAuthGuard + RoleGuard (global + controller-level @UseGuards), permission codes via @Permissions() on every admin route; the two portal routes carry no @Permissions() and rely on assertPortalAudience + an identity-layer row lookup |
| Persistence | PostgreSQL (own tables + read access to every domain's tables), Redis (cache + rate limiting), BullMQ (reports, reports-sweep queues), file storage (report-artifact upload type) |
| Runtime source of truth | report_jobs.status for export state; report_definitions.version/report_dashboards.version for optimistic concurrency; report_schedules.status for schedule state; report_settings (one row) for thresholds; ReportRegistryService (in-memory, built at boot) for what reports/datasets exist |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
| Report key | A dotted lowercase identifier like attendance.class_summary naming one runnable report | shared/dto/report-result.dto.ts (REPORT_KEY_PATTERN) | Every route that names a report |
| Analytic report | A kind: "analytic" report — KPIs + charts + a (usually aggregated) table, no pagination beyond the table's own rows | shared/report-types.ts | Catalog, run, dashboards, profiles |
| Dataset report | A kind: "dataset" report — a page of raw rows, paginated, sortable, streamable for export | shared/report-types.ts | Catalog, run, export |
| Scope mode | How a report's or builder dataset's rows relate to the caller's teaching assignment: school_only, teaching, self_staff, teaching_and_self_staff, unscoped | shared/report-types.ts | Authorisation, catalog, builder datasets |
ReportsSchoolWide_READ | The permission that turns "my classes only" into "the whole school" | packages/db/src/authorization/permission-catalog.ts | Every teaching-scoped report/dataset, year closing |
ReportsContacts_READ | The permission that unlocks phone/email columns/fields on directory-style reports and the builder | same | students.directory, staff.directory, contact-flagged builder fields |
| Export job | A background-generated CSV/XLSX/PDF file, tracked in report_jobs | packages/db/src/schema/school/reports.ts | POST /reports/jobs and its lifecycle routes |
| Package | A multi-report export bundled into one XLSX or PDF file (package.annual_academic, package.custom) | shared/report-package-authorizer.service.ts | Job creation only — no live "run" of a package exists |
| Filter summary | A human-readable, name-free description of a job's filters, persisted on the job row | jobs/report-job-planner.service.ts (summarise) | Job list/detail |
| Custom builder | A user-composed query over one frozen dataset (builder.custom), with its own filters/columns/grouping/aggregates | builder/dataset-registry.ts, builder/builder-compiler.ts | GET /reports/builder/datasets, POST /reports/builder/preview, saved/scheduled builder.custom configs |
| Dashboard widget | One tile on a dashboard — a report, an alerts feed, or a shortcut — with a grid position | dashboards/dto/dashboard.dto.ts | Dashboard CRUD and render |
Optimistic concurrency (expectedVersion) | A caller must supply the row's current version on every mutating call; a mismatch is 409 REPORT_VERSION_CONFLICT | report_definitions.version, report_dashboards.version, report_schedules.version | Saved reports, templates, dashboards, schedules |
| Profile | A composite, multi-section read-only screen for one student, class or teacher | profiles/dto/profile-result.dto.ts | GET /reports/profiles/... |
| Portal reports | The pupil's/guardian's own read-only reports screen (placements, attendance, results, assignments, promotions) | portal/dto/portal-reports.dto.ts | /api/mobile/student/reports, /api/mobile/guardian/children/:studentId/reports |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/reports/catalog | Admin | JwtAuthGuard, RoleGuard | Reports_READ | ReportCatalogController | Reports and analytics the caller may run |
| Admin | GET | /api/reports/metrics | Admin | JwtAuthGuard, RoleGuard | Reports_READ | ReportCatalogController | The central metric-definition catalogue |
| Admin | GET | /api/reports/filter-options | Admin | JwtAuthGuard, RoleGuard | Reports_READ | ReportCatalogController | Options for the filter bar, scoped to the caller |
| Admin | POST | /api/reports/run | Admin | JwtAuthGuard, RoleGuard, IpThrottlerGuard | Reports_READ + the report's own requires | ReportRunController | Run one report live |
| Admin | GET | /api/reports/overview | Admin | JwtAuthGuard, RoleGuard | Reports_READ | ReportOverviewController | Cross-domain KPIs and alerts for the school's dashboard |
| Admin | GET | /api/reports/year-closing | Admin | JwtAuthGuard, RoleGuard | Reports_READ (+ ReportsSchoolWide_READ enforced in the service) | ReportOverviewController | Checks and summary before closing an academic year |
| Admin | GET | /api/reports/profiles/students/:studentId | Admin | JwtAuthGuard, RoleGuard | Reports_READ | ReportProfilesController | One pupil's analytic profile |
| Admin | GET | /api/reports/profiles/classes/:classId | Admin | JwtAuthGuard, RoleGuard | Reports_READ | ReportProfilesController | One class's analytic profile |
| Admin | GET | /api/reports/profiles/teachers/:staffId | Admin | JwtAuthGuard, RoleGuard | Reports_READ | ReportProfilesController | One teacher's analytic profile |
| Admin | GET | /api/reports/builder/datasets | Admin | JwtAuthGuard, RoleGuard | ReportBuilder_READ | ReportBuilderController | Datasets and fields the caller may build a report from |
| Admin | POST | /api/reports/builder/preview | Admin | JwtAuthGuard, RoleGuard, IpThrottlerGuard | ReportBuilder_READ | ReportBuilderController | Run a custom query live |
| Admin | GET | /api/reports/dashboards | Admin | JwtAuthGuard, RoleGuard | ReportDashboards_READ | ReportDashboardsController | My personal dashboards plus every school dashboard |
| Admin | POST | /api/reports/dashboards | Admin | JwtAuthGuard, RoleGuard | ReportDashboards_CREATE | ReportDashboardsController | Create a dashboard |
| Admin | GET | /api/reports/dashboards/:id | Admin | JwtAuthGuard, RoleGuard | ReportDashboards_READ | ReportDashboardsController | Read one dashboard |
| Admin | PATCH | /api/reports/dashboards/:id | Admin | JwtAuthGuard, RoleGuard | ReportDashboards_UPDATE | ReportDashboardsController | Update a dashboard |
| Admin | DELETE | /api/reports/dashboards/:id | Admin | JwtAuthGuard, RoleGuard | ReportDashboards_DELETE | ReportDashboardsController | Delete a dashboard |
| Admin | POST | /api/reports/dashboards/:id/render | Admin | JwtAuthGuard, RoleGuard, IpThrottlerGuard | ReportDashboards_READ | ReportDashboardsController | Run every widget on a dashboard |
| Admin | GET | /api/reports/saved | Admin | JwtAuthGuard, RoleGuard | SavedReports_READ | SavedReportsController | List my saved reports |
| Admin | POST | /api/reports/saved | Admin | JwtAuthGuard, RoleGuard | SavedReports_CREATE | SavedReportsController | Save a report configuration |
| Admin | GET | /api/reports/saved/:id | Admin | JwtAuthGuard, RoleGuard | SavedReports_READ | SavedReportsController | Read one of my saved reports |
| Admin | PATCH | /api/reports/saved/:id | Admin | JwtAuthGuard, RoleGuard | SavedReports_UPDATE | SavedReportsController | Update a saved report |
| Admin | POST | /api/reports/saved/:id/archive | Admin | JwtAuthGuard, RoleGuard | SavedReports_UPDATE | SavedReportsController | Archive a saved report |
| Admin | POST | /api/reports/saved/:id/restore | Admin | JwtAuthGuard, RoleGuard | SavedReports_UPDATE | SavedReportsController | Restore an archived saved report |
| Admin | DELETE | /api/reports/saved/:id | Admin | JwtAuthGuard, RoleGuard | SavedReports_DELETE | SavedReportsController | Delete a saved report |
| Admin | GET | /api/reports/templates | Admin | JwtAuthGuard, RoleGuard | Reports_READ | ReportTemplatesController | Templates I can run, plus system presets |
| Admin | POST | /api/reports/templates | Admin | JwtAuthGuard, RoleGuard | ReportTemplates_CREATE | ReportTemplatesController | Create a school-wide template |
| Admin | GET | /api/reports/templates/:id | Admin | JwtAuthGuard, RoleGuard | Reports_READ | ReportTemplatesController | Read one template |
| Admin | PATCH | /api/reports/templates/:id | Admin | JwtAuthGuard, RoleGuard | ReportTemplates_UPDATE | ReportTemplatesController | Update a template |
| Admin | DELETE | /api/reports/templates/:id | Admin | JwtAuthGuard, RoleGuard | ReportTemplates_DELETE | ReportTemplatesController | Delete a template |
| Admin | GET | /api/reports/schedules | Admin | JwtAuthGuard, RoleGuard | ReportSchedules_READ | ReportSchedulesController | Your own scheduled reports |
| Admin | GET | /api/reports/schedules/recipient-options | Admin | JwtAuthGuard, RoleGuard | ReportSchedules_CREATE | ReportSchedulesController | Users/roles eligible to receive a given report — declared before :id |
| Admin | POST | /api/reports/schedules | Admin | JwtAuthGuard, RoleGuard | ReportSchedules_CREATE | ReportSchedulesController | Create a scheduled report |
| Admin | GET | /api/reports/schedules/:id | Admin | JwtAuthGuard, RoleGuard | ReportSchedules_READ | ReportSchedulesController | One scheduled report |
| Admin | PATCH | /api/reports/schedules/:id | Admin | JwtAuthGuard, RoleGuard | ReportSchedules_UPDATE | ReportSchedulesController | Edit a scheduled report |
| Admin | POST | /api/reports/schedules/:id/activate | Admin | JwtAuthGuard, RoleGuard | ReportSchedules_UPDATE | ReportSchedulesController | Draft/paused → active |
| Admin | POST | /api/reports/schedules/:id/pause | Admin | JwtAuthGuard, RoleGuard | ReportSchedules_UPDATE | ReportSchedulesController | Active → paused |
| Admin | POST | /api/reports/schedules/:id/archive | Admin | JwtAuthGuard, RoleGuard | ReportSchedules_UPDATE | ReportSchedulesController | Any non-archived state → archived |
| Admin | POST | /api/reports/schedules/:id/run-now | Admin | JwtAuthGuard, RoleGuard | ReportSchedules_UPDATE | ReportSchedulesController | Run this schedule's report once, right now |
| Admin | DELETE | /api/reports/schedules/:id | Admin | JwtAuthGuard, RoleGuard | ReportSchedules_DELETE | ReportSchedulesController | Soft-delete a schedule (always archived) |
| Admin | GET | /api/reports/settings | Admin | JwtAuthGuard, RoleGuard | ReportSettings_READ | ReportSettingsController | Read report settings |
| Admin | PATCH | /api/reports/settings | Admin | JwtAuthGuard, RoleGuard | ReportSettings_UPDATE | ReportSettingsController | Update report settings |
| Admin | GET | /api/reports/jobs | Admin | JwtAuthGuard, RoleGuard | ReportExports_READ (own) / ReportJobs_READ (scope=all) | ReportJobsController | List export jobs |
| Admin | POST | /api/reports/jobs | Admin | JwtAuthGuard, RoleGuard | ReportExports_CREATE | ReportJobsController | Request a background export |
| Admin | GET | /api/reports/jobs/:id | Admin | JwtAuthGuard, RoleGuard | ReportExports_READ | ReportJobsController | One export job |
| Admin | GET | /api/reports/jobs/:id/events | Admin | JwtAuthGuard, RoleGuard | ReportExports_READ | ReportJobsController | Job lifecycle history |
| Admin | POST | /api/reports/jobs/:id/cancel | Admin | JwtAuthGuard, RoleGuard | ReportExports_UPDATE | ReportJobsController | Cancel a queued/processing export |
| Admin | POST | /api/reports/jobs/:id/retry | Admin | JwtAuthGuard, RoleGuard | ReportExports_UPDATE | ReportJobsController | Re-run a failed export |
| Admin | DELETE | /api/reports/jobs/:id/artifact | Admin | JwtAuthGuard, RoleGuard | ReportExports_UPDATE | ReportJobsController | Delete the generated file now |
| Admin | GET | /api/reports/jobs/:id/download | Admin | JwtAuthGuard, RoleGuard | ReportExports_READ | ReportJobsController | Download the file |
| Mobile/portal | GET | /api/mobile/student/reports | Pupil | JwtAuthGuard, RoleGuard | none (assertPortalAudience("student")) | ReportsStudentPortalController | The pupil's own reports |
| Mobile/portal | GET | /api/mobile/guardian/children/:studentId/reports | Guardian | JwtAuthGuard, RoleGuard | none (assertPortalAudience("guardian")) | ReportsGuardianPortalController | A guardian's own child's reports |
Every leaf module under ReportsModule declares its own controllers. GET /reports/schedules/recipient-options is declared before GET /reports/schedules/:id in the controller so Nest's route matching never treats the literal "recipient-options" segment as an :id.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Notes |
|---|---|---|---|---|
| Every admin route in this module | @UseGuards(JwtAuthGuard, RoleGuard) (+ IpThrottlerGuard on run/preview/render) | req.user: AuthUser, resolved to a ReportPrincipal via ReportPrincipalService.fromRequest | Route-specific (see table above) | A caller with no active role gets 403 AUTH_ACTIVE_ROLE_REQUIRED before any report-specific check runs |
| The two portal routes | @UseGuards(JwtAuthGuard, RoleGuard), no @Permissions() | req.user: AuthUser, checked by assertPortalAudience against activeRole.scopeKind | None | The identity-layer row lookup (resolveOwnChildId / the student's own users.id match) is the actual authorization decision; a mismatch is 404, never 403 |
Every permission module this surface enforces, from packages/db/src/authorization/permission-catalog.ts:
| Module | Grants | Notes |
|---|---|---|
Reports | Reports_READ | The gate to the whole area; and-ed with every domain code below on every single report |
ReportsSchoolWide | ReportsSchoolWide_READ | Rows are school-wide rather than limited to the caller's own classes/staff row; also required for the year-closing screen |
ReportsContacts | ReportsContacts_READ | Phone/email columns/fields in directory reports, exports and the builder |
ReportsStudents | ReportsStudents_READ | Gates students.* and demographics.* |
ReportsAttendance | ReportsAttendance_READ | Gates attendance.* |
ReportsLeave | ReportsLeave_READ | Gates leave.* |
ReportsAcademics | ReportsAcademics_READ | Gates exams.* |
ReportsAssignments | ReportsAssignments_READ | Gates assignments.* |
ReportsPromotions | ReportsPromotions_READ | Gates promotions.* |
ReportsStaff | ReportsStaff_READ | Gates staff.*, timetable.* |
ReportsCommunication | ReportsCommunication_READ | Gates notices.*, notifications.delivery |
ReportsComplaints | ReportsComplaints_READ | Gates complaints.* (the dataset report additionally needs Complaints_READ, the complaints module's own code) |
ReportsIdCards | ReportsIdCards_READ | Gates id_cards.* |
ReportsFinance | ReportsFinance_READ | Gates finance.*; individual finance reports additionally require the owning module's own read code (FeeExports_READ, SalaryDashboard_READ, Assets_READ) |
SavedReports | SavedReports_READ, SavedReports_CREATE, SavedReports_UPDATE, SavedReports_DELETE | A caller's own saved configurations |
ReportTemplates | ReportTemplates_CREATE, ReportTemplates_UPDATE, ReportTemplates_DELETE | School-wide templates; reading one needs only Reports_READ |
ReportBuilder | ReportBuilder_READ | Gates both builder routes; the dataset chosen inside a builder body decides which domain codes the run additionally needs |
ReportExports | ReportExports_READ, ReportExports_CREATE, ReportExports_UPDATE | Request, list and download your own generated reports |
ReportJobs | ReportJobs_READ, ReportJobs_UPDATE | See and retry/cancel every user's generated reports — never download them (see §11 of the backend doc: canDownload still requires being the requester or a recipient) |
ReportSchedules | ReportSchedules_READ, ReportSchedules_CREATE, ReportSchedules_UPDATE, ReportSchedules_DELETE | Scheduled-report CRUD and lifecycle, all owner-scoped |
ReportDashboards | ReportDashboards_READ, ReportDashboards_CREATE, ReportDashboards_UPDATE, ReportDashboards_DELETE | Personal dashboards; _READ also shows school dashboards |
ReportDashboardsShared | (create/edit school-scope dashboards) | Required in addition to ReportDashboards_CREATE/_UPDATE when scope: "school" |
ReportSettings | ReportSettings_READ, ReportSettings_UPDATE | The settings admin screen |
MyReports | (nav gate only) | The pupil/guardian reports screen's nav gate; the portal routes themselves assert audience rather than a permission |
6. DTO and Model Reference
6.1 RunReportDto (body of POST /reports/run)
| Field | Type | Required | Default | Validation | Source |
|---|---|---|---|---|---|
reportKey | string | Yes | — | @MaxLength(80), REPORT_KEY_PATTERN (dotted lowercase, never ending .json/.xml/.txt/.svg/.csv/.pdf/.xlsx/.png/.jpg/.jpeg/.gif/.webp/.ico — a mobile-proxy path-extension guard) | report-result.dto.ts |
filters | object (Record<string, unknown>) | No | {} | @IsObject(); keys validated against the report's own filters allow-list at runtime | report-result.dto.ts |
columns | string[] | No | the report's defaultColumns | @ArrayMaxSize(60), each @MaxLength(60) | report-result.dto.ts |
sort | { key: string; dir: "asc"|"desc" } | No | null | key ≤60 chars; key must be in the report's sortable list | report-result.dto.ts |
page | number | No | 1 | @Min(1) @Max(10000) | report-result.dto.ts |
size | number | No | 25 | @Min(1) @Max(100) | report-result.dto.ts |
refresh | boolean | No | false | Bypasses the cache; rate-limited to once per 10s per report per user | report-result.dto.ts |
builder | object | No | — | Required when reportKey is builder.custom; validated against resolveBuilderQuery | report-result.dto.ts |
6.2 ReportResultDto (response of POST /reports/run, POST /reports/builder/preview, and the value inside every dashboard/profile/portal section)
| Field | Type | Notes |
|---|---|---|
reportKey | string | |
title, category, kind | string | kind is "analytic" or "dataset" |
generatedAt | ISO string | |
cached | boolean | Whether this response came from the Redis cache-aside layer |
context | object | ReportResultContext: sessions, dateFrom/To, dateFrom/ToBs, compareFrom/To, period, anchorDate, population, filters (label/value pairs), scopeLimited, minGroupSize |
summary | ReportKpiDto[] | KPI cards |
charts | ReportChartDto[] | Zero or more charts |
table | ReportTableDto | Exactly one table |
warnings | ReportWarningDto[] | See the fixed warning-code catalogue in the backend doc |
metrics | string[] | Keys into GET /reports/metrics used by this result |
ReportKpiDto
| Field | Type | Notes |
|---|---|---|
key, label | string | |
value | number | null | null when there is nothing to compute (e.g. zero denominator) |
unit | enum | count, percent, paisa, days, hours, marks, gpa, ratio |
metricKey | string | null | Links to METRIC_DEFINITIONS when this KPI is a defined metric |
suppressed | boolean | |
comparison | { value, label } | null | Optional secondary value (e.g. previous period) |
ReportChartDto
| Field | Type | Notes |
|---|---|---|
key, title | string | |
type | enum | bar, stacked_bar, line, scatter, donut |
xKey, xLabel | string | |
series | {key,label,unit}[] | One or more series |
data | Record<string, string|number|null>[] | Row-shaped chart data (the same rows a table would carry) |
note | string | null | Free-text caveat shown under the chart |
ReportTableDto
| Field | Type | Notes |
|---|---|---|
columns | ReportTableColumn[] | {key,label,type,enumKey,sortable,drill} |
rows | Record<string, unknown>[] | |
totals | object or null | A synthetic totals row, when the report computes one |
page | {page,size,count} | null | Present only on paginated (dataset) reports |
suppressedCells | {row,key}[] | Cells blanked by small-group suppression |
6.3 ReportCatalogItemDto (one entry of GET /reports/catalog)
| Field | Type | Notes |
|---|---|---|
key, title, description, category | string | category is one of the 19 ReportCategory values (§7.1) |
kind | enum | analytic | dataset |
filters | string[] | Every filter key this report accepts |
requiredFilters | string[] | Filters that must be present before it can run |
columns | array | {key,label,type,enumKey,contact}[] — a contact column is omitted from defaultColumns and only listed here if the caller holds ReportsContacts_READ |
defaultColumns | string[] | |
sortable, groupable | string[] | |
metrics | string[] | Keys into the metric catalogue |
exportFormats | string[] | Subset of csv/xlsx/pdf |
heavy | boolean | A heavy report over more than one academic session is refused live (409 REPORT_HEAVY_USE_EXPORT) — export it instead |
cacheScope | enum | shared | principal |
scopeMode | enum | school_only, teaching, self_staff, teaching_and_self_staff, unscoped |
presets | array | System-template presets shown in the catalog |
The catalog lists only reports the caller may run — a report whose domain code or scope the caller lacks is absent from the response entirely, never shown as a locked/greyed entry.
6.4 MetricDefinitionDto (one entry of GET /reports/metrics)
key, name, description, formula, numerator, denominator (nullable), sourceModule, scope, unit, yearBehaviour, excludes — all string. Every one of the finance/complaints/communication metric keys is now referenced by at least one live report (see the feature doc's inventory), unlike the earlier state of this module.
6.5 FilterOptionsDto (response of GET /reports/filter-options)
| Field | Type | Notes |
|---|---|---|
sessions | {id,name,isCurrent,startDate,endDate}[] | Every academic session, newest first — not scoped, since sessions themselves are not sensitive |
grades, sections, subjects, departments, designations, leaveTypes, complaintCategories, feeCategories, rooms | {id,name}[] | Reference lists, listed in full for every caller |
classes | {id,name,gradeId,sectionId,sessionId}[] | Scoped: for a non-school-wide caller, only classes in their teaching scope for the selected (or current) session; a caller with no teaching assignment gets an empty list (sql\false`` predicate) |
exams | {id,name,sessionId,resultsPublished}[] | Non-cancelled exams of the selected/current session |
6.6 CreateReportJobDto (body of POST /reports/jobs)
| Field | Type | Required | Validation |
|---|---|---|---|
reportKey | string | Yes | REPORT_KEY_PATTERN, ≤80 chars — accepts an ordinary report key, builder.custom, or package.annual_academic / package.custom |
config | ReportConfigDto | Yes | Nested-validated; carries filters/columns/sort/groupBy/chart/builder/reports (the last only for a custom package) |
format | enum | Yes | csv | xlsx | pdf — packages only accept xlsx/pdf |
title | string | No | ≤200 chars; sanitised (control chars and bidi-override characters stripped) before use as a file name component |
Header: Idempotency-Key (optional) — 8–128 chars of [A-Za-z0-9_-]; a repeat with the same body within its lifetime returns the same job (created: false); a repeat with a different body 409s.
6.7 ListReportJobsQueryDto (query of GET /reports/jobs)
| Field | Type | Default | Notes |
|---|---|---|---|
scope | "mine" | "all" | "mine" | "all" needs ReportJobs_READ (or superadmin) |
status | comma-separated ReportJobStatus[] | — | Any of queued, processing, completed, completed_with_warnings, failed, cancelled, expired |
origin | "manual" | "schedule" | — | |
scheduleId | uuid | — | Filters to one schedule's jobs |
page | number | 1 | 1–10000 |
size | number | 20 | 1–100 |
6.8 ReportJobDto (one job, returned by list/get/create/cancel/retry/delete/run-now)
| Field | Type | Notes |
|---|---|---|
id | uuid | The job's public_id — the only id ever exposed |
reportKey, title | string | |
format | enum | csv|xlsx|pdf |
origin | enum | manual|schedule |
scheduleId | uuid | null | The owning schedule's public id |
status | enum | The seven REPORT_JOB_STATUSES |
progressPercent | number | 0–100, throttled server-side to one write per 5s during generation |
rowCount | number | null | |
warnings | {code,message,count}[] | |
filterSummary | string | Never contains a person's name — studentId/staffId/q labels are stripped |
requestedBy | {id,name} | null | |
createdAt, startedAt, finishedAt, expiresAt | ISO string or null | |
errorCategory, errorMessage | string | null | errorMessage is always one of six fixed sanitised sentences, never a raw exception |
retryable | boolean | true only if the caller can manage this job, it is failed, and retryCount < 10 |
cancellable | boolean | true only if manageable and queued/processing |
downloadable | boolean | true only if completed, not expired, an artifact exists, and canDownload() passes for the current caller |
downloadCount, attemptCount, retryCount | number |
6.9 ReportJobEventDto (one entry of GET /reports/jobs/:id/events)
| Field | Type | Notes |
|---|---|---|
event | string | One of the 13 REPORT_JOB_EVENTS enum values |
actorName | string | null | null for system-triggered events (worker/sweep), a name for user actions |
detail | object | Free-form, ≤4 KiB, e.g. {category, attempt} for a failure |
createdAt | ISO string |
6.10 BuilderDatasetDto (one entry of GET /reports/builder/datasets)
| Field | Type | Notes |
|---|---|---|
key, label | string | |
scopeMode | enum | school_only, teaching, self_staff, teaching_and_self_staff, unscoped |
fields | BuilderFieldDto[] | {key,label,type,contact,aggOnly,demographic,enumValues?}; a contact field is omitted unless the caller holds ReportsContacts_READ |
6.11 BuilderQueryDto (body of POST /reports/builder/preview)
| Field | Type | Required | Validation |
|---|---|---|---|
dataset | string | Yes | ≤60 chars — must name a dataset the caller may query |
columns | string[] | Yes | 1–BUILDER_MAX_COLUMNS entries, each ≤60 chars |
filters | BuilderFilterDto[] | No | ≤BUILDER_MAX_FILTERS; {field, op, value?} — value required for every op except is_null/not_null |
groupBy | string[] | No | ≤BUILDER_MAX_GROUP_BY |
aggregates | BuilderAggregateDto[] | No | ≤BUILDER_MAX_AGGREGATES; {fn, field?} — field required unless fn is count |
sort | {key, dir} | No | |
page | number | No | default 1 |
size | number | No | default 25, max BUILDER_MAX_PAGE_SIZE |
This DTO enforces shape only; every field/op/aggregate is re-validated against the chosen dataset's frozen field list by resolveBuilderQuery (§6.9 of the backend doc), because which fields exist depends on which dataset was chosen.
6.12 Dashboard DTOs
DashboardWidgetPositionDto: {x: 0-11, y: 0-200, w: 1-12, h: 1-12} (a 12-column grid). DashboardWidgetDto: {id (uuid), title (1-80 chars), reportKey, config: ReportConfigDto, view (one of 9 DASHBOARD_WIDGET_VIEWS), position}. DashboardDto (create body): {name (1-120), description? (≤500), scope ("personal"|"school"), defaultFilters?, widgets (≤24)}. UpdateDashboardDto adds a required expectedVersion and makes every other field optional. DashboardResponseDto adds id, version, editable (false when the caller cannot edit a school-scope dashboard), createdAt, updatedAt. DeleteDashboardQueryDto.expectedVersion is the one optional optimistic-lock field in the whole module. RenderDashboardDto: {filters?} merged over the dashboard's own default_filters. DashboardRenderResultDto: {dashboardId, generatedAt, widgets: DashboardWidgetResultDto[]}, each {widgetId, result, error, alerts?} — error is {errorCode, message} when that one widget failed; the rest of the array still contains results.
6.13 Saved report and template DTOs
SaveReportDto (create): {name (1-120, trimmed), description? (≤500), reportKey (matches REPORT_KEY_PATTERN), config: ReportConfigDto, status? ("draft"|"active", default "active")}. TemplateDto is the same shape without status (templates are always active). UpdateSavedReportDto/UpdateTemplateDto require expectedVersion and make every other field optional. SavedReportVersionDto/ExpectedVersionQueryDto: {expectedVersion} only, used by archive/restore/delete. SavedReportDto (response, shared by both saved reports and templates): {id, kind ("saved"|"template"), name, description, reportKey, config, status ("draft"|"active"|"archived"), version, createdAt, updatedAt, archivedAt, runnable, source?} — runnable: false means the caller no longer holds the codes the reportKey needs; source ("owner"|"system") appears only in the templates list, distinguishing a school-authored template from a built-in system preset. ListSavedQueryDto: {page?, size?, includeTemplates?} — includeTemplates appends the caller's runnable templates, unpaginated, after the saved page. ListTemplatesQueryDto: {includeSystem?} (default true).
6.14 Schedule DTOs
CreateReportScheduleDto: {name (≤120, single line, no control chars), reportKey, config: ReportConfigDto, format (csv|xlsx|pdf), frequency (daily|weekly|monthly|monthly_bs), timeOfDay ("HH:MM" school-local), weekday? (0-6, required iff weekly), dayOfMonth? (-1 or 1-28, required iff monthly/monthly_bs), timezone? (IANA, defaults to school_profile.timezone), recipients: {users?: uuid[≤50], roles?: uuid[≤10]}, activate? (boolean — true creates it already active with next_run_at computed), sourceDefinitionId? (uuid, a saved report owned by the caller)}. UpdateReportScheduleDto is every field of the create DTO made optional (minus sourceDefinitionId, which cannot be changed after creation) plus a required expectedVersion. ListReportSchedulesQueryDto: {status? (comma-separated), page?, size?}. DeleteReportScheduleQueryDto.expectedVersion is required here (unlike the dashboard delete). ScheduleTransitionDto.expectedVersion is optional on activate/pause/archive. RecipientOptionsQueryDto: {reportKey, q?}. RecipientOptionsDto/ScheduleRecipientsOutDto: {users: {id,name}[], roles: {id,name}[]}. ReportScheduleDto (response) adds, beyond the create fields: id, status, nextRunAt/nextRunAtBs, lastRunAt/lastRunAtBs (Bikram Sambat twins of every timestamp, in the schedule's own timezone), lastOutcome, consecutiveFailures, needsAttention, attentionReason, recipients (resolved to names), version, createdAt, updatedAt.
6.15 Settings DTOs
UpdateReportSettingsDto: expectedVersion (required) plus every report_settings field made optional on its own DB-mirrored range (defaultDateRangeDays 1-366, artifactRetentionDays 1-90, jobHistoryRetentionDays 30-3650, maxExportRows 1000-100000, minGroupSize 3-50, ageBucketBounds 1-12 ascending ints 1-30, attendanceThresholdPercent 0-100 with ≤2 decimals — the only field converted to a different unit for storage (basis points), absenceStreakDays 2-60, failedSubjectsThreshold 1-20, overdueFeeThresholdPaisa 0-100000000000, scheduleFailureAlertThreshold 1-20, includeSchoolLogo boolean). ReportSettingsDto (response) mirrors every column, presenting attendanceThresholdPercent (derived) rather than the stored basis points.
6.16 Profile and portal DTOs
ProfileQueryDto: {sessionId? (uuid)} — see §8.13-8.15 for what an out-of-scope or mismatched session does per profile kind. ReportProfileDto: {generatedAt, subject (a small identifying summary specific to the profile kind), sections: ProfileSectionDto[]}, each section {key, title, available, result: ReportResult | null} — available: false sections carry result: null rather than being omitted from the array. PortalReportsDto: {generatedAt, student: {id, fullName, currentPlacement: {grade,section,rollNumber,sessionName} | null}, sections: PortalReportSectionDto[]}, always exactly four sections (placements, attendance, results, assignments, promotions — five keys, four of which are always present alongside placements) with no availability gating, since no permission code applies to the caller's own record.
7. Enum Reference
7.1 ReportCategory (19 values, shared/report-types.ts)
students, demographics, attendance, leave, academics, assignments, exams, promotions, teachers, staff, classes, subjects, calendar, communication, complaints, id_cards, finance, cross_domain, academic_year. teachers, classes and subjects are declared members of the union with no registered report using them directly — staff.* reports use staff/teachers-adjacent vocabulary but are tagged category: "staff".
7.2 REPORT_FORMATS
csv, xlsx, pdf — packaged exports (package.*) accept only xlsx/pdf.
7.3 REPORT_JOB_STATUSES (state machine)
Every transition above is one UPDATE ... WHERE status = '<from>' [AND lease_token = $t] — there is no separate application-level lock, so two concurrent actions (a user cancel racing the worker's finalize; two tabs both clicking retry) always resolve to exactly one winner and the loser sees the 409 for the state it lost to.
7.4 ReportColumnType
text, number, percent, paisa, date, datetime, status (requires enumKey), grade, boolean, hours, days.
7.5 REPORT_SCHEDULE_STATUSES / REPORT_SCHEDULE_FREQUENCIES / REPORT_SCHEDULE_ATTENTION_REASONS
Statuses: draft, active, paused, archived (see the state table in the feature doc). Frequencies: daily, weekly, monthly, monthly_bs. Attention reasons: failures, owner_inactive, owner_permission, invalid_config, schedule_error.
7.6 DASHBOARD_WIDGET_VIEWS (9 values)
kpi, bar, stacked_bar, line, scatter, donut, table, alerts, shortcut. All but shortcut are actually executed at render time; shortcut returns its stored navigation target unresolved.
7.7 The Central Metric Catalogue (GET /reports/metrics)
key, name, formula, unit for every entry — covering attendance, exams, assignments, promotions, fees, finance (payroll/assets), complaints, leave, notices/notifications, ID cards, enrollment, demographics, and cross-domain correlation. Every metric key in the catalogue is referenced by at least one currently-registered report (finance, complaints and communication now ship reports, closing the gap an earlier version of this module had).
Attendance shares (rate, absence, late, excused, staff rate, register coverage) are clamped to 0.01–99.99 when strictly between none and all; none stays 0, all stays 100. Other percentages (pass, promotion, completion, collection, per-100) are not clamped. The formula of each attendance metric states the clamp.
8. Endpoint Reference
8.1 GET /api/reports/catalog
Purpose
Returns every report the caller may run, given their current permissions and scope. The admin panel's report picker calls this to build its list — never a hardcoded list of report keys, since availability is permission-dependent and changes per role.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/reports/catalog/report-catalog.controller.ts:46 |
| Service | apps/api/src/modules/reports/catalog/report-catalog.service.ts |
| DTO | apps/api/src/modules/reports/shared/dto/report-result.dto.ts (ReportCatalogItemDto, CatalogQueryDto) |
Auth and Permissions
- Auth: Bearer JWT
- Guard chain:
JwtAuthGuard,RoleGuard - Permission:
Reports_READ - Rate limit: none beyond global defaults
Request
| Part | Required | Details |
|---|---|---|
| Query | No | q (≤100 chars, matches title/description/category/key/searchTerms, all words must match), category (≤40 chars), kind (analytic|dataset) |
GET /api/reports/catalog?category=attendance&kind=analytic HTTP/1.1
Authorization: Bearer TOKENResponse
{
"success": true,
"message": "Catalog fetched.",
"data": [
{
"key": "attendance.class_summary",
"title": "Class attendance",
"description": "Attendance rate, absence and late rates per class for the selected period.",
"category": "attendance",
"kind": "analytic",
"filters": ["sessionId", "dateFrom", "dateTo", "dateFromBs", "dateToBs", "period", "gradeIds", "sectionIds", "classIds"],
"requiredFilters": [],
"columns": [{ "key": "className", "label": "Class", "type": "text", "enumKey": null, "contact": false }],
"defaultColumns": ["className", "students", "requiredDays", "registersTaken", "registersMissing", "marked", "present", "late", "absent", "excused", "ratePct", "absenceRatePct", "lateRatePct"],
"sortable": [],
"groupable": [],
"metrics": ["attendance.rate", "attendance.absence_rate", "attendance.late_rate"],
"exportFormats": ["csv", "xlsx", "pdf"],
"heavy": false,
"cacheScope": "shared",
"scopeMode": "teaching",
"presets": []
}
]
}Side Effects
None — read-only, no cache write, no audit record.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
401 | — | No/invalid token |
403 | AUTH_ACTIVE_ROLE_REQUIRED | No active role on the session |
Edge Cases
- No reports match the filters →
data: [], stillsuccess: true. - A caller with only
Reports_READand no domain codes →data: [](the catalog is never "everything, greyed out").
8.2 GET /api/reports/metrics
Returns the full central metric-definition catalogue. Used for a "what does this number mean" tooltip/glossary next to any KPI. Permission: Reports_READ only — the definitions reveal no data, only vocabulary. No parameters. Response: array of MetricDefinitionDto, unfiltered, identical for every caller. Errors: 401/403 only, as in §8.1.
8.3 GET /api/reports/filter-options
Populates the report filter bar: sessions, grades, sections, classes (scoped to the caller), subjects, departments, designations, exams, leave types, complaint categories, fee categories, rooms. Query: sessionId? (uuid, defaults to the current session). Response: FilterOptionsDto (§6.5). No side effects. Edge cases: no current session and no sessionId → classes: [], exams: [], reference lists still populated; a caller with no teaching assignment → classes: [] (the scope predicate is sql\false``, not an error).
8.4 POST /api/reports/run
Purpose
Runs exactly one report live and returns its full result. This is the single route every report page's "load" and "apply filters" actions call, and the route dashboards, profiles, overview and year-closing internally route through for each of their constituent reports. Results are cached briefly (60–120s) and keyed on the resolved filters/columns/sort/scope, so identical requests within the TTL do not re-query the database.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | catalog/report-catalog.controller.ts (ReportRunController) |
| DTO | shared/dto/report-result.dto.ts (RunReportDto, ReportResultDto) |
| Service | catalog/report-run.service.ts |
| Authoriser | shared/report-authorizer.service.ts |
| Cache | shared/report-cache.service.ts |
| Query | shared/report-query.service.ts |
Auth and Permissions
- Auth: Bearer JWT
- Guard chain:
JwtAuthGuard,RoleGuard,IpThrottlerGuard - Permission:
Reports_READplus every code in the target report'srequires - Rate limit:
ADMIN_REPORT_RUN— 60 requests/minute, keyed per user - Idempotency: not idempotent by design for
refresh: true(each is a fresh compute); otherwise served from cache
Request
Minimal:
{ "reportKey": "attendance.class_summary" }Full:
{
"reportKey": "students.directory",
"filters": { "sessionId": "0198c1e4-...", "gradeIds": ["0198c1e4-..."], "gender": ["female"] },
"columns": ["studentCode", "fullName", "grade", "section", "phone"],
"sort": { "key": "fullName", "dir": "asc" },
"page": 1,
"size": 25,
"refresh": false
}Response
ReportResultDto (§6.2), status 200. Example (analytic report, truncated):
{
"success": true,
"message": "Report generated.",
"data": {
"reportKey": "attendance.class_summary",
"title": "Class attendance",
"category": "attendance",
"kind": "analytic",
"generatedAt": "2026-09-24T04:12:00.000Z",
"cached": false,
"context": { "sessions": [{ "id": "...", "name": "2082 BS" }], "dateFrom": "2026-08-25", "dateTo": "2026-09-24", "filters": [], "scopeLimited": false, "minGroupSize": 5 },
"summary": [{ "key": "rate", "label": "Attendance rate", "value": 92.4, "unit": "percent", "metricKey": "attendance.rate", "suppressed": false }],
"charts": [{ "key": "rateByClass", "title": "Attendance rate by class", "type": "bar", "xKey": "className", "xLabel": "Class", "series": [{ "key": "ratePct", "label": "Attendance rate", "unit": "percent" }], "data": [], "note": null }],
"table": { "columns": [], "rows": [], "totals": null, "page": null, "suppressedCells": [] },
"warnings": [],
"metrics": ["attendance.rate", "attendance.absence_rate", "attendance.late_rate"]
}
}Side Effects
- Cache: read/write via
ReportCacheService, keyed as in the backend doc §8. No explicit invalidation route exists — TTL expiry (or areport_settings.versionbump) is the only eviction. - Database: one
REPEATABLE READ, READ ONLYtransaction per uncached run. - No jobs, no notifications, no realtime events, no audit record.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
400 | REPORT_FILTER_INVALID | Unknown filter key, malformed value, unknown column/sort key, >60 columns |
400 | REPORT_DATE_RANGE_INVALID | Missing half of a range, reversed, not real, too long, invalid BS date |
400 | REPORT_PERIOD_INVALID | Unknown period, combined with dates/session, unresolvable |
400 | REPORT_TOO_MANY_SESSIONS | >5 sessionIds |
400 | REPORT_REQUIRED_FILTER_MISSING | A requiredFilters entry absent (e.g. examId on exams.results) |
400 | REPORT_BUILDER_INVALID | builder.custom with a missing or invalid builder body |
403 | REPORT_DOMAIN_FORBIDDEN | Missing a domain permission |
403 | REPORT_SCOPE_FORBIDDEN | School-wide report without ReportsSchoolWide_READ, or a filter outside teaching scope |
403 | REPORT_COLUMN_FORBIDDEN | A contact column/sort requested without ReportsContacts_READ |
404 | REPORT_NOT_FOUND | Unknown reportKey |
404 | REPORT_REFERENCE_NOT_FOUND | A filter's public id does not exist |
409 | REPORT_HEAVY_USE_EXPORT | A heavy report requested live across >1 session |
429 | REPORT_REFRESH_RATE_LIMITED | Second refresh: true within 10s |
429 | — (IpThrottlerGuard) | >60 requests/minute |
503 | REPORT_BUSY | Connection semaphore exhausted |
503 | REPORT_QUERY_TIMEOUT | Statement timeout or 15s live deadline exceeded |
Edge Cases
- Empty result set →
table.rows: [],warningsmay includeNO_SOURCE_ROWSorSOURCE_NOT_CONFIGURED. - A small demographic group → cells blanked,
suppressedCellspopulated,GROUPS_SUPPRESSEDwarning present. attendance.dailyrequested over more than 31 days → 400REPORT_DATE_RANGE_INVALID(its own live-only span cap).leave.balanceslive without exactly oneleaveTypeIds→ 400REPORT_REQUIRED_FILTER_MISSING.attendance.class_summaryreturns sevensummaryKPIs. The rates are over marked pupil records only, so they stay high while registers go untaken; the last four give the base, with the same meaning as the overview's attendance section. Values are numbers ornull; thepercentunit is 0–100; no small-group suppression applies to them (suppressedis alwaysfalse). The rate, absence, late and coverage shares are clamped to 0.01–99.99 when strictly between none and all, so one absence among tens of thousands of marked records never reads 100% and one untaken register among them never reads full coverage; none stays0and all stays100.
key | Unit | Meaning |
|---|---|---|
rate, absenceRate, lateRate | percent | Present or late, absent and late, each as a share of marked pupil records on required days. |
markedRecordCount | count | The marked pupil records those rates are computed over — a count of pupil records, not of registers. |
markedCount | count | Registers taken in the window, for the classes that pass the report's filters and the caller's scope. A register is one active class on one required day; it is taken when an attendance session whose status is not not_started exists for that class and date — the same rule as the registersTaken column of the class table, so the KPI equals that column's sum. A not_started session with no records, which a request of only unmarks leaves behind, is not a taken register. |
expectedCount | count | Registers expected: active classes of the session that pass the report's class filters and the caller's scope × required calendar days in [dateFrom, dateTo]. 0 when no class matches or the window has no required day. |
coverageRatePct | percent | markedCount as a share of expectedCount; null when expectedCount is 0. |
Example Requests
curl -X POST "$API_URL/api/reports/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"reportKey":"attendance.class_summary","filters":{"period":"last_30_days"}}'8.5 GET /api/reports/overview
Purpose
Returns cross-domain KPIs and alerts for the admin panel's landing dashboard — one section per domain (students, attendance, academics, assignments, promotions, complaints, finance, staff, id_cards, communication), each with its own KPIs and threshold-driven alerts (e.g. classes below the attendance threshold, overdue fees, schedules needing attention).
Source Evidence
overview/reports-overview.controller.ts, overview/reports-overview.service.ts, overview/overview-sections.query.ts, overview/overview-register-tally.query.ts, overview/overview-alerts.service.ts, overview/overview.dto.ts.
Auth and Permissions
Permission: Reports_READ. Each section's KPIs/alerts are independently gated by that domain's own Reports*_READ code — a section the caller cannot see is omitted from sections entirely. Every section that is returned has available: true; it carries empty kpis and alerts only when no academic session resolves.
Request
Query: sessionId? (uuid) — defaults to the current academic session.
Response
OverviewDto: {generatedAt, session: {id,name}, sections: OverviewSectionDto[]}, each {key, available, kpis: ReportKpiDto[], alerts: OverviewAlertDto[]}. An OverviewAlertDto is {code, label, count, reportKey, filters} — clicking one navigates to reportKey pre-filtered with filters.
The attendance section's KPIs cover the 30 days ending today (school-local date) — today and the 29 days before it — restricted to required calendar days and, for a caller who is not school-wide, to the classes they teach. Every value is a number or null; the percent unit is 0–100.
key | Unit | Meaning |
|---|---|---|
ratePct | percent | Present or late, as a share of marked pupil records. Untaken registers do not enter it. |
absenceRatePct, lateRatePct, excusedRatePct | percent | Absent, late and excused, each as a share of marked pupil records. |
markedCount | count | Registers taken in the window. A register is one active class of the session on one required day; it is taken when an attendance session whose status is not not_started exists for that class and date. A not_started session with no records, which a request of only unmarks leaves behind, is not a taken register. |
expectedCount | count | Registers expected in the window: active classes of the session × required days, today included. 0 when the session has no calendar rows or no active class. |
coverageRatePct | percent | markedCount as a share of expectedCount; null when expectedCount is 0. |
The rate, the absence, late and excused shares and the register coverage are clamped to 0.01–99.99 whenever the share is strictly between none and all, so one absence among many marked records never reads 100%, one present record never reads 0%, and one untaken register never reads full coverage; none stays 0 and all stays 100.
The rate is a quality of what was marked and the coverage says how much was marked, so a client shows them together: ratePct 100 with coverageRatePct 20 means the registers that were taken were all clean and most were never taken. expectedCount − markedCount is the untaken remainder of the window; the ATTENDANCE_UNMARKED_REGISTERS alert ("Registers not taken in the last 7 days") counts the same quantity over the trailing 7 days (today and the 6 days before it), so its count never exceeds that remainder. The BELOW_THRESHOLD alert reads the same 30-day window as the rates.
Side Effects
None — read-only; an explicit ReportAuditService VIEW record is written (this GET is not covered by the global mutating-request interceptor).
Error Cases
401/403 AUTH_ACTIVE_ROLE_REQUIRED only.
8.6 GET /api/reports/year-closing
Purpose
Returns the blocking and informational checks (open leave/complaints, unpublished exams, missing ID cards, etc.) plus summary KPI sections an administrator reviews before closing an academic year.
Source Evidence
overview/reports-overview.controller.ts, overview/reports-year-closing.service.ts, overview/year-closing-checks.query.ts, overview/year-closing-gating.util.ts.
Auth and Permissions
Permission: Reports_READ and ReportsSchoolWide_READ — enforced in the service via ReportCompositeAuthorizer, 403 REPORT_SCOPE_FORBIDDEN without the latter (year-closing figures are never meaningful per-class).
Request
Query: sessionId? (uuid).
Response
YearClosingDto: {generatedAt, session, checks: YearClosingCheckDto[], summary: YearClosingSummarySectionDto[]}. Each check is {code, label, count, unit: "count"|"paisa", reportKey, filters, severity: "blocking"|"info"} — unit tells the client how to render count: as a plain number, or as a paisa amount needing the same money formatting as any other paisa-typed value. Every check is unit: "count" except FEES_OUTSTANDING (label "Outstanding fee balance", reportKey: "finance.fee_balances", severity: "info"), which is unit: "paisa" and carries the sum of outstanding balances in paisa, not a count of pupils. A check or summary section the caller lacks the underlying domain permission for is omitted from the response entirely — never shown as a locked row.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
403 | REPORT_SCOPE_FORBIDDEN | Caller lacks ReportsSchoolWide_READ |
8.7 GET /api/reports/profiles/students/:studentId, /classes/:classId, /teachers/:staffId
Purpose
Three composite, read-only analytic profile screens — everything about one student, one class, or one teacher, assembled from several underlying reports/queries into named sections.
Source Evidence
profiles/reports-profiles.controller.ts, profiles/student-profile.service.ts, profiles/class-profile.service.ts, profiles/teacher-profile.service.ts, profiles/dto/profile-query.dto.ts, profiles/dto/profile-result.dto.ts.
Auth and Permissions
Permission: Reports_READ only at the route level — the finer-grained per-section domain-code and scope checks, and the 404-on-out-of-scope-or-missing-target rule, live in ReportCompositeAuthorizer.authorizeProfile, run inside each service.
Request
Path: studentId/classId/staffId (UUID, ParseUUIDPipe). Query: ProfileQueryDto {sessionId?}.
?sessionId= behaves differently per profile kind:
- Student: must be a session visible to the caller; an unknown or out-of-scope id is
404 REPORT_PROFILE_NOT_FOUND. - Class: a class belongs to exactly one session, so
sessionIdcannot select a different one — a mismatch is400 REPORT_FILTER_INVALID, not a 404. - Teacher: any session is acceptable.
Absent → the caller's current session.
Response
ReportProfileDto: {generatedAt, subject, sections: ProfileSectionDto[]}, each {key, title, available, result: ReportResult | null}.
A subject teacher (holding the domain read code but not the class-teacher relationship) sees only published exam results, and overall (cross-subject) results only for classes where they are the class teacher. The class profile's "class sections grid" is scoped to the caller's own visible classes (school-wide callers see every sibling class; a teaching-scoped caller sees only their own), with small groups suppressed the same as any other breakdown, and every classId on the grid is the class's public UUID.
Side Effects
None mutating; an explicit ReportAuditService VIEW record is written per profile read.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
400 | REPORT_FILTER_INVALID | Class profile: sessionId does not match the class's own session |
404 | REPORT_PROFILE_NOT_FOUND | Target does not exist, is outside the caller's scope, or (student) sessionId is unknown/out of scope |
8.8 GET /api/reports/builder/datasets
Lists the datasets and fields the caller may compose a custom report from. Permission: ReportBuilder_READ. A dataset whose domain codes (or school-wide scope, for a school_only dataset) the caller lacks is omitted; a contact-flagged field is omitted unless the caller holds ReportsContacts_READ. Response: BuilderDatasetDto[] (§6.10). No side effects.
8.9 POST /api/reports/builder/preview
Purpose
Runs a user-composed query against one dataset live, read-only, authorised and cached exactly like any other report (the run is registered under the single key builder.custom).
Source Evidence
builder/report-builder.controller.ts, builder/report-builder.service.ts, builder/builder-compiler.ts (resolveBuilderQuery), builder/builder-query-runner.ts, builder/dto/builder-query.dto.ts.
Auth and Permissions
- Guard chain:
JwtAuthGuard,RoleGuard,IpThrottlerGuard - Permission:
ReportBuilder_READ, plus every domain code the chosen dataset requires (resolved byresolveBuilderQueryand checked exactly likeReportDefinition.requires) - Rate limit:
ADMIN_REPORT_RUN(shared with the live-run route)
Request
BuilderQueryDto (§6.11).
{ "dataset": "students", "columns": ["grade", "gender"], "groupBy": ["grade", "gender"], "aggregates": [{ "fn": "count" }] }Response
ReportResultDto, identical shape to a catalog report's run response, with reportKey: "builder.custom".
Side Effects
Same as POST /reports/run: cache read/write only, no database writes.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
400 | REPORT_BUILDER_INVALID | Unknown dataset/field, an aggOnly field outside an aggregate, a demographic/aggOnly field used in a row filter, or an identifying groupBy field combined with a demographic field |
403 | REPORT_SCOPE_FORBIDDEN | A school_only dataset without ReportsSchoolWide_READ |
403 | REPORT_COLUMN_FORBIDDEN | A contact field referenced without ReportsContacts_READ |
Edge Cases
- A group whose row count falls under
report_settings.min_group_sizeis dropped from the result entirely (not blanked) — the builder has no fixed column set to blank a cell in. - Sort tiebreak and
GROUP BYare always deterministic (group key, then a stable column), so paginated pages never reorder between requests.
8.10 Report Dashboards (/api/reports/dashboards)
Six routes: GET (list mine + every school dashboard), POST (create), GET /:id, PATCH /:id (requires expectedVersion), DELETE /:id (optional expectedVersion), POST /:id/render.
Create/update body: DashboardDto/UpdateDashboardDto (§6.12). Render body: RenderDashboardDto {filters?}, merged over the dashboard's default_filters. Render response: DashboardRenderResultDto — every widget's result or per-widget error, concurrency 2, 18s deadline for the whole batch; one widget failing never blanks the others.
Permissions: ReportDashboards_READ/_CREATE/_UPDATE/_DELETE; creating or editing a scope: "school" dashboard additionally needs ReportDashboardsShared codes — attempting it without them is 403 REPORT_DASHBOARD_SCOPE_FORBIDDEN. Rate limit on render: ADMIN_REPORT_RUN.
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | REPORT_DASHBOARD_NOT_FOUND | Unknown id, or not visible |
409 | REPORT_DASHBOARD_NAME_TAKEN | Name collision within the same scope |
403 | REPORT_DASHBOARD_SCOPE_FORBIDDEN | School-scope create/edit without the shared code |
409 | REPORT_DASHBOARD_LIMIT_REACHED | Personal/school dashboard cap reached |
409 | REPORT_VERSION_CONFLICT | expectedVersion stale on update, or (if supplied) on delete |
Edge case: a view: "shortcut" widget's render entry returns its stored {target: {reportKey, savedId?, templateId?}} unresolved — the client navigates to it rather than rendering inline data.
8.11 Saved Reports (/api/reports/saved) and Templates (/api/reports/templates)
Saved reports — seven routes, all scoped to the caller's own owner_user_id: GET (list, ?includeTemplates=true appends runnable templates unpaginated), POST (create), GET /:id, PATCH /:id (expectedVersion required), POST /:id/archive, POST /:id/restore, DELETE /:id.
Templates — five routes, school-wide (no owner): GET (?includeSystem= default true), POST (ReportTemplates_CREATE), GET /:id, PATCH /:id (ReportTemplates_UPDATE), DELETE /:id (ReportTemplates_DELETE). Reading a template needs only Reports_READ.
Both validate config against the named reportKey's definition (unknown filter/column/sort keys rejected exactly as a live run would reject them) before writing.
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | REPORT_SAVED_NOT_FOUND / REPORT_TEMPLATE_NOT_FOUND | Unknown id, wrong owner (saved), or wrong kind |
409 | REPORT_SAVED_NAME_TAKEN / REPORT_TEMPLATE_NAME_TAKEN | Case-insensitive name collision within scope |
409 | REPORT_SAVED_LIMIT_REACHED | Caller already has 100 live saved reports |
409 | REPORT_SAVED_STATE_INVALID | e.g. restoring a report that is not archived |
409 | REPORT_VERSION_CONFLICT | expectedVersion stale |
Response DTO for both: SavedReportDto (§6.13). Example create:
{ "name": "My weekly attendance", "reportKey": "attendance.class_summary", "config": { "filters": { "period": "last_7_days" } } }8.12 Report Schedules (/api/reports/schedules)
Ten routes, all owner-scoped: GET (list), GET /recipient-options (declared before :id), POST (create), GET /:id, PATCH /:id (expectedVersion required), POST /:id/activate, POST /:id/pause, POST /:id/archive (each with an optional expectedVersion), POST /:id/run-now (returns a ReportJobDto, 202 Accepted), DELETE /:id (expectedVersion required).
Create/update validate config against the report definition, the cadence fields' interdependencies (weekday iff weekly, dayOfMonth iff monthly/monthly_bs), and every named recipient's eligibility to receive this exact report+config via the same predicate the sweep will use — an ineligible name is refused up front rather than silently skipped later.
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | REPORT_SCHEDULE_NOT_FOUND | Unknown id, or not owned by the caller |
400 | REPORT_SCHEDULE_INVALID | Bad config, or a cadence field missing/contradictory |
400 | REPORT_SCHEDULE_RECIPIENTS_INVALID | A named recipient is not eligible |
409 | REPORT_SCHEDULE_TRANSITION_INVALID | e.g. activating an already-archived schedule |
409 | REPORT_SCHEDULE_LIMIT_REACHED | Per-owner schedule cap reached |
409 | REPORT_VERSION_CONFLICT | expectedVersion stale |
GET /recipient-options?reportKey=attendance.class_summary&q=ram returns {users, roles} of eligible people/roles, bounded by a candidate pool and result limit.
8.13 Report Settings (/api/reports/settings)
GET (ReportSettings_READ) and PATCH (ReportSettings_UPDATE, expectedVersion required, body §6.15). The update converts attendanceThresholdPercent to basis points and bumps version in the same UPDATE, which is also the report cache's own version key — a threshold change is visible on the very next report run, no cache flush needed.
| HTTP Status | Error Code | Condition |
|---|---|---|
400 | REPORT_SETTINGS_INVALID | A field fails its range check |
409 | REPORT_VERSION_CONFLICT | expectedVersion stale |
503 | REPORT_SETTINGS_MISSING | The singleton row is missing (installation fault) |
8.14 GET /api/reports/jobs
Lists export jobs — the requester's own by default, or every job with scope=all and ReportJobs_READ. "Mine" includes jobs where the caller is a recipient, not only the requester. Query: ListReportJobsQueryDto (§6.7). Response: paginated ReportJobDto[]. Error: 403 REPORT_SCOPE_FORBIDDEN on scope=all without ReportJobs_READ.
8.15 POST /api/reports/jobs
Purpose
Requests a background export — a single report, builder.custom, or a package (package.annual_academic, package.custom) — in CSV, XLSX or PDF.
Source Evidence
jobs/report-jobs.controller.ts, jobs/report-jobs.service.ts (create), jobs/report-job-planner.service.ts, jobs/report-job-writer.service.ts, jobs/dto/report-job.dto.ts.
Auth and Permissions
Permission: ReportExports_CREATE, plus (via the planner) every code the target report(s) require. Idempotency: Idempotency-Key header, scoped per user.
Request
Minimal:
{ "reportKey": "attendance.daily", "config": { "filters": { "dateFrom": "2026-08-01", "dateTo": "2026-08-31" } }, "format": "xlsx" }Package:
{
"reportKey": "package.annual_academic",
"config": { "filters": { "sessionId": "0198c1e4-..." } },
"format": "pdf",
"title": "Annual report 2082"
}Response
202 Accepted, ReportJobDto. message is "Export queued." on a new job or "Export already requested." when the same Idempotency-Key matched an existing one.
Side Effects
- Database: one
report_jobsrow inserted inside a transaction holdingpg_advisory_xact_lock(hashtext('report_jobs:{userId}')), with anoutbox_eventsrow. - Outbox → BullMQ: relayed to
QueueName.REPORTSasreports.generate. - No file exists yet — poll
GET /reports/jobs/:idor wait for thereport.readynotification.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
400 | REPORT_CONFIG_INVALID | Malformed Idempotency-Key |
400 | REPORT_EXPORT_UNSUPPORTED | Format not supported by this report/package |
400 | REPORT_CONFIG_INVALID | A custom package with fewer than 2 or more than 12 parts, a duplicate part, or a part that is itself a package/builder key |
| any authoriser error | (as in §8.4) | The planner re-runs full authorisation for every part |
403 | REPORT_DOMAIN_FORBIDDEN | No part of a package could be authorised (empty result) |
409 | REPORT_JOB_IDEMPOTENCY_CONFLICT | Same key, different body |
429 | REPORT_JOB_LIMIT_REACHED | 5 active (queued/processing) jobs already |
Edge Cases
package.annual_academicsilently omits parts the caller cannot see and adds aPACKAGE_PARTS_OMITTEDwarning once the file is generated.- Sending recipients (schedule-only, not exposed on this manual-create route) excludes principal-scoped parts from being generated at all for a job that will have recipients.
8.16 GET /api/reports/jobs/:id
Polls one export job's current state. Permission: ReportExports_READ. Visibility (canView): requester, a recipient, or ReportJobs_READ/superadmin. 404 REPORT_JOB_NOT_FOUND for both "does not exist" and "not visible" — identical response either way.
8.17 GET /api/reports/jobs/:id/events
Shows the job's full lifecycle history. Response: ReportJobEventDto[] (§6.9), oldest-first, capped at 500 events. Same 404 rule as §8.16.
8.18 POST /api/reports/jobs/:id/cancel
Cancels a job still queued or processing. Permission: ReportExports_UPDATE; manageable = requester or ReportJobs_UPDATE/superadmin. A conditional UPDATE ... WHERE status IN ('queued','processing'); a processing job's worker notices at its next checkpoint (its lease-conditional UPDATE returns zero rows) and cleans up — it does not stop instantly. Errors: 404 REPORT_JOB_NOT_FOUND, 409 REPORT_JOB_NOT_CANCELLABLE.
8.19 POST /api/reports/jobs/:id/retry
Re-queues a failed job with the same configuration. Response: ReportJobDto (status: "queued", retryCount incremented), a fresh outbox delivery event. Error: 409 REPORT_JOB_NOT_RETRYABLE (not failed, or retryCount >= 10).
8.20 DELETE /api/reports/jobs/:id/artifact
Deletes the generated file immediately. Response: ReportJobDto (status: "expired", artifactKey: null). Storage deletion is attempted synchronously; on failure the tracking row is left for the maintenance sweep. Error: 409 REPORT_JOB_NOT_READY.
8.21 GET /api/reports/jobs/:id/download
Purpose
Streams the generated file — the only route in the module whose response is not the standard JSON envelope.
Auth and Permissions
- Permission:
ReportExports_READ. canDownload()re-checks, at download time: requester-or-recipient, everyrequired_codesstill held, contacts code ifincludesContacts, andReportsSchoolWide_READifgeneratedScope === "school_wide".
Response
Raw file bytes. Headers: Content-Type, Content-Disposition: attachment; filename="..."; filename*=UTF-8''..., Content-Length, Cache-Control: no-store, private, X-Content-Type-Options: nosniff.
Side Effects
download_count incremented, last_downloaded_at set, a downloaded report_job_events row, and an explicit ReportAuditService.record(..., "DOWNLOAD", ...) call (metadata carries reportKey, format, rowCount, scopeKind, includesContacts, asRecipient — never a file name or content).
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | REPORT_JOB_NOT_FOUND | Not visible, or canDownload() fails — same response as "does not exist" |
410 | REPORT_JOB_EXPIRED | Status expired, or past expiresAt |
409 | REPORT_JOB_NOT_READY | Not yet completed, or no artifact key |
8.22 GET /api/mobile/student/reports
Purpose
The pupil's own reports screen: placements, attendance, results, assignments, promotions — always their own record, never any permission-gated selection.
Source Evidence
portal/reports-student-portal.controller.ts, portal/reports-portal.service.ts, portal/dto/portal-reports.dto.ts.
Auth and Permissions
No @Permissions(). assertPortalAudience(actor, "student"), then the controller resolves students by userId = actor.id AND deleted_at IS NULL directly (the identity layer, per the repo's portal rules — never PeopleAccessService.scopeFor).
Response
PortalReportsDto (§6.16).
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
403 | (audience assertion) | Caller's activeRole.scopeKind is not student |
404 | STUDENT_NOT_FOUND | No live student row for this user |
Edge Cases
- Assignment counts include only published or closed assignments, and "already due" counts only assignments whose due date has passed as of now — nothing draft or not-yet-due is counted.
- Attendance's "expected days" figure is bounded at today — no future school day counts as a day the pupil should already have a mark for.
8.23 GET /api/mobile/guardian/children/:studentId/reports
Same shape and rules as §8.22, for a guardian viewing one child. assertPortalAudience(actor, "guardian"), then GuardianPortalService.resolveOwnChildId(actor, studentId) — the identity-layer predicate that is the authorization decision; a mismatch is 404 STUDENT_NOT_FOUND, never 403, so studentId is not an enumeration oracle over the pupil roll.
9. Flow Diagrams
9.1 Route Ownership
9.2 Error Branch (POST /reports/run)
9.3 Optimistic Concurrency (any PATCH/archive/restore/delete on saved/template/dashboard/schedule)
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
POST /reports/run, POST /reports/builder/preview (dataset kind) | page/size (in body) | 25 | 100 | Per-report/dataset sortable list | The report's or dataset's own allow-list | page up to 10000 |
GET /reports/jobs | page/size (PaginationUtil) | 20 | 100 | none (fixed: createdAt DESC, id DESC) | status, origin, scheduleId, scope | none beyond page size |
GET /reports/jobs/:id/events | none | — | — | none (fixed chronological) | none | 500 events |
GET /reports/saved | page/size | 20 | 100 | none | includeTemplates (appends, unpaginated) | none |
GET /reports/schedules | page/size | 20 | 100 | none | status (comma-separated) | page up to 10000 |
An analytic-kind report's table.page is null — its table is a bounded aggregation, not a paginated roll. Only dataset-kind reports and the builder carry page.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | Yes | reports:v1:{key}:{hash}:{scope}:s{version}, 60–120s TTL, cache-aside, fail-open; shared by run, builder preview, dashboard widgets, profile/overview sections | shared/report-cache.service.ts |
| Redis rate limiter | Yes | reports:refresh:{userId}:{reportKey}, SET NX EX 10, fail-open | shared/report-cache.service.ts |
| BullMQ | Yes | QueueName.REPORTS (generation, concurrency 1), QueueName.REPORTS_SWEEP (schedule + maintenance, concurrency 1) | packages/jobs/src/index.ts, jobs/workers/* |
| Object storage | Yes | UploadType.REPORT_ARTIFACT (report-artifact prefix), staged then moved via StorageManager | jobs/generation/report-generation.service.ts |
| External API | No | Nothing outside the platform is called | — |
12. Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Admin panel | Report keys are opaque strings from the catalog/builder datasets — never hardcode a list. Poll /reports/jobs/:id (or listen for report.ready/report.failed) rather than assuming a fixed generation time. Every mutating call on saved/template/dashboard/schedule needs the row's current version. | Show error.errorCode mapped to a friendly message; REPORT_BUSY/REPORT_QUERY_TIMEOUT should suggest "export instead"; REPORT_VERSION_CONFLICT should refetch and re-apply. | Stable for every route in §4 — every leaf now has a live surface. |
| Mobile/panel portal | The portal routes carry no permission — availability is entirely audience-based; a 404 on the guardian route can mean "not your child" or "does not exist", indistinguishably. | Treat 404 on the portal routes as "cannot show this", never surface it as a permission error. | Stable. |
| QA | Seed data must include an academic session with school days configured, or most reports return SOURCE_NOT_CONFIGURED/empty. | Reproduce a specific failure by holding/removing the exact permission code named in §5. | — |
| Internal service | None of this module is called internally by another module today (no cross-module dependency on Reports). | — | — |
13. API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? |
|---|---|---|---|---|
| Adding a report key to a domain class | Admin panel catalog picker (auto-discovers it) | None to existing routes | None | No — additive |
| Adding a column/filter to an existing report | Any saved report/schedule/dashboard referencing that key by its config.columns/config.filters | The authoriser is forward-compatible with unknown-but-now-known keys; a removed column referenced by an old saved config produces the COLUMNS_DROPPED warning path | None automatic | No |
| Adding a builder dataset | GET /reports/builder/datasets (auto-discovers it) | None | None | No |
Zero-Omission API Checklist
- Every controller route documented (49 admin routes across 10 controllers + 2 portal routes = 51, matching
apps/api/test/structure/structure.baseline.json). - Every DTO field, enum and default documented.
- Every error code this surface can throw documented with its condition.
- Every auth guard and permission documented.
- Every cache key and queue documented.
- Optimistic-concurrency behaviour documented per surface (required vs optional
expectedVersion).
See Also
- Backend doc:
/docs/developer/reports/backend - Features and flows doc:
/docs/developer/reports/feature
Reports Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Reports & Analytics.
Exams Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the examination and result module.