Skoolsewa - School Management System Docs
Developer ResourcesReports

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

AreaFiles InspectedWhat Was Verified
ControllersAll twelve controller files under apps/api/src/modules/reports/Every route, guard, decorator and status code, via `grep -nE "@(Get
DTOsshared/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.tsEvery field, validator, default
ServicesOne service per controller (see the backend doc §6)Behavior, side effects, response mapping
Schemapackages/db/src/schema/school/reports.tsEnums surfaced on the wire (REPORT_FORMATS, REPORT_JOB_STATUSES, REPORT_SCHEDULE_STATUSES, REPORT_SCHEDULE_FREQUENCIES, REPORT_SCHEDULE_ATTENTION_REASONS)
Errorsapps/api/src/common/types/error-codes.tsEvery REPORT_* code this surface can throw — all are live
Permissionspackages/db/src/authorization/permission-catalog.tsEvery Reports* / Report* code
Rate limitsapps/api/src/common/guards/ip-throttler.config.tsADMIN_REPORT_RUN, ADMIN_REPORT_EXPORT

2. Module Summary

FieldValue
Module nameReports & Analytics
Module slugreports
Primary actorsAny 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 surfacesAdmin (/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 modelJwtAuthGuard + 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
PersistencePostgreSQL (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 truthreport_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 docsBackend, Features and flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
Report keyA dotted lowercase identifier like attendance.class_summary naming one runnable reportshared/dto/report-result.dto.ts (REPORT_KEY_PATTERN)Every route that names a report
Analytic reportA kind: "analytic" report — KPIs + charts + a (usually aggregated) table, no pagination beyond the table's own rowsshared/report-types.tsCatalog, run, dashboards, profiles
Dataset reportA kind: "dataset" report — a page of raw rows, paginated, sortable, streamable for exportshared/report-types.tsCatalog, run, export
Scope modeHow a report's or builder dataset's rows relate to the caller's teaching assignment: school_only, teaching, self_staff, teaching_and_self_staff, unscopedshared/report-types.tsAuthorisation, catalog, builder datasets
ReportsSchoolWide_READThe permission that turns "my classes only" into "the whole school"packages/db/src/authorization/permission-catalog.tsEvery teaching-scoped report/dataset, year closing
ReportsContacts_READThe permission that unlocks phone/email columns/fields on directory-style reports and the buildersamestudents.directory, staff.directory, contact-flagged builder fields
Export jobA background-generated CSV/XLSX/PDF file, tracked in report_jobspackages/db/src/schema/school/reports.tsPOST /reports/jobs and its lifecycle routes
PackageA multi-report export bundled into one XLSX or PDF file (package.annual_academic, package.custom)shared/report-package-authorizer.service.tsJob creation only — no live "run" of a package exists
Filter summaryA human-readable, name-free description of a job's filters, persisted on the job rowjobs/report-job-planner.service.ts (summarise)Job list/detail
Custom builderA user-composed query over one frozen dataset (builder.custom), with its own filters/columns/grouping/aggregatesbuilder/dataset-registry.ts, builder/builder-compiler.tsGET /reports/builder/datasets, POST /reports/builder/preview, saved/scheduled builder.custom configs
Dashboard widgetOne tile on a dashboard — a report, an alerts feed, or a shortcut — with a grid positiondashboards/dto/dashboard.dto.tsDashboard 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_CONFLICTreport_definitions.version, report_dashboards.version, report_schedules.versionSaved reports, templates, dashboards, schedules
ProfileA composite, multi-section read-only screen for one student, class or teacherprofiles/dto/profile-result.dto.tsGET /reports/profiles/...
Portal reportsThe 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

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
AdminGET/api/reports/catalogAdminJwtAuthGuard, RoleGuardReports_READReportCatalogControllerReports and analytics the caller may run
AdminGET/api/reports/metricsAdminJwtAuthGuard, RoleGuardReports_READReportCatalogControllerThe central metric-definition catalogue
AdminGET/api/reports/filter-optionsAdminJwtAuthGuard, RoleGuardReports_READReportCatalogControllerOptions for the filter bar, scoped to the caller
AdminPOST/api/reports/runAdminJwtAuthGuard, RoleGuard, IpThrottlerGuardReports_READ + the report's own requiresReportRunControllerRun one report live
AdminGET/api/reports/overviewAdminJwtAuthGuard, RoleGuardReports_READReportOverviewControllerCross-domain KPIs and alerts for the school's dashboard
AdminGET/api/reports/year-closingAdminJwtAuthGuard, RoleGuardReports_READ (+ ReportsSchoolWide_READ enforced in the service)ReportOverviewControllerChecks and summary before closing an academic year
AdminGET/api/reports/profiles/students/:studentIdAdminJwtAuthGuard, RoleGuardReports_READReportProfilesControllerOne pupil's analytic profile
AdminGET/api/reports/profiles/classes/:classIdAdminJwtAuthGuard, RoleGuardReports_READReportProfilesControllerOne class's analytic profile
AdminGET/api/reports/profiles/teachers/:staffIdAdminJwtAuthGuard, RoleGuardReports_READReportProfilesControllerOne teacher's analytic profile
AdminGET/api/reports/builder/datasetsAdminJwtAuthGuard, RoleGuardReportBuilder_READReportBuilderControllerDatasets and fields the caller may build a report from
AdminPOST/api/reports/builder/previewAdminJwtAuthGuard, RoleGuard, IpThrottlerGuardReportBuilder_READReportBuilderControllerRun a custom query live
AdminGET/api/reports/dashboardsAdminJwtAuthGuard, RoleGuardReportDashboards_READReportDashboardsControllerMy personal dashboards plus every school dashboard
AdminPOST/api/reports/dashboardsAdminJwtAuthGuard, RoleGuardReportDashboards_CREATEReportDashboardsControllerCreate a dashboard
AdminGET/api/reports/dashboards/:idAdminJwtAuthGuard, RoleGuardReportDashboards_READReportDashboardsControllerRead one dashboard
AdminPATCH/api/reports/dashboards/:idAdminJwtAuthGuard, RoleGuardReportDashboards_UPDATEReportDashboardsControllerUpdate a dashboard
AdminDELETE/api/reports/dashboards/:idAdminJwtAuthGuard, RoleGuardReportDashboards_DELETEReportDashboardsControllerDelete a dashboard
AdminPOST/api/reports/dashboards/:id/renderAdminJwtAuthGuard, RoleGuard, IpThrottlerGuardReportDashboards_READReportDashboardsControllerRun every widget on a dashboard
AdminGET/api/reports/savedAdminJwtAuthGuard, RoleGuardSavedReports_READSavedReportsControllerList my saved reports
AdminPOST/api/reports/savedAdminJwtAuthGuard, RoleGuardSavedReports_CREATESavedReportsControllerSave a report configuration
AdminGET/api/reports/saved/:idAdminJwtAuthGuard, RoleGuardSavedReports_READSavedReportsControllerRead one of my saved reports
AdminPATCH/api/reports/saved/:idAdminJwtAuthGuard, RoleGuardSavedReports_UPDATESavedReportsControllerUpdate a saved report
AdminPOST/api/reports/saved/:id/archiveAdminJwtAuthGuard, RoleGuardSavedReports_UPDATESavedReportsControllerArchive a saved report
AdminPOST/api/reports/saved/:id/restoreAdminJwtAuthGuard, RoleGuardSavedReports_UPDATESavedReportsControllerRestore an archived saved report
AdminDELETE/api/reports/saved/:idAdminJwtAuthGuard, RoleGuardSavedReports_DELETESavedReportsControllerDelete a saved report
AdminGET/api/reports/templatesAdminJwtAuthGuard, RoleGuardReports_READReportTemplatesControllerTemplates I can run, plus system presets
AdminPOST/api/reports/templatesAdminJwtAuthGuard, RoleGuardReportTemplates_CREATEReportTemplatesControllerCreate a school-wide template
AdminGET/api/reports/templates/:idAdminJwtAuthGuard, RoleGuardReports_READReportTemplatesControllerRead one template
AdminPATCH/api/reports/templates/:idAdminJwtAuthGuard, RoleGuardReportTemplates_UPDATEReportTemplatesControllerUpdate a template
AdminDELETE/api/reports/templates/:idAdminJwtAuthGuard, RoleGuardReportTemplates_DELETEReportTemplatesControllerDelete a template
AdminGET/api/reports/schedulesAdminJwtAuthGuard, RoleGuardReportSchedules_READReportSchedulesControllerYour own scheduled reports
AdminGET/api/reports/schedules/recipient-optionsAdminJwtAuthGuard, RoleGuardReportSchedules_CREATEReportSchedulesControllerUsers/roles eligible to receive a given report — declared before :id
AdminPOST/api/reports/schedulesAdminJwtAuthGuard, RoleGuardReportSchedules_CREATEReportSchedulesControllerCreate a scheduled report
AdminGET/api/reports/schedules/:idAdminJwtAuthGuard, RoleGuardReportSchedules_READReportSchedulesControllerOne scheduled report
AdminPATCH/api/reports/schedules/:idAdminJwtAuthGuard, RoleGuardReportSchedules_UPDATEReportSchedulesControllerEdit a scheduled report
AdminPOST/api/reports/schedules/:id/activateAdminJwtAuthGuard, RoleGuardReportSchedules_UPDATEReportSchedulesControllerDraft/paused → active
AdminPOST/api/reports/schedules/:id/pauseAdminJwtAuthGuard, RoleGuardReportSchedules_UPDATEReportSchedulesControllerActive → paused
AdminPOST/api/reports/schedules/:id/archiveAdminJwtAuthGuard, RoleGuardReportSchedules_UPDATEReportSchedulesControllerAny non-archived state → archived
AdminPOST/api/reports/schedules/:id/run-nowAdminJwtAuthGuard, RoleGuardReportSchedules_UPDATEReportSchedulesControllerRun this schedule's report once, right now
AdminDELETE/api/reports/schedules/:idAdminJwtAuthGuard, RoleGuardReportSchedules_DELETEReportSchedulesControllerSoft-delete a schedule (always archived)
AdminGET/api/reports/settingsAdminJwtAuthGuard, RoleGuardReportSettings_READReportSettingsControllerRead report settings
AdminPATCH/api/reports/settingsAdminJwtAuthGuard, RoleGuardReportSettings_UPDATEReportSettingsControllerUpdate report settings
AdminGET/api/reports/jobsAdminJwtAuthGuard, RoleGuardReportExports_READ (own) / ReportJobs_READ (scope=all)ReportJobsControllerList export jobs
AdminPOST/api/reports/jobsAdminJwtAuthGuard, RoleGuardReportExports_CREATEReportJobsControllerRequest a background export
AdminGET/api/reports/jobs/:idAdminJwtAuthGuard, RoleGuardReportExports_READReportJobsControllerOne export job
AdminGET/api/reports/jobs/:id/eventsAdminJwtAuthGuard, RoleGuardReportExports_READReportJobsControllerJob lifecycle history
AdminPOST/api/reports/jobs/:id/cancelAdminJwtAuthGuard, RoleGuardReportExports_UPDATEReportJobsControllerCancel a queued/processing export
AdminPOST/api/reports/jobs/:id/retryAdminJwtAuthGuard, RoleGuardReportExports_UPDATEReportJobsControllerRe-run a failed export
AdminDELETE/api/reports/jobs/:id/artifactAdminJwtAuthGuard, RoleGuardReportExports_UPDATEReportJobsControllerDelete the generated file now
AdminGET/api/reports/jobs/:id/downloadAdminJwtAuthGuard, RoleGuardReportExports_READReportJobsControllerDownload the file
Mobile/portalGET/api/mobile/student/reportsPupilJwtAuthGuard, RoleGuardnone (assertPortalAudience("student"))ReportsStudentPortalControllerThe pupil's own reports
Mobile/portalGET/api/mobile/guardian/children/:studentId/reportsGuardianJwtAuthGuard, RoleGuardnone (assertPortalAudience("guardian"))ReportsGuardianPortalControllerA 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

SurfaceGuard/DecoratorIdentity ShapePermissionNotes
Every admin route in this module@UseGuards(JwtAuthGuard, RoleGuard) (+ IpThrottlerGuard on run/preview/render)req.user: AuthUser, resolved to a ReportPrincipal via ReportPrincipalService.fromRequestRoute-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.scopeKindNoneThe 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:

ModuleGrantsNotes
ReportsReports_READThe gate to the whole area; and-ed with every domain code below on every single report
ReportsSchoolWideReportsSchoolWide_READRows are school-wide rather than limited to the caller's own classes/staff row; also required for the year-closing screen
ReportsContactsReportsContacts_READPhone/email columns/fields in directory reports, exports and the builder
ReportsStudentsReportsStudents_READGates students.* and demographics.*
ReportsAttendanceReportsAttendance_READGates attendance.*
ReportsLeaveReportsLeave_READGates leave.*
ReportsAcademicsReportsAcademics_READGates exams.*
ReportsAssignmentsReportsAssignments_READGates assignments.*
ReportsPromotionsReportsPromotions_READGates promotions.*
ReportsStaffReportsStaff_READGates staff.*, timetable.*
ReportsCommunicationReportsCommunication_READGates notices.*, notifications.delivery
ReportsComplaintsReportsComplaints_READGates complaints.* (the dataset report additionally needs Complaints_READ, the complaints module's own code)
ReportsIdCardsReportsIdCards_READGates id_cards.*
ReportsFinanceReportsFinance_READGates finance.*; individual finance reports additionally require the owning module's own read code (FeeExports_READ, SalaryDashboard_READ, Assets_READ)
SavedReportsSavedReports_READ, SavedReports_CREATE, SavedReports_UPDATE, SavedReports_DELETEA caller's own saved configurations
ReportTemplatesReportTemplates_CREATE, ReportTemplates_UPDATE, ReportTemplates_DELETESchool-wide templates; reading one needs only Reports_READ
ReportBuilderReportBuilder_READGates both builder routes; the dataset chosen inside a builder body decides which domain codes the run additionally needs
ReportExportsReportExports_READ, ReportExports_CREATE, ReportExports_UPDATERequest, list and download your own generated reports
ReportJobsReportJobs_READ, ReportJobs_UPDATESee 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)
ReportSchedulesReportSchedules_READ, ReportSchedules_CREATE, ReportSchedules_UPDATE, ReportSchedules_DELETEScheduled-report CRUD and lifecycle, all owner-scoped
ReportDashboardsReportDashboards_READ, ReportDashboards_CREATE, ReportDashboards_UPDATE, ReportDashboards_DELETEPersonal dashboards; _READ also shows school dashboards
ReportDashboardsShared(create/edit school-scope dashboards)Required in addition to ReportDashboards_CREATE/_UPDATE when scope: "school"
ReportSettingsReportSettings_READ, ReportSettings_UPDATEThe 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)

FieldTypeRequiredDefaultValidationSource
reportKeystringYes—@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
filtersobject (Record<string, unknown>)No{}@IsObject(); keys validated against the report's own filters allow-list at runtimereport-result.dto.ts
columnsstring[]Nothe report's defaultColumns@ArrayMaxSize(60), each @MaxLength(60)report-result.dto.ts
sort{ key: string; dir: "asc"|"desc" }Nonullkey ≤60 chars; key must be in the report's sortable listreport-result.dto.ts
pagenumberNo1@Min(1) @Max(10000)report-result.dto.ts
sizenumberNo25@Min(1) @Max(100)report-result.dto.ts
refreshbooleanNofalseBypasses the cache; rate-limited to once per 10s per report per userreport-result.dto.ts
builderobjectNo—Required when reportKey is builder.custom; validated against resolveBuilderQueryreport-result.dto.ts

6.2 ReportResultDto (response of POST /reports/run, POST /reports/builder/preview, and the value inside every dashboard/profile/portal section)

FieldTypeNotes
reportKeystring
title, category, kindstringkind is "analytic" or "dataset"
generatedAtISO string
cachedbooleanWhether this response came from the Redis cache-aside layer
contextobjectReportResultContext: sessions, dateFrom/To, dateFrom/ToBs, compareFrom/To, period, anchorDate, population, filters (label/value pairs), scopeLimited, minGroupSize
summaryReportKpiDto[]KPI cards
chartsReportChartDto[]Zero or more charts
tableReportTableDtoExactly one table
warningsReportWarningDto[]See the fixed warning-code catalogue in the backend doc
metricsstring[]Keys into GET /reports/metrics used by this result

ReportKpiDto

FieldTypeNotes
key, labelstring
valuenumber | nullnull when there is nothing to compute (e.g. zero denominator)
unitenumcount, percent, paisa, days, hours, marks, gpa, ratio
metricKeystring | nullLinks to METRIC_DEFINITIONS when this KPI is a defined metric
suppressedboolean
comparison{ value, label } | nullOptional secondary value (e.g. previous period)

ReportChartDto

FieldTypeNotes
key, titlestring
typeenumbar, stacked_bar, line, scatter, donut
xKey, xLabelstring
series{key,label,unit}[]One or more series
dataRecord<string, string|number|null>[]Row-shaped chart data (the same rows a table would carry)
notestring | nullFree-text caveat shown under the chart

ReportTableDto

FieldTypeNotes
columnsReportTableColumn[]{key,label,type,enumKey,sortable,drill}
rowsRecord<string, unknown>[]
totalsobject or nullA synthetic totals row, when the report computes one
page{page,size,count} | nullPresent only on paginated (dataset) reports
suppressedCells{row,key}[]Cells blanked by small-group suppression

6.3 ReportCatalogItemDto (one entry of GET /reports/catalog)

FieldTypeNotes
key, title, description, categorystringcategory is one of the 19 ReportCategory values (§7.1)
kindenumanalytic | dataset
filtersstring[]Every filter key this report accepts
requiredFiltersstring[]Filters that must be present before it can run
columnsarray{key,label,type,enumKey,contact}[] — a contact column is omitted from defaultColumns and only listed here if the caller holds ReportsContacts_READ
defaultColumnsstring[]
sortable, groupablestring[]
metricsstring[]Keys into the metric catalogue
exportFormatsstring[]Subset of csv/xlsx/pdf
heavybooleanA heavy report over more than one academic session is refused live (409 REPORT_HEAVY_USE_EXPORT) — export it instead
cacheScopeenumshared | principal
scopeModeenumschool_only, teaching, self_staff, teaching_and_self_staff, unscoped
presetsarraySystem-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)

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

FieldTypeRequiredValidation
reportKeystringYesREPORT_KEY_PATTERN, ≤80 chars — accepts an ordinary report key, builder.custom, or package.annual_academic / package.custom
configReportConfigDtoYesNested-validated; carries filters/columns/sort/groupBy/chart/builder/reports (the last only for a custom package)
formatenumYescsv | xlsx | pdf — packages only accept xlsx/pdf
titlestringNo≤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)

FieldTypeDefaultNotes
scope"mine" | "all""mine""all" needs ReportJobs_READ (or superadmin)
statuscomma-separated ReportJobStatus[]—Any of queued, processing, completed, completed_with_warnings, failed, cancelled, expired
origin"manual" | "schedule"—
scheduleIduuid—Filters to one schedule's jobs
pagenumber11–10000
sizenumber201–100

6.8 ReportJobDto (one job, returned by list/get/create/cancel/retry/delete/run-now)

FieldTypeNotes
iduuidThe job's public_id — the only id ever exposed
reportKey, titlestring
formatenumcsv|xlsx|pdf
originenummanual|schedule
scheduleIduuid | nullThe owning schedule's public id
statusenumThe seven REPORT_JOB_STATUSES
progressPercentnumber0–100, throttled server-side to one write per 5s during generation
rowCountnumber | null
warnings{code,message,count}[]
filterSummarystringNever contains a person's name — studentId/staffId/q labels are stripped
requestedBy{id,name} | null
createdAt, startedAt, finishedAt, expiresAtISO string or null
errorCategory, errorMessagestring | nullerrorMessage is always one of six fixed sanitised sentences, never a raw exception
retryablebooleantrue only if the caller can manage this job, it is failed, and retryCount < 10
cancellablebooleantrue only if manageable and queued/processing
downloadablebooleantrue only if completed, not expired, an artifact exists, and canDownload() passes for the current caller
downloadCount, attemptCount, retryCountnumber

6.9 ReportJobEventDto (one entry of GET /reports/jobs/:id/events)

FieldTypeNotes
eventstringOne of the 13 REPORT_JOB_EVENTS enum values
actorNamestring | nullnull for system-triggered events (worker/sweep), a name for user actions
detailobjectFree-form, ≤4 KiB, e.g. {category, attempt} for a failure
createdAtISO string

6.10 BuilderDatasetDto (one entry of GET /reports/builder/datasets)

FieldTypeNotes
key, labelstring
scopeModeenumschool_only, teaching, self_staff, teaching_and_self_staff, unscoped
fieldsBuilderFieldDto[]{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)

FieldTypeRequiredValidation
datasetstringYes≤60 chars — must name a dataset the caller may query
columnsstring[]Yes1–BUILDER_MAX_COLUMNS entries, each ≤60 chars
filtersBuilderFilterDto[]No≤BUILDER_MAX_FILTERS; {field, op, value?} — value required for every op except is_null/not_null
groupBystring[]No≤BUILDER_MAX_GROUP_BY
aggregatesBuilderAggregateDto[]No≤BUILDER_MAX_AGGREGATES; {fn, field?} — field required unless fn is count
sort{key, dir}No
pagenumberNodefault 1
sizenumberNodefault 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

EvidencePath
Controllerapps/api/src/modules/reports/catalog/report-catalog.controller.ts:46
Serviceapps/api/src/modules/reports/catalog/report-catalog.service.ts
DTOapps/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

PartRequiredDetails
QueryNoq (≤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 TOKEN

Response

{
  "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 StatusError CodeCondition
401—No/invalid token
403AUTH_ACTIVE_ROLE_REQUIREDNo active role on the session

Edge Cases

  • No reports match the filters → data: [], still success: true.
  • A caller with only Reports_READ and 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

EvidencePath
Controllercatalog/report-catalog.controller.ts (ReportRunController)
DTOshared/dto/report-result.dto.ts (RunReportDto, ReportResultDto)
Servicecatalog/report-run.service.ts
Authorisershared/report-authorizer.service.ts
Cacheshared/report-cache.service.ts
Queryshared/report-query.service.ts

Auth and Permissions

  • Auth: Bearer JWT
  • Guard chain: JwtAuthGuard, RoleGuard, IpThrottlerGuard
  • Permission: Reports_READ plus every code in the target report's requires
  • 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 a report_settings.version bump) is the only eviction.
  • Database: one REPEATABLE READ, READ ONLY transaction per uncached run.
  • No jobs, no notifications, no realtime events, no audit record.

Error Cases

HTTP StatusError CodeCondition
400REPORT_FILTER_INVALIDUnknown filter key, malformed value, unknown column/sort key, >60 columns
400REPORT_DATE_RANGE_INVALIDMissing half of a range, reversed, not real, too long, invalid BS date
400REPORT_PERIOD_INVALIDUnknown period, combined with dates/session, unresolvable
400REPORT_TOO_MANY_SESSIONS>5 sessionIds
400REPORT_REQUIRED_FILTER_MISSINGA requiredFilters entry absent (e.g. examId on exams.results)
400REPORT_BUILDER_INVALIDbuilder.custom with a missing or invalid builder body
403REPORT_DOMAIN_FORBIDDENMissing a domain permission
403REPORT_SCOPE_FORBIDDENSchool-wide report without ReportsSchoolWide_READ, or a filter outside teaching scope
403REPORT_COLUMN_FORBIDDENA contact column/sort requested without ReportsContacts_READ
404REPORT_NOT_FOUNDUnknown reportKey
404REPORT_REFERENCE_NOT_FOUNDA filter's public id does not exist
409REPORT_HEAVY_USE_EXPORTA heavy report requested live across >1 session
429REPORT_REFRESH_RATE_LIMITEDSecond refresh: true within 10s
429— (IpThrottlerGuard)>60 requests/minute
503REPORT_BUSYConnection semaphore exhausted
503REPORT_QUERY_TIMEOUTStatement timeout or 15s live deadline exceeded

Edge Cases

  • Empty result set → table.rows: [], warnings may include NO_SOURCE_ROWS or SOURCE_NOT_CONFIGURED.
  • A small demographic group → cells blanked, suppressedCells populated, GROUPS_SUPPRESSED warning present.
  • attendance.daily requested over more than 31 days → 400 REPORT_DATE_RANGE_INVALID (its own live-only span cap).
  • leave.balances live without exactly one leaveTypeIds → 400 REPORT_REQUIRED_FILTER_MISSING.
  • attendance.class_summary returns seven summary KPIs. 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 or null; the percent unit is 0–100; no small-group suppression applies to them (suppressed is always false). 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 stays 0 and all stays 100.
keyUnitMeaning
rate, absenceRate, lateRatepercentPresent or late, absent and late, each as a share of marked pupil records on required days.
markedRecordCountcountThe marked pupil records those rates are computed over — a count of pupil records, not of registers.
markedCountcountRegisters 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.
expectedCountcountRegisters 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.
coverageRatePctpercentmarkedCount 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.

keyUnitMeaning
ratePctpercentPresent or late, as a share of marked pupil records. Untaken registers do not enter it.
absenceRatePct, lateRatePct, excusedRatePctpercentAbsent, late and excused, each as a share of marked pupil records.
markedCountcountRegisters 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.
expectedCountcountRegisters 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.
coverageRatePctpercentmarkedCount 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 StatusError CodeCondition
403REPORT_SCOPE_FORBIDDENCaller 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 sessionId cannot select a different one — a mismatch is 400 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 StatusError CodeCondition
400REPORT_FILTER_INVALIDClass profile: sessionId does not match the class's own session
404REPORT_PROFILE_NOT_FOUNDTarget 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 by resolveBuilderQuery and checked exactly like ReportDefinition.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 StatusError CodeCondition
400REPORT_BUILDER_INVALIDUnknown 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
403REPORT_SCOPE_FORBIDDENA school_only dataset without ReportsSchoolWide_READ
403REPORT_COLUMN_FORBIDDENA contact field referenced without ReportsContacts_READ

Edge Cases

  • A group whose row count falls under report_settings.min_group_size is dropped from the result entirely (not blanked) — the builder has no fixed column set to blank a cell in.
  • Sort tiebreak and GROUP BY are 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 StatusError CodeCondition
404REPORT_DASHBOARD_NOT_FOUNDUnknown id, or not visible
409REPORT_DASHBOARD_NAME_TAKENName collision within the same scope
403REPORT_DASHBOARD_SCOPE_FORBIDDENSchool-scope create/edit without the shared code
409REPORT_DASHBOARD_LIMIT_REACHEDPersonal/school dashboard cap reached
409REPORT_VERSION_CONFLICTexpectedVersion 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 StatusError CodeCondition
404REPORT_SAVED_NOT_FOUND / REPORT_TEMPLATE_NOT_FOUNDUnknown id, wrong owner (saved), or wrong kind
409REPORT_SAVED_NAME_TAKEN / REPORT_TEMPLATE_NAME_TAKENCase-insensitive name collision within scope
409REPORT_SAVED_LIMIT_REACHEDCaller already has 100 live saved reports
409REPORT_SAVED_STATE_INVALIDe.g. restoring a report that is not archived
409REPORT_VERSION_CONFLICTexpectedVersion 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 StatusError CodeCondition
404REPORT_SCHEDULE_NOT_FOUNDUnknown id, or not owned by the caller
400REPORT_SCHEDULE_INVALIDBad config, or a cadence field missing/contradictory
400REPORT_SCHEDULE_RECIPIENTS_INVALIDA named recipient is not eligible
409REPORT_SCHEDULE_TRANSITION_INVALIDe.g. activating an already-archived schedule
409REPORT_SCHEDULE_LIMIT_REACHEDPer-owner schedule cap reached
409REPORT_VERSION_CONFLICTexpectedVersion 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 StatusError CodeCondition
400REPORT_SETTINGS_INVALIDA field fails its range check
409REPORT_VERSION_CONFLICTexpectedVersion stale
503REPORT_SETTINGS_MISSINGThe 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_jobs row inserted inside a transaction holding pg_advisory_xact_lock(hashtext('report_jobs:{userId}')), with an outbox_events row.
  • Outbox → BullMQ: relayed to QueueName.REPORTS as reports.generate.
  • No file exists yet — poll GET /reports/jobs/:id or wait for the report.ready notification.

Error Cases

HTTP StatusError CodeCondition
400REPORT_CONFIG_INVALIDMalformed Idempotency-Key
400REPORT_EXPORT_UNSUPPORTEDFormat not supported by this report/package
400REPORT_CONFIG_INVALIDA 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
403REPORT_DOMAIN_FORBIDDENNo part of a package could be authorised (empty result)
409REPORT_JOB_IDEMPOTENCY_CONFLICTSame key, different body
429REPORT_JOB_LIMIT_REACHED5 active (queued/processing) jobs already

Edge Cases

  • package.annual_academic silently omits parts the caller cannot see and adds a PACKAGE_PARTS_OMITTED warning 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, every required_codes still held, contacts code if includesContacts, and ReportsSchoolWide_READ if generatedScope === "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 StatusError CodeCondition
404REPORT_JOB_NOT_FOUNDNot visible, or canDownload() fails — same response as "does not exist"
410REPORT_JOB_EXPIREDStatus expired, or past expiresAt
409REPORT_JOB_NOT_READYNot 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 StatusError CodeCondition
403(audience assertion)Caller's activeRole.scopeKind is not student
404STUDENT_NOT_FOUNDNo 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)

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
POST /reports/run, POST /reports/builder/preview (dataset kind)page/size (in body)25100Per-report/dataset sortable listThe report's or dataset's own allow-listpage up to 10000
GET /reports/jobspage/size (PaginationUtil)20100none (fixed: createdAt DESC, id DESC)status, origin, scheduleId, scopenone beyond page size
GET /reports/jobs/:id/eventsnone——none (fixed chronological)none500 events
GET /reports/savedpage/size20100noneincludeTemplates (appends, unpaginated)none
GET /reports/schedulespage/size20100nonestatus (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

IntegrationUsed?DetailsSource
Redis cacheYesreports:v1:{key}:{hash}:{scope}:s{version}, 60–120s TTL, cache-aside, fail-open; shared by run, builder preview, dashboard widgets, profile/overview sectionsshared/report-cache.service.ts
Redis rate limiterYesreports:refresh:{userId}:{reportKey}, SET NX EX 10, fail-openshared/report-cache.service.ts
BullMQYesQueueName.REPORTS (generation, concurrency 1), QueueName.REPORTS_SWEEP (schedule + maintenance, concurrency 1)packages/jobs/src/index.ts, jobs/workers/*
Object storageYesUploadType.REPORT_ARTIFACT (report-artifact prefix), staged then moved via StorageManagerjobs/generation/report-generation.service.ts
External APINoNothing outside the platform is called—

12. Consumer Integration Notes

ConsumerRequired KnowledgeFailure HandlingContract Stability
Admin panelReport 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 portalThe 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.
QASeed 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 serviceNone of this module is called internally by another module today (no cross-module dependency on Reports).——

13. API Change Impact

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?
Adding a report key to a domain classAdmin panel catalog picker (auto-discovers it)None to existing routesNoneNo — additive
Adding a column/filter to an existing reportAny saved report/schedule/dashboard referencing that key by its config.columns/config.filtersThe authoriser is forward-compatible with unknown-but-now-known keys; a removed column referenced by an old saved config produces the COLUMNS_DROPPED warning pathNone automaticNo
Adding a builder datasetGET /reports/builder/datasets (auto-discovers it)NoneNoneNo

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

On this page

Reports & Analytics - API Reference1. Documentation Evidence2. Module Summary3. Concepts and Terminology4. API Surface Map5. Auth, Identity, and Permissions6. DTO and Model Reference6.1 RunReportDto (body of POST /reports/run)6.2 ReportResultDto (response of POST /reports/run, POST /reports/builder/preview, and the value inside every dashboard/profile/portal section)ReportKpiDtoReportChartDtoReportTableDto6.3 ReportCatalogItemDto (one entry of GET /reports/catalog)6.4 MetricDefinitionDto (one entry of GET /reports/metrics)6.5 FilterOptionsDto (response of GET /reports/filter-options)6.6 CreateReportJobDto (body of POST /reports/jobs)6.7 ListReportJobsQueryDto (query of GET /reports/jobs)6.8 ReportJobDto (one job, returned by list/get/create/cancel/retry/delete/run-now)6.9 ReportJobEventDto (one entry of GET /reports/jobs/:id/events)6.10 BuilderDatasetDto (one entry of GET /reports/builder/datasets)6.11 BuilderQueryDto (body of POST /reports/builder/preview)6.12 Dashboard DTOs6.13 Saved report and template DTOs6.14 Schedule DTOs6.15 Settings DTOs6.16 Profile and portal DTOs7. Enum Reference7.1 ReportCategory (19 values, shared/report-types.ts)7.2 REPORT_FORMATS7.3 REPORT_JOB_STATUSES (state machine)7.4 ReportColumnType7.5 REPORT_SCHEDULE_STATUSES / REPORT_SCHEDULE_FREQUENCIES / REPORT_SCHEDULE_ATTENTION_REASONS7.6 DASHBOARD_WIDGET_VIEWS (9 values)7.7 The Central Metric Catalogue (GET /reports/metrics)8. Endpoint Reference8.1 GET /api/reports/catalogPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge Cases8.2 GET /api/reports/metrics8.3 GET /api/reports/filter-options8.4 POST /api/reports/runPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.5 GET /api/reports/overviewPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError Cases8.6 GET /api/reports/year-closingPurposeSource EvidenceAuth and PermissionsRequestResponseError Cases8.7 GET /api/reports/profiles/students/:studentId, /classes/:classId, /teachers/:staffIdPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError Cases8.8 GET /api/reports/builder/datasets8.9 POST /api/reports/builder/previewPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge Cases8.10 Report Dashboards (/api/reports/dashboards)8.11 Saved Reports (/api/reports/saved) and Templates (/api/reports/templates)8.12 Report Schedules (/api/reports/schedules)8.13 Report Settings (/api/reports/settings)8.14 GET /api/reports/jobs8.15 POST /api/reports/jobsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge Cases8.16 GET /api/reports/jobs/:id8.17 GET /api/reports/jobs/:id/events8.18 POST /api/reports/jobs/:id/cancel8.19 POST /api/reports/jobs/:id/retry8.20 DELETE /api/reports/jobs/:id/artifact8.21 GET /api/reports/jobs/:id/downloadPurposeAuth and PermissionsResponseSide EffectsError Cases8.22 GET /api/mobile/student/reportsPurposeSource EvidenceAuth and PermissionsResponseError CasesEdge Cases8.23 GET /api/mobile/guardian/children/:studentId/reports9. Flow Diagrams9.1 Route Ownership9.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 Search11. Caching, Jobs, and External Integrations12. Consumer Integration Notes13. API Change ImpactZero-Omission API ChecklistSee Also