Skoolsewa - School Management System Docs
Developer ResourcesReports

Reports & Analytics Features and Flows

Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Reports & Analytics.

Reports & Analytics — Features and Flows

1. Documentation Evidence

Source TypeFiles or DocsWhat Was Extracted
Backend doc/docs/developer/reports/backendService behavior, persistence, cache, jobs, state transitions
API doc/docs/developer/reports/apiRoute surface, actors, auth
Domain report filesapps/api/src/modules/reports/domains/*/*.reports.ts, builder/builder.reports.tsEvery registered report key, scoped to each domain and read this session
ControllersAll twelve controller files under modules/reports/Every live route — builder, dashboards, saved reports, templates, schedules, settings, overview, profiles, portal all have real controllers today

2. Feature Summary

FieldValue
ModuleReports & Analytics
Primary user valueA school administrator, department head or teacher answers a question about the school — attendance, results, promotions, staffing, ID cards, finance, complaints, communication — without asking another department to run a query; builds their own dashboards and custom queries; schedules recurring exports so a report simply arrives; and a pupil or guardian can see the child's own record without going through an admin at all.
ActorsAdmin-panel users (superadmin, admin, department heads, teachers holding domain-specific report codes); background workers (schedule sweep, export generation, maintenance); pupils and guardians (portal, identity-only)
Main entry pointsThe report catalog and live-run screen; the custom builder; dashboards; saved reports and templates; scheduled reports; settings; the cross-domain overview and year-closing screens; student/class/teacher profiles; the export-jobs screen; two cron ticks (schedule sweep every minute, maintenance every 15 minutes); the pupil/guardian portal screen
Main outputsOn-screen KPIs/charts/tables; downloadable CSV/XLSX/PDF files; in-app and email notifications when a background export finishes or fails
Related docsAPI, Backend

3. Actor Matrix

ActorCan DoCannot DoAuth Requirement
SuperadminRun and export every report in every scope; manage every dashboard/schedule/template; see and manage every user's export jobs—JWT + active role (superadmin bypasses every permission check)
Admin / department head holding Reports_READ + a domain code + ReportsSchoolWide_READRun/export that domain's reports school-wide; build custom queries on that domain's datasets; author school-wide templates and shared dashboardsReports whose domain code they lack (never see them in the catalog/builder at all)JWT + active role + the listed codes
Teacher holding Reports_READ + a domain code, no ReportsSchoolWide_READRun/export that domain's reports limited to their own teaching classes (or their own staff record for staff/self-scoped reports); build custom queries scoped the same wayWiden a filter beyond their teaching scope (403), see school-wide totalsJWT + active role + the listed codes
A caller without ReportsContacts_READEverything above, minus phone/email columns/fields on directory reports and the builderRequest a contact column/field or sort by one (403 if explicitly requested)as above
Dashboard ownerCreate/edit/delete their own personal dashboards; read (never edit) school dashboards without ReportDashboardsSharedAuthor a school-scope dashboard without the shared codeReportDashboards_*
Saved-report ownerSave, edit, archive, restore, delete their own configurations, up to 100 liveEdit or see another user's saved reportsSavedReports_*
Template author (ReportTemplates_*)Create/edit/delete school-wide templates—ReportTemplates_*; reading any template needs only Reports_READ
Schedule ownerCreate, edit, activate/pause/archive, run-now, and delete their own scheduled reports; pick eligible recipientsSchedule a recipient who could not legitimately see the report (refused up front)ReportSchedules_*
Settings adminRead/update the one report_settings row—ReportSettings_*
Export-job requesterCreate, list, cancel, retry, delete and download their own jobsDownload a job generated in another person's teaching/principal scopeReportExports_CREATE/_READ/_UPDATE
Export-job recipient (schedule delivery)See a job they are a recipient of; download it only if it is not requester-only-scoped and they still hold every code it neededCancel/retry someone else's job; download a teaching-scoped job that isn't theirsReportExports_READ
Operator holding ReportJobs_READ/_UPDATESee and cancel/retry every user's export jobs (operational oversight)Download a job that isn't theirs or a recipient's — visibility and download entitlement are separateReportJobs_READ/_UPDATE
Background worker (schedule sweep, generation, maintenance)Turn a due schedule into a job; generate a job's file; expire/purge/recover jobsBypass the same authorisation a live run would face — every background run re-authorises against the requester's current permissionsInternal (no HTTP identity)
Pupil (portal)See their own placements, attendance, results, assignments and promotionsSee any other pupil's data, or anything permission-gatedJWT + active role, audience = student, no permission code
Guardian (portal)See their own child's same five sectionsSee any child that is not theirsJWT + active role, audience = guardian, no permission code

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
Browse available reportsAdminAny with Reports_READGET /reports/catalogRegistry + permissionsnoneAPI §8.1
Read the metric glossaryAdminAny with Reports_READGET /reports/metricsStatic cataloguenoneAPI §8.2
Populate the filter barAdminAny with Reports_READGET /reports/filter-optionsReference tables, teaching scopenoneAPI §8.3
Run a report liveAdminDomain-code holderPOST /reports/runDomain tables (one snapshot)Redis cache (write)API §8.4
Bypass the cacheAdminDomain-code holderPOST /reports/run with refresh: trueas aboveRedis cache + rate-limit keyAPI §8.4
See the cross-domain overviewAdminAny with Reports_READGET /reports/overviewEvery domain's summary tablesnoneAPI §8.5
Review year-closing checksAdmin+ ReportsSchoolWide_READGET /reports/year-closingEvery domain's summary tablesnoneAPI §8.6
Open a student/class/teacher profileAdminAny with Reports_READGET /reports/profiles/...Several underlying reportsnoneAPI §8.7
Discover buildable datasetsAdminReportBuilder_READGET /reports/builder/datasetsRegistry + permissionsnoneAPI §8.8
Preview a custom queryAdminReportBuilder_READPOST /reports/builder/previewThe chosen dataset's tablesRedis cacheAPI §8.9
List/create/edit/delete a dashboardAdminReportDashboards_*/reports/dashboards*report_dashboardsinsert/update/soft-deleteAPI §8.10
Render a dashboardAdminReportDashboards_READPOST /reports/dashboards/:id/renderEvery widget's underlying reportRedis cache (per widget)API §8.10
Save/edit/archive/restore/delete a saved reportAdminSavedReports_*/reports/saved*report_definitions (kind=saved)insert/update/soft-deleteAPI §8.11
Create/edit/delete a school templateAdminReportTemplates_*/reports/templates*report_definitions (kind=template)insert/update/soft-deleteAPI §8.11
Create/edit/activate/pause/archive/run-now/delete a scheduleAdminReportSchedules_*/reports/schedules*report_schedules(+recipients)insert/update/soft-delete, report_jobs insert (run-now)API §8.12
Read eligible recipientsAdminReportSchedules_CREATEGET /reports/schedules/recipient-optionsUsers, roles, canDownload predicatenoneAPI §8.12
Read/update report settingsAdminReportSettings_*/reports/settingsreport_settingsupdate + version bumpAPI §8.13
Request a background exportAdminReportExports_CREATE holderPOST /reports/jobsas abovereport_jobs row, outbox_events rowAPI §8.15
Request a multi-report packageAdminReportExports_CREATE holderPOST /reports/jobs with package.* keyas above, per partreport_jobs rowAPI §8.15
Track an export's progressAdminRequester/recipient/ReportJobs_READGET /reports/jobs, GET /reports/jobs/:idreport_jobsnoneAPI §8.14, §8.16
See why an export failedAdminRequester/recipient/ReportJobs_READGET /reports/jobs/:id/eventsreport_job_eventsnoneAPI §8.17
Cancel a running exportAdminRequester/ReportJobs_UPDATEPOST /reports/jobs/:id/cancelreport_jobsreport_jobs.statusAPI §8.18
Retry a failed exportAdminRequester/ReportJobs_UPDATEPOST /reports/jobs/:id/retryreport_jobsreport_jobs.status, retry_count, new outbox eventAPI §8.19
Delete a file earlyAdminRequester/ReportJobs_UPDATEDELETE /reports/jobs/:id/artifactreport_jobsreport_jobs.status → expired, storage deleteAPI §8.20
Download a finished exportAdminRequester or eligible recipientGET /reports/jobs/:id/downloadreport_jobs, storagedownload_count, event, audit recordAPI §8.21
See my own reportsPupilSelf (audience student)GET /api/mobile/student/reportsOwn attendance/results/assignments/promotionsnoneAPI §8.22
See my child's reportsGuardianSelf (audience guardian)GET /api/mobile/guardian/children/:id/reportsChild's attendance/results/assignments/promotionsnoneAPI §8.23
Automatic scheduled exportWorkerSchedule sweepCron, every minutereport_schedulesreport_jobs row, report_schedules.next_run_at/last_run_atBackend §7.5
Automatic file expiryWorkerMaintenance sweepCron, every 15 minutesreport_jobsstatus → expired, artifact_key → nullBackend §7.6
Automatic orphan cleanupWorkerMaintenance sweepCron, every 15 minutesreport_job_objectsrow deleted, storage object deletedBackend §7.6
Automatic history purgeWorkerMaintenance sweepCron, every 15 minutesreport_jobsrow deleted (once no objects remain)Backend §7.6
Automatic lost-delivery recoveryWorkerMaintenance sweepCron, every 15 minutesreport_jobsstatus → queued, recovery_count incrementedBackend §7.6
Automatic schedule pause on repeated failureWorkerSchedule sweep / generationOn the Nth consecutive failurereport_schedules, report_settings.schedule_failure_alert_thresholdstatus → paused, notificationBackend §7.5/§10

5. User-Facing Flows

5.1 Browsing and running a report live

Summary

An admin-panel user opens the Reports area, picks a report from the catalog (which only ever lists what they are entitled to run), sets filters, and sees KPIs, charts and a table on screen within a couple of seconds.

Preconditions

  • An active role is selected on the session.
  • The caller holds Reports_READ and the report's domain code(s).
  • For a teaching-scoped report, the caller has at least one class they teach (otherwise the report legitimately returns nothing, not an error).

Main Flow

StepActor/SystemActionResultSource
1UserOpens Reports, browses the catalogGET /reports/catalog returns only runnable reportsAPI §8.1
2UserOpens GET /reports/filter-options implicitly (page load)Session/grade/class/subject/etc. options populate, classes narrowed to teaching scopeAPI §8.3
3UserChooses filters, clicks "Run"POST /reports/runAPI §8.4
4BackendAuthorises, resolves filters, checks cacheCache hit → instant; miss → computesBackend §7.1–7.2
5BackendRuns the provider inside one read-only snapshotKPIs, charts, table, warnings assembledBackend §6.4
6UserReads the result; optionally drills into a linked report, opens a profile, or clicks "Export"See Flows 5.2/5.3—

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
No teaching assignmentA teacher with no class this session runs a teaching-scoped reportEmpty result, appropriate warning200, empty table
Report is heavy and >1 session selectede.g. comparing three academic years liveRefused; the user must export instead409 REPORT_HEAVY_USE_EXPORT
Cache hitIdentical request within the TTLcached: true, no database read200
Second refresh within 10sUser clicks "refresh" twice quicklyRefused429 REPORT_REFRESH_RATE_LIMITED
Database under loadSemaphore or timeout exhaustedRefused with a message suggesting export503 REPORT_BUSY / REPORT_QUERY_TIMEOUT
Small demographic groupA gender/ethnicity/mother-tongue/disability/age cell has fewer than min_group_size peopleCell blanked, suppression warning200, suppressedCells populated

5.2 Composing a custom report with the builder

Summary

A user who needs a cut of the data no catalog report provides picks a dataset (students, attendance, academics, leave, or one of the school-only datasets), chooses columns, filters, optional grouping and aggregates, and previews the result live before saving or scheduling it.

Preconditions

  • The caller holds ReportBuilder_READ plus the domain codes of every dataset they want to query.

Main Flow

StepActor/SystemActionResultSource
1UserOpens the builderGET /reports/builder/datasets returns queryable datasets and fieldsAPI §8.8
2UserPicks a dataset, columns, filters, groupingClient validates shape only—
3UserClicks "Preview"POST /reports/builder/previewAPI §8.9
4BackendValidates the body against the dataset's frozen field list, authorises, runsSame authorise → cache → query path as any reportBackend §6.9
5UserSaves the query as a saved report, or schedules itPOST /reports/saved / POST /reports/schedules with reportKey: "builder.custom" and the query in config.builderFlows 5.4/5.5

Branches and Edge Cases

BranchConditionBehaviorError/Result
Aggregate-only field used as a plain columne.g. a running total outside aggregatesRefused400 REPORT_BUILDER_INVALID
Demographic field in a row filtere.g. filtering individual rows by ethnicityRefused — filtering, not grouping, on identity re-identifies individuals400 REPORT_BUILDER_INVALID
Identifying field grouped alongside a demographic fielde.g. grouping by name and ethnicity togetherRefused — defeats suppression400 REPORT_BUILDER_INVALID
Small group in the resultA group's row count is under min_group_sizeThe whole group is dropped, not blanked200, fewer rows than expected
school_only dataset, non-school-wide callere.g. staff distribution as a builder datasetDataset not offered at all in datasets()Absent from GET /reports/builder/datasets

5.3 Building and rendering a dashboard

Summary

A user arranges up to 24 widgets — reports, an alerts feed, or shortcuts to other screens — on a personal or (with the shared code) school-wide dashboard, then renders it in one call.

Main Flow

StepActor/SystemActionResultSource
1UserCreates a dashboard, adding widgets with a grid position eachPOST /reports/dashboardsAPI §8.10
2UserOpens the dashboardPOST /reports/dashboards/:id/render with any filter overridesAPI §8.10
3BackendRuns every non-shortcut widget concurrently (bounded), each through the normal report pathPer-widget result or errorBackend §6.10
4UserClicks a shortcut widgetClient navigates to the widget's stored target (a report, saved report, or template)—

Branches and Edge Cases

BranchConditionBehaviorError/Result
One widget's report fails (timeout, forbidden)e.g. a permission revoked since the widget was addedThat widget's tile shows its own errorThe rest of the dashboard still renders
School-scope dashboard, caller lacks the shared codeTrying to create/edit oneRefused403 REPORT_DASHBOARD_SCOPE_FORBIDDEN
Reading someone else's school dashboardCaller holds ReportDashboards_READ but not the shared codeVisible, editable: false200
Deleting with a stale expectedVersionSomeone else edited the dashboard firstRefused409 REPORT_VERSION_CONFLICT (only if expectedVersion was supplied — it is optional on delete)
Name collision within scopeTwo personal dashboards, same owner, same nameRefused409 REPORT_DASHBOARD_NAME_TAKEN

5.4 Saving a report configuration, and school-wide templates

Summary

Rather than re-entering the same filters every time, a user saves a report's current configuration under a name. A user with template authority can instead publish the same shape as a school-wide template every teacher with the right codes can run.

Main Flow

StepActor/SystemActionResultSource
1UserRuns a report, clicks "Save"POST /reports/savedAPI §8.11
2UserReopens it laterGET /reports/saved/:id, or lists via GET /reports/savedAPI §8.11
3UserEdits itPATCH /reports/saved/:id with the current versionAPI §8.11
4UserNo longer needs it but wants to keep historyPOST /reports/saved/:id/archiveAPI §8.11
5UserChanges their mindPOST /reports/saved/:id/restoreAPI §8.11
6 (template author)UserPublishes the same configuration for everyonePOST /reports/templatesAPI §8.11

Branches and Edge Cases

BranchConditionBehaviorError/Result
Name already used (same owner, same kind, live)Case-insensitive collisionRefused409 REPORT_SAVED_NAME_TAKEN / REPORT_TEMPLATE_NAME_TAKEN
100 saved reports alreadyTrying to save a 101stRefused409 REPORT_SAVED_LIMIT_REACHED
Stale editSomeone/something else changed it first (two tabs)Refused409 REPORT_VERSION_CONFLICT
A permission was later revokedListing a saved report/template whose codes the caller no longer holdsStill listedrunnable: false, not deleted
Restoring a report that isn't archivedWrong state for the actionRefused409 REPORT_SAVED_STATE_INVALID

5.5 Scheduling a recurring export

Summary

A user picks a report (or a saved report) and a cadence (daily/weekly/monthly/monthly BS), names recipients (specific people and/or whole roles), and the schedule then runs unattended — no admin ever needs to remember to generate it again.

Preconditions

  • The caller holds ReportSchedules_CREATE and every code the target report needs.

Main Flow

StepActor/SystemActionResultSource
1UserOpens the recipient pickerGET /reports/schedules/recipient-options?reportKey=...API §8.12
2UserCreates the schedule with cadence + recipientsPOST /reports/schedulesAPI §8.12
3BackendValidates config, cadence fields, and every recipient's eligibilityRefused up front if any is ineligibleBackend §6.12
4User (optional)Wants an immediate copy without waiting for the next slotPOST /reports/schedules/:id/run-nowAPI §8.12
5CronThe schedule's own cadence fires it automaticallySee Flow 5.7Backend §7.5
6UserPauses, reactivates, or archives it laterPOST .../pause / .../activate / .../archiveAPI §8.12

Branches and Edge Cases

BranchConditionBehaviorError/Result
Named recipient cannot legitimately see the reporte.g. a role with no matching teaching assignmentRefused at create/update time, before the row is written400 REPORT_SCHEDULE_RECIPIENTS_INVALID
Weekly cadence with no weekdayMissing the field the frequency requiresRefused400 REPORT_SCHEDULE_INVALID
Run-now on a paused scheduleValid — run-now does not require activeCreates a manual-origin job immediately202 Accepted
Activating an already-archived scheduleInvalid transitionRefused409 REPORT_SCHEDULE_TRANSITION_INVALID
Deleting a scheduleAny stateAlways ends archived, soft-deleted200

5.6 Requesting and downloading a background export

Summary

A user needs a file — for printing, emailing to a governor, or because the report is too large to view comfortably on screen. They click Export, choose a format, and are notified when it's ready.

Preconditions

  • Fewer than 5 of the caller's own jobs are currently queued/processing.
  • The caller holds ReportExports_CREATE and every code the target report(s) need.

Main Flow

StepActor/SystemActionResultSource
1UserClicks "Export", chooses formatPOST /reports/jobsAPI §8.15
2BackendPlans and authorises (same rules as a live run)AuthorizedRun[] builtBackend §7.3
3BackendInserts the job row + outbox event in one transactionJob queuedBackend §5.2
4WorkerOutbox dispatcher relays to BullMQ; ReportsQueueProcessor claims the jobJob processingBackend §7.4
5WorkerReads every part in one snapshot, streams dataset rows in 1000-row batches, renders CSV/XLSX/PDF, uploadsJob completed/completed_with_warningsBackend §7.4
6BackendSends report.ready (or report.scheduled_ready) notificationUser notified in-app (+ email for scheduled)Backend §10
7UserDownloads the fileGET /reports/jobs/:id/downloadAPI §8.21

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Duplicate submitSame Idempotency-Key, same body, double-clickReturns the existing job, no second job created202, "Export already requested."
Duplicate submit, different bodySame key reused for a different requestRefused409 REPORT_JOB_IDEMPOTENCY_CONFLICT
5 active jobs alreadyUser tries a 6thRefused; must wait for one to finish429 REPORT_JOB_LIMIT_REACHED
Generation fails, retryableA transient database/storage error, attempts remain, not a repeat timeoutJob returns to queued; BullMQ redelivers with backoffNo user action needed
Generation fails, terminalPermission gone, validation failure, or a second timeout in a rowJob failed, notified, dead-letter record writtenUser can retry manually
Worker dies mid-generationLease expires with no finalizeMaintenance sweep reclaims it 5 minutes later and resendsJob returns to queued automatically
Cancel while processingUser cancels a running jobRow flips to cancelled immediately; the worker notices at its next 5-second checkpoint and cleans upDownloaded file (if any) is discarded
Artifact expiredUser waits past artifact_retention_days (default 7)File deleted, row becomes expired, still shows historical row/warning counts410 REPORT_JOB_EXPIRED on download attempt
Package part unavailable to callerAnnual package includes a report the caller cannot seeThat sheet is silently omitted, others still generatedPACKAGE_PARTS_OMITTED warning on the finished file

5.7 Scheduled report delivery (fully automated)

Summary

A schedule ticks on its own cadence, generates a job as its owner, and delivers it to a configured set of users and/or roles — skipping anyone who could not legitimately see the data.

Main Flow

StepActor/SystemActionResult
1CronReportsScheduler.scheduleTick fires every minutereports.schedule_sweep enqueued (deduplicated by minute bucket)
2WorkerReportScheduleSweepService.run finds due schedulesCandidates locked one at a time, SKIP LOCKED
3WorkerRebuilds the owner's principal as of nowIf the owner is inactive or no longer holds the run-as role, the schedule pauses instead of running
4WorkerPlans the job exactly as a manual create wouldAny authorisation failure pauses the schedule with a specific reason
5WorkerResolves recipients: expands role recipients to live members, drops anyone who could not download this exact jobIneligible recipients recorded as recipient_skipped events, never silently dropped without a trace
6WorkerInserts the job, advances next_run_at to the next slotJob proceeds through the normal generation flow (5.6)
7BackendOn success, notifies the owner and eligible recipients (report.scheduled_ready, in-app + email)
8BackendOn repeated failure (configurable threshold, default 3), pauses the schedule and notifies only the ownerreport.schedule_attention

Branches and Edge Cases

BranchConditionBehavior
Server was down for daysSchedule's next_run_at is far in the pastOnly the latest due slot produces one job; no back-fill of missed runs
Recipient is a rolee.g. "all department heads"Expands to every live member of that role at run time, not at schedule-creation time
Recipient could not see the datae.g. a teaching-scoped report and a recipient with no matching teaching assignmentSkipped, counted under recipient_skipped with a reason code, never sent a broken link
Requester-only contentThe report's scope makes it meaningful only to the person who ran itNo recipients are ever notified or permitted to download it — only the schedule owner
Two API replicas sweep at onceBoth pick up the same batchSELECT ... FOR UPDATE SKIP LOCKED on the per-schedule row means each schedule is only actually processed once

5.8 Reviewing the cross-domain overview and closing an academic year

Summary

The landing dashboard shows one KPI-and-alerts section per domain; before closing an academic year, an administrator reviews a dedicated screen of blocking and informational checks plus summary figures.

Main Flow

StepActor/SystemActionResultSource
1UserOpens the admin landing pageGET /reports/overviewAPI §8.5
2UserClicks an alert (e.g. "3 classes below attendance threshold")Navigates to the alert's reportKey pre-filtered—
3UserBefore closing the year, opens the closing checklistGET /reports/year-closingAPI §8.6
4UserResolves each blocking checkRe-opens year-closing to confirm it cleared—

Branches and Edge Cases

BranchConditionBehavior
Caller lacks a domain's codese.g. no ReportsFinance_READThat overview section is omitted from the response; that check/summary is omitted from year-closing entirely
Few registers were takenThe attendance rate is high but most class-day registers in the last 30 days were never takenThe rate covers marked pupil records only, so the section also carries markedCount, expectedCount and coverageRatePct; the screen shows coverage beside the rate
Caller lacks ReportsSchoolWide_READOpening year-closingRefused entirely — 403 REPORT_SCOPE_FORBIDDEN
A blocking check is still opene.g. unpublished exam resultsShown with severity: "blocking"; the closing action itself lives outside this module

5.9 Opening a student, class or teacher profile

Summary

A single composite screen answers "everything about this student/class/teacher" without the user assembling several reports by hand.

Main Flow

StepActor/SystemActionResultSource
1UserOpens a student/class/teacher from elsewhere in the panelGET /reports/profiles/{students|classes|teachers}/:idAPI §8.7
2BackendAuthorises and runs each section independentlySections the caller cannot see are available: falseBackend §6.13
3UserSwitches session (student/teacher profile only)?sessionId= re-fetches session-scoped sections—

Branches and Edge Cases

BranchConditionBehaviorError/Result
Student profile, unknown/out-of-scope session?sessionId= not visible to callerRefused404 REPORT_PROFILE_NOT_FOUND
Class profile, mismatched sessionA class belongs to exactly one sessionRefused (the class exists, the filter is incoherent)400 REPORT_FILTER_INVALID
Teacher profile, any sessionTeacher identity is not session-scopedAlways resolves if the session itself exists200
Subject teacher viewing a student's resultsCaller is not the class teacherSees only published results; overall (cross-subject) results hidden unless caller is the class teacher200, narrowed sections
Class sections gridNon-school-wide callerGrid limited to their own visible sibling classes; small groups suppressed200

5.10 Pupil and guardian portal reports

Summary

A pupil or their guardian opens a read-only screen of the pupil's own placements, attendance, results, assignments and promotions — no admin permission involved, only proof of identity.

Main Flow

StepActor/SystemActionResultSource
1PupilOpens "My Reports"GET /api/mobile/student/reportsAPI §8.22
1'GuardianOpens a child's reportsGET /api/mobile/guardian/children/:studentId/reportsAPI §8.23
2BackendAsserts audience, resolves identity (own row / resolveOwnChildId)Own student row found, or 404Backend §6.14
3BackendAssembles the five sections through the normal report path, as the pupil's own recordNo permission gating — audience is the entire controlBackend §6.14

Branches and Edge Cases

BranchConditionBehaviorError/Result
Guardian requests a child that is not theirsstudentId belongs to someone else's childRefused, indistinguishable from "does not exist"404 STUDENT_NOT_FOUND
Caller's role is not student/guardianWrong audience for the routeRefused403 (audience assertion)
Assignment not yet dueDue date in the futureNot counted as outstandingExcluded from the count
Assignment still a draftTeacher has not published itNever shown to the pupil at allExcluded from the count and section
Attendance "expected days"Any school day in the futureNever countedBounded at today

6. Admin Flows

6.1 Cancel

Covered in Flow 5.6. Permission: ReportExports_UPDATE (own) or ReportJobs_UPDATE (any). Guard condition: status IN ('queued','processing').

6.2 Retry

Covered in Flow 5.6. Guard condition: status = 'failed' AND retry_count < 10.

6.3 Delete artifact (early expiry)

Covered in Flow 5.6. Guard condition: status IN ('completed','completed_with_warnings').

6.4 Operational oversight (ReportJobs_READ/_UPDATE)

An operator holding these codes sees every user's export jobs and can cancel/retry any of them — for cleaning up a stuck queue or investigating a complaint — but critically cannot download a job that is not theirs, because visibility and download entitlement are deliberately separate rules (canView vs canDownload). This prevents an operational role from becoming a backdoor into every teacher's personally-scoped exports.

6.5 Managing dashboards, saved reports, templates, schedules and settings

Every one of these is a straightforward create/read/update/(soft)delete cycle with two consistent rules across all four: optimistic concurrency (a required expectedVersion on every mutation except dashboard delete and the three schedule-transition routes, which accept it optionally) and ownership vs school-wide scope (personal dashboards and saved reports are owner-only; templates and school-scope dashboards need their own dedicated CRUD codes rather than an ownership check).

7. Lifecycle and State Transitions

7.1 Export job (report_jobs.status)

FromEvent/ActionToGuard ConditionSide Effects
—Create (manual/run-now) or schedule sweepqueuedActive-job cap not exceeded (manual only)Outbox event written
queuedWorker claimsprocessingRow is still queuedLease token issued, attempt_count+1
processingFinalize, no warningscompletedLease still validArtifact stored, expires_at set, notification sent
processingFinalize, with warningscompleted_with_warningsLease still validSame, plus warnings recorded
processingRetryable failure, attempts remain, not 2nd timeoutqueuedLease still validattempt_failed event; BullMQ backoff redelivers
processing / queuedTerminal failurefailedLease valid (processing) or none (queued, sweep giving up)Notification sent; schedule failure counter incremented if scheduled
queued / processingUser cancelcancelledRow still activeWorker notices at next checkpoint if processing
failedUser retryqueuedretry_count < 10New outbox delivery
completed*Retention elapsed, or user deletes artifactexpired—Storage object removed, artifact_key nulled
any terminalHistory retention elapsed, no objects remain(row deleted)——

7.2 Scheduled report (report_schedules.status)

FromEventToGuard ConditionSide Effects
—Createddraft, or active if activate: trueValid cadencenext_run_at computed if active
draft / pausedPOST .../activateactiveValid cadence, config still validnext_run_at computed
activePOST .../pausepaused——
activeConsecutive failures reach the configured thresholdpausedneeds_attention = true, attention_reason = 'failures'Owner notified once per pause episode
activeOwner becomes inactive at sweep timepausedattention_reason = 'owner_inactive'Owner notified
activeOwner no longer holds the run-as role, or lost the required permissionpausedattention_reason = 'owner_permission'Owner notified
activeThe stored config no longer validates against the reportpausedattention_reason = 'invalid_config'Owner notified
activeAn unexpected error during planningpausedattention_reason = 'schedule_error', consecutive_failures+1Owner notified
any non-archivedPOST .../archive, or deletedarchived (soft delete on the delete route)—deleted_at set on delete; a deleted schedule is always archived

7.3 Saved report / template / dashboard (status, version)

FromEventToGuard ConditionSide Effects
—Createactive (or draft for a saved report explicitly requesting it)Name unique in scope, under the per-owner cap (saved only)version = 1
active/draftPATCHsame status (or a new one, if included)expectedVersion matchesversion+1
activePOST .../archive (saved reports only; templates/dashboards are deleted, not archived)archivedexpectedVersion matchesarchived_at set
archivedPOST .../restoreactiveexpectedVersion matches, currently archivedarchived_at cleared
anyDELETE(soft-deleted)expectedVersion matches (dashboards: optional)deleted_at set

8. Business Rules and Policy Traceability

RuleBusiness ReasonEnforced InTests/Source
A report requires Reports_READ and every domain code it namesOne reporting area over every domain, gated per-domain rather than all-or-nothingReportAuthorizer.requiredCodes/holdsAllBackend §7.1
A school_only report or builder dataset needs ReportsSchoolWide_READ regardless of domain codesSome facts (ID card print runs, staff-wide distributions, cross-year progression, finance totals) are never meaningful per-classReportAuthorizer.authorize, ReportBuilderService.datasetsshared/report-authorizer.service.ts:108
Filters can only narrow the caller's scope, never widen itA teacher must not be able to type a class id they don't teach into a filter and see it anywayReportAuthorizer.checkNarrowingFiltersshared/report-authorizer.service.ts:290
A contact column/field is excluded from defaults and 403s if explicitly requested without the contacts codePhone/email are more sensitive than the rest of a directory rowReportAuthorizer.resolveColumns/resolveSort, builder-compiler.tsshared/report-authorizer.service.ts:223,262
A small group's counts are blanked below min_group_size in fixed reports, or dropped entirely in the builderA single pupil's ethnicity, disability or mother tongue must not be inferable from a "group of 1"suppressTable, builder-compiler.tsshared/report-result.ts:169
A heavy report cannot run live across more than one sessionMulti-year comparisons are expensive; force them through the export path, which has a longer timeout and doesn't compete with live screensReportAuthorizer.authorizeshared/report-authorizer.service.ts:173
A download re-checks the caller's current permissions against the job's stored required_codesA role revoked after a file was generated must not still be able to fetch that filecanDownloadjobs/report-job-access.ts:77
A teaching-scoped or principal-scoped job is downloadable only by its requesterThe file's content depends on who ran it; a recipient seeing it out of context would be seeing data outside their own entitlementisRequesterOnlyJobjobs/report-job-access.ts:26
A schedule recipient is checked against the same download rule twice: at create/update, and again before every runNever send a link that then 404s for the recipient, and never let a since-revoked permission silently keep a recipient on a listReportScheduleRecipientsService, ReportScheduleSweepService.resolveRecipientsjobs/workers/report-schedule-sweep.service.ts:279
A schedule only ever produces one job per due slotA server outage must not turn into a burst of duplicate reports on restartreport_jobs_schedule_slot_unique, SKIP LOCKED per-row transactionpackages/db/src/schema/school/reports.ts
An idempotency key with a mismatched body is refused, not silently acceptedA double-submit with different filters must not resolve to whichever request happened to be firstReportJobsService.createjobs/report-jobs.service.ts:112
A job's filter_summary never contains a student/staff name or free-text search termThe persisted, widely-visible summary field must not leak identitycleanText + PERSONAL_FILTER_KEYS exclusionjobs/report-job-planner.service.ts:59
Every export queue and sweep queue is isolated at concurrency 1, on its own BullMQ queueA two-minute export must never delay notifications or the next minute's schedule tickReportsQueueProcessor/ReportsSweepProcessor @Processor(..., {concurrency:1})jobs/workers/*.processor.ts
Every saved report, template, dashboard and schedule mutation requires the caller's expectedVersionTwo tabs editing the same saved query must not silently overwrite each otherSavedReportsService, ReportTemplatesService, ReportDashboardsService, ReportScheduleLifecycleServicesaved/saved-reports.service.ts:282-296
A builder query cannot filter rows on a demographic field, or group an identifying field alongside a demographic oneThe row-level filter and the combined grouping are exactly the shapes that would re-identify a suppressed small groupresolveBuilderQuerybuilder/builder-compiler.ts
An overview/year-closing section, or a profile section, is independently gated: an overview or year-closing section is simply absent, and a profile section is returned with available: false, rather than erroring the whole compositeA caller missing one domain's codes should still see every section they can seeReportCompositeAuthorizershared/report-composite-authorizer.service.ts
A guardian's/pupil's own reports carry no permission code at allThe control is "is this you / your child", never a grantable permission — a permission model would let an administrator grant a stranger access to a child's dataassertPortalAudience + identity-layer row lookupportal/reports-student-portal.controller.ts, portal/reports-guardian-portal.controller.ts
Portal assignment counts exclude drafts and not-yet-due work; attendance "expected days" never counts future school daysA pupil's own dashboard must not show a phantom missing assignment or an attendance shortfall for a day that hasn't happened yetReportsPortalServiceportal/reports-portal.service.ts

9. Data and Side Effects by Flow

FlowDB WritesCache EffectsJobsNotifications
Live run / builder previewnone (read-only)write on miss, read on hitnonenone
Dashboard rendernoneper-widget write on miss, read on hitnonenone
Overview / year-closing / profile readnoneper-section write on miss, read on hitnonenone
Save/edit/archive/restore/delete saved report or templatereport_definitions insert/updatenonenonenone
Create/edit/delete dashboardreport_dashboards insert/updatenonenonenone
Create/edit/activate/pause/archive/run-now/delete schedulereport_schedules(+recipients) insert/update; run-now also report_jobs+outbox_events insertnonerun-now enqueues reports.generatenone from these routes directly
Update settingsreport_settings update, version+1invalidates every report cache key implicitly (version is part of the key)nonenone
Export createreport_jobs insert, outbox_events insertnoneenqueues reports.generate (via outbox)none yet
Export generation successreport_jobs update (finalize), report_job_events insert, report_job_objects insertnonenone (terminal)report.ready or report.scheduled_ready
Export generation failure (retryable)report_jobs update (back to queued), report_job_events insertnoneBullMQ redeliversnone
Export generation failure (terminal)report_jobs update (failed), report_job_events insert, job_failures insertnonenonereport.failed
Downloadreport_jobs update (download_count), report_job_events insertnonenonenone
Schedule sweep (success)report_jobs insert, report_schedules update (next_run_at/last_run_at), report_job_events insert (skipped recipients, if any)noneenqueues generation(deferred to generation outcome)
Schedule sweep (pause)report_schedules update (status/attention fields)nonenonereport.schedule_attention
Maintenance sweepreport_jobs update (expired/failed), report_job_objects delete, report_jobs delete (purge)nonenonenone
Portal read (student/guardian)nonewrite on miss, read on hit (per underlying report)nonenone

10. Error and Recovery Flows

ScenarioTriggerUser/System ExperienceRecoverySource
Report capacity exhaustedToo many concurrent live runs503 REPORT_BUSY on the run requestUser retries shortly, or exports insteadReportQueryService
Query too slowStatement or wall-clock timeout503 REPORT_QUERY_TIMEOUT, message suggests exportingUser exports (longer timeout, keyset streaming)ReportQueryService
Worker crash mid-exportProcess dies holding a leaseJob appears stuck at some progress percentageMaintenance sweep reclaims it after 5 minutes' grace and resendsreport-maintenance.service.ts
Delivery lost between outbox and queueOutbox row relayed but the BullMQ job vanished (broker restart, etc.)Job stuck queued past 15 minutesMaintenance sweep detects no live BullMQ job and resendsreport-maintenance.service.ts
Undeliverable job5+ recoveries and still not generated after 24hJob would otherwise loop foreverMarked failed with error_category: internal, owner notifiedreport-maintenance.service.ts
Redis unavailableCache/rate-limit backend downReports/builder/dashboards still run, just uncached; refresh rate limit fails openAutomatic — no manual recovery neededReportCacheService
Schedule repeatedly failsConsecutive-failure threshold reachedSchedule auto-pauses rather than retrying foreverOwner reviews and reactivates manuallyreport-schedule-sweep.service.ts, report-job-lifecycle.service.ts
Two tabs edit the same saved report/dashboard/scheduleConcurrent PATCH with the same starting expectedVersionThe second request's version no longer matches409 REPORT_VERSION_CONFLICT — client refetches and reappliessaved-reports.service.ts and equivalents
Recipient's eligibility changes between schedule creation and a runA role or permission was revoked in the meantimeRecipient silently skipped for that run, recorded as an eventOwner sees no missing-recipient error; the event log explains itreport-schedule-sweep.service.ts

11. Report Inventory (every registered report key, verbatim from the domain files)

64 report keys are registered across fifteen domain classes plus the builder, all currently live (no domain ships zero definitions).

11.1 Students & Demographics (ReportsStudents_READ) — 9 reports

KeyKindScope
students.directorydatasetteaching
students.status_summaryanalyticteaching
students.admissionsanalyticteaching
demographics.genderanalyticteaching
demographics.ageanalyticteaching
demographics.grade_distributionanalyticteaching
demographics.section_occupancyanalyticteaching
demographics.enrollment_trendanalyticschool_only
demographics.classificationanalyticschool_only

students.directory carries four contact: true fields (phone, email, guardianPhone, guardianEmail).

11.2 Calendar — 1 report (unscoped)

calendar.summary — scopeMode: "unscoped", the only report requiring no domain code beyond Reports_READ.

11.3 ID Cards (ReportsIdCards_READ) — 4 reports, all school_only

id_cards.coverage, id_cards.missing, id_cards.status, id_cards.register.

11.4 Attendance (ReportsAttendance_READ) — 7 reports

attendance.daily (dataset, teaching, heavy: true, live capped at 31 days), attendance.student_summary (dataset, teaching), attendance.class_summary (analytic, teaching), attendance.trend (analytic, teaching), attendance.absence_analysis (analytic, teaching), attendance.calendar (analytic, teaching), attendance.staff_summary (dataset, self_staff, requires ReportsAttendance_READ + ReportsStaff_READ).

11.5 Leave (ReportsLeave_READ) — 3 reports, teaching_and_self_staff

leave.requests, leave.summary, leave.balances (heavy: true; live requires exactly one leaveTypeIds).

11.6 Academics / Exams (ReportsAcademics_READ) — 8 reports

exams.summary, exams.results (requires examId), exams.subject_performance (requires examId), exams.grade_distribution (requires examId), exams.class_comparison (requires examId + gradeIds), exams.student_progression, exams.teacher_subjects (self_staff, + ReportsStaff_READ, requires examId), exams.exam_comparison (requires examId + compareExamId).

11.7 Assignments (ReportsAssignments_READ) — 3 reports, teaching

assignments.overview, assignments.submissions, assignments.student_workload.

11.8 Promotions (ReportsPromotions_READ) — 3 reports

promotions.summary (teaching), promotions.decisions (teaching), promotions.progression (school_only, requires sessionId).

11.9 Staff & Timetable (ReportsStaff_READ) — 5 reports

staff.directory (self_staff, two contact: true columns), staff.distribution (school_only), staff.teacher_workload (self_staff), timetable.room_usage (school_only), timetable.period_utilization (school_only).

11.10 Communication (ReportsCommunication_READ) — 3 reports, all school_only

Source: domains/communication/communication.reports.ts, notices-summary.query.ts, notices-register.query.ts, notifications-delivery.query.ts.

notices.summary (analytic)

  • Filters: dateFrom, dateTo, period.
  • Columns (the one table, grouped by category): category (text), count (number, "Published").
  • KPIs: published (count), scheduled (count, from the backlog query), expired (count — a published notice whose expiresAt has passed as of ctx.now), draft (count, backlog), readRatePct (percent, metricKey: "notices.read_rate").
  • Charts: byPriority (bar, one bar per notice priority); byBsMonth (bar, two series — published count and readRatePct — bucketed by the BS month of publishedAt in the school's timezone).
  • Metrics referenced: notices.read_rate — computed as Σ min(reads, recipientCount) ÷ Σ recipientCount × 100 across every published, non-null-recipient notice; a notice with no resolved recipient count is excluded from both sums, not treated as zero.
  • Suppression / money: no small-group suppression (this report never breaks a value down by pupil/staff identity) and no money fields.
  • Empty case: NO_SOURCE_ROWS when there are zero published/expired rows and zero draft and zero scheduled.

notices.register (dataset)

  • Filters: dateFrom, dateTo, period, q.
  • Columns: title (text, sortable), category (text), priority (status, enumKey: "notice_priority"), status (status, enumKey: "notice_status"), publishedAt (datetime, sortable), expiresAt (datetime), audience (text), createdBy (text), recipients (number), reads (number), readRatePct (percent).
  • Sortable: title, publishedAt.
  • Metrics referenced: notices.read_rate (surfaced per-row as readRatePct, not recomputed as a KPI here).
  • Supports streamRows for export (keyset batches), and pagination (page/size) live.

notifications.delivery (analytic)

  • Filters: dateFrom, dateTo, period.
  • Columns (the one table, by channel × status): channel (status, enumKey: "notification_channel"), status (status, enumKey: "delivery_status"), count (number).
  • KPI: deliveryRatePct (percent, metricKey: "notifications.delivery_rate") — (sent + delivered) ÷ (sent + delivered + dead) × 100, computed from the same channel×status rows the table shows.
  • Charts: bySourceModule (bar, delivery count per originating module); byBsMonth (bar, "Volume per BS month" — total deliveries of any status, bucketed by day then rolled into BS months).
  • Suppression / money: none.
  • Empty case: NO_SOURCE_ROWS when the overall total count is zero.

11.11 Complaints (ReportsComplaints_READ) — 2 reports, both school_only, cacheScope: "principal"

Source: domains/complaints/complaints.reports.ts, complaints-summary.query.ts, complaints-register.query.ts. Both definitions are cacheScope: "principal" — visibility is per-caller (ComplaintAccessService.listVisibilityWhere excludes the submitter and the subject from what a given viewer may see), so a shared cache key would leak one person's visible complaint set to another viewer who cannot see the same complaints.

complaints.summary (analytic)

  • Filters: dateFrom, dateTo, period, complaintCategoryIds, complaintPriority, complaintStatus.
  • Columns (the one table, by category): category (text), count (number, "Submitted").
  • KPIs: submitted (count, total visible rows), open (count — status in the open set), resolved, closed, rejected, cancelled (each a count of that exact status), overdue (count — open and past dueAt), openUrgentHigh (count — open and priority urgent/high), slaCompliancePct (percent, metricKey: "complaints.sla_compliance" — resolved-with-a-due-date-on-time ÷ resolved-with-a-due-date × 100), avgResolutionHours (hours, metricKey: "complaints.avg_resolution_hours" — mean of (resolvedAt − max(submittedAt, lastReopenedAt)) minus paused seconds, rounded to 2 decimals), medianResolutionHours (hours, same basis, median, no metric key), reopenRatePct (percent, metricKey: "complaints.reopen_rate" — reopened ÷ submitted × 100).
  • Charts: byPriority (bar), byStatus (bar), byBsMonth (bar, submissions bucketed by BS month of submittedAt), bySubmitterKind (bar — anonymous complaints are grouped under "anonymous" rather than their real submitter kind), byGradeSection (bar, suppressed: built via suppressTable on count with min_group_size, and anonymous complaints are excluded from this breakdown entirely before suppression even runs, since an anonymous complaint has no attributable grade/section to report).
  • Suppression / money: byGradeSection chart data is small-group suppressed; a GROUPS_SUPPRESSED warning is added when any cell is blanked. No money fields.
  • Empty case: NO_SOURCE_ROWS when there are zero visible rows.

complaints.register (dataset)

  • Requires: ReportsComplaints_READ and Complaints_READ (the complaints module's own read code, not only the reports-domain code) — a caller must be independently entitled to read complaints at all, not merely hold the reporting permission.
  • Filters: dateFrom, dateTo, period, complaintCategoryIds, complaintPriority, complaintStatus, q.
  • Columns: reference (text, sortable), categoryName (text), priority (status, enumKey: "complaint_priority"), status (status, enumKey: "complaint_status"), submittedAt/acknowledgedAt/dueAt/resolvedAt/closedAt (all datetime; submittedAt sortable), isOverdue (boolean), reopenCount (number), escalationLevel (number), gradeName (text), sectionName (text).
  • Sortable: reference, submittedAt.
  • Every row is filtered through the same ComplaintAccessService.listVisibilityWhere the summary uses, so the register never lists a complaint the caller could not otherwise see; supports streamRows for export.
  • Empty case: NO_SOURCE_ROWS when zero visible rows.

11.12 Finance (ReportsFinance_READ) — 7 reports, all school_only. Money is integer paisa end to end — every paisa-typed field is a summed stored column, never a float recomputation.

Source: domains/finance/finance.reports.ts, finance-payroll-assets.reports.ts, and their *.query.ts files.

finance.fees_overview (analytic)

  • Filters: sessionId, sessionIds, gradeIds, classIds, feeCategoryIds.
  • Columns (one row per grade): gradeName (text, drills into finance.fee_balances filtered on gradeIds), grossBilled, scholarships, charges, adjustments, waivers, netBilled, collected, outstanding, overdue (all paisa), collectionRatePct (percent), studentsWithOutstanding (number).
  • KPIs: grossBilled, scholarships, charges, adjustments, waivers, netBilled, collected (all paisa), outstanding (paisa, metricKey: "fees.outstanding"), overdue (paisa, metricKey: "fees.overdue"), collectionRatePct (percent, metricKey: "fees.collection_rate" — Σ paid ÷ Σ net billed × 100), studentsWithOutstanding (count).
  • Chart: byPeriod (line, net billed vs collected paisa, bucketed by BS fee-period month).
  • Empty case: FEES_NOT_GENERATED warning when there are zero fee obligations for the selection (a distinct warning from NO_SOURCE_ROWS, naming the specific missing precondition — fee generation, not merely empty data).

finance.fee_balances (dataset)

  • Requires: ReportsFinance_READ and FeeExports_READ (the fees module's own export code).
  • Filters: sessionId, gradeIds, sectionIds, classIds, studentId, q.
  • Columns: studentCode (text, sortable), fullName (text, sortable), grade (text), section (text), net (paisa), paid (paisa), balance (paisa, sortable), overdue (paisa), oldestDueDate (date), openObligations (number).
  • Sortable: fullName, studentCode, balance. Groupable: grade, section.
  • Metrics referenced: fees.outstanding, fees.overdue (surfaced per-row, not as KPIs — this report has no summary).
  • Empty case: NO_SOURCE_ROWS.

finance.aging (analytic)

  • Filters: sessionId, gradeIds.
  • Columns (one row per grade): gradeName (text), notYetDue, d1To30, d31To60, d61To90, d91Plus, noDueDate (all paisa — outstanding balance bucketed by days overdue).
  • KPI: outstanding (paisa, metricKey: "fees.aging" — sum of every bucket across every grade).
  • Empty case: NO_SOURCE_ROWS.

finance.collections (analytic)

  • Filters: sessionId, dateFrom, dateTo, period, granularity.
  • Columns (one row per payment source): source (text), payments (number), paymentAmount (paisa), reversals (number), reversalAmount (paisa).
  • KPIs: payments (count), paymentAmount (paisa), reversals (count), reversalAmount (paisa), netCollected (paisa — payments minus reversals).
  • Chart: line — "Net collected" over the selected granularity's buckets.
  • Metrics: none declared (metrics: []) — this report's own KPI labels (payments, netCollected, etc.) are not registered in the central METRIC_DEFINITIONS catalogue.
  • Empty case: NO_SOURCE_ROWS when both payments and reversals totals are zero.

finance.fee_categories (analytic)

  • Filters: sessionId, gradeIds.
  • Columns (one row per fee category): categoryName (text), amount (paisa), sharePct (percent — this category's amount as a share of total billing).
  • Metrics: none declared.
  • Empty case: NO_SOURCE_ROWS.

finance.payroll (analytic)

  • Requires: ReportsFinance_READ and SalaryDashboard_READ (the salary module's own dashboard code).
  • Filters: fiscalYearBs, dateFrom, dateTo.
  • Columns (one row per salary period, BS-month labelled): period (text), headcount (number), gross, deductions, net, paid (all paisa).
  • Metrics referenced: finance.payroll_totals (no KPI row in this report — summary: [] — the metric is registered but not surfaced here as a card).
  • Suppression: payroll rows are small-group suppressed by headcount — payrollSuppressedIndices(headcounts, minGroupSize) blanks every one of headcount/gross/deductions/net/paid (all set to null) for any period whose headcount falls under min_group_size, because a payroll figure for a handful of staff would otherwise reveal an individual's pay. A GROUPS_SUPPRESSED warning (with the suppressed-cell count) is added whenever this fires, alongside NO_SOURCE_ROWS if there are no periods at all.

finance.assets (analytic)

  • Requires: ReportsFinance_READ and Assets_READ (the assets module's own read code).
  • Filters: none (filters: []) — this report is unconditionally school-wide, no date or session narrowing.
  • Columns (one row per category × status): categoryName (text), status (status, enumKey: "asset_status"), records (number), items (number), recordedCost (paisa).
  • KPI: recordedCost (paisa, metricKey: "finance.asset_recorded_cost" — sum across every category/status row).
  • Warnings: NO_SOURCE_ROWS if empty; ASSET_COST_MISSING (with a count) when one or more asset records have no recorded cost — a distinct signal from an empty result, since the assets exist but their cost figure is incomplete.

11.13 Cross-Domain — 7 reports, all analytic

Source: domains/cross-domain/*.reports.ts and their paired *.query.ts files, sharing helpers from cross-domain-shared.ts (attendance/completion banding, Pearson correlation, scatter chart building, and the correlation sample-size warning).

cross.attendance_vs_assignments (teaching, ReportsAttendance_READ + ReportsAssignments_READ)

  • Filters: sessionId, dateFrom, dateTo, dateFromBs, dateToBs, period, gradeIds, classIds.
  • Columns: band (text — the pupil's attendance band for the window), students (number, pupils in that band), avgY (percent — average assignment completion rate in that band).
  • KPIs: students (count, total paired pupils), pearsonR (ratio, metricKey: "cross.pearson_r", Pearson correlation between attendance rate and completion rate).
  • Chart: a scatter of attendance % (x) vs completion % (y), one point per pupil.
  • Metrics referenced: attendance.rate, assignments.completion_rate, cross.pearson_r.
  • Warnings: NO_SOURCE_ROWS if no paired pupils; otherwise the shared correlationWarnings(pairCount) (a low-sample-size caveat below a fixed threshold).

cross.leave_vs_attendance (teaching, ReportsLeave_READ + ReportsAttendance_READ)

  • Filters: sessionId, dateFrom, dateTo, dateFromBs, dateToBs, period, gradeIds, classIds.
  • Columns (one row per pupil): studentCode (text), fullName (text), approvedLeaveDays (number), excusedDays (number), absentDays (number), ratePct (percent — attendance rate).
  • KPIs: students (count), totalApprovedLeaveDays (days), totalExcusedDays (days), totalAbsentDays (days), avgAttendanceRatePct (percent, mean across pupils, rounded to 2 decimals).
  • Metrics referenced: leave.approval_rate, attendance.rate (declared, not computed as a KPI here beyond the average shown).
  • Empty case: emptyResult with NO_SOURCE_ROWS when there are no pupils with attendance in the selection.

cross.attendance_vs_performance (teaching, ReportsAttendance_READ + ReportsAcademics_READ, requires examId)

  • Filters: sessionId, examId, gradeIds, classIds. requiredFilters: examId.
  • Columns: band (text, attendance band), students (number), avgY (percent, average exam %), passRatePct (percent, pass rate within the band).
  • KPIs: students (count), pearsonR (ratio, metricKey: "cross.pearson_r").
  • Chart: scatter, attendance % (x) vs exam % (y).
  • Metrics referenced: attendance.rate, cross.pearson_r.

cross.assignments_vs_performance (teaching, ReportsAssignments_READ + ReportsAcademics_READ, requires examId)

  • Filters: sessionId, examId, gradeIds, classIds. requiredFilters: examId.
  • Columns: band (text, completion band), students (number), avgY (percent, average exam %), passRatePct (percent).
  • KPIs: students (count), pearsonR (ratio, metricKey: "cross.pearson_r").
  • Chart: scatter, completion % (x) vs exam % (y).
  • Metrics referenced: assignments.completion_rate, cross.pearson_r.

cross.attendance_vs_promotion (teaching, ReportsAttendance_READ + ReportsPromotions_READ, requires sessionId)

  • Filters: sessionId, gradeIds. requiredFilters: sessionId.
  • Columns (one row per promotion outcome): outcome (status, enumKey: "promotion_outcome"), students (number), avgAttendancePct (percent), medianAttendancePct (percent).
  • KPIs: one per outcome present (e.g. promotedStudents, retainedStudents, …), each a count, labelled from a fixed outcome-label map (Promoted, Retained, Conditionally promoted, Graduated, Pending, Excluded).
  • Metrics referenced: attendance.rate, promotions.promotion_rate.
  • Empty case: SOURCE_NOT_CONFIGURED (not NO_SOURCE_ROWS) when there are no finalized promotion decisions yet for the session — a distinct signal that promotions haven't been run, not merely that pupils have no attendance.

cross.enrollment_vs_promotion (teaching, ReportsStudents_READ + ReportsPromotions_READ, requires sessionId)

  • Filters: sessionId, gradeIds. requiredFilters: sessionId.
  • Columns (one row per grade): grade (text), activeEnrolled (number), finalizedDecisions (number), pending (number), promoted (number), retained (number), graduated (number), decisionCoveragePct (percent — finalized decisions ÷ active enrolled).
  • KPIs: activeEnrolled (count, total across grades), finalizedDecisions (count, total across grades).
  • Metrics referenced: enrollment.active, promotions.promotion_rate.
  • Empty case: NO_SOURCE_ROWS when there are no active enrolments for the session.

cross.complaints_by_class (school_only, ReportsComplaints_READ + ReportsStudents_READ, cacheScope: "principal")

  • Built by a DI factory (CrossDomainComplaintsFactory) so only this one cross-domain report depends on ComplaintAccessService.
  • Filters: sessionId, dateFrom, dateTo, dateFromBs, dateToBs, period.
  • Columns (one row per class): className (text), complaints (number), activeStudents (number), per100Students (number — complaints normalized per 100 active pupils).
  • KPIs: classes (count), complaints (count, total), activeStudents (count, total).
  • Suppression: complaints and its dependent per100Students are small-group suppressed together (suppressTable(rows, ["complaints"], minGroupSize, {complaints: ["per100Students"]})) — a class with few enough complaints has both cells blanked so the per-100 rate cannot be used to infer the raw (small) count. Only non-anonymous complaints are counted at all.
  • Empty case: emptyResult when there are no classes in the selection.

11.14 Academic Year — 1 report

Source: domains/year/year.reports.ts, year-comparison.query.ts.

year.comparison (analytic, school_only, requires: [], cacheScope: "principal")

  • requires: [] at the definition level is deliberate: the caller's access to each individual comparable metric is checked per-metric against ctx.principal directly (ctx.principal.isSuperadmin || ctx.principal.permissions.has(metric.code)) rather than as one upfront domain-code gate — a metric whose code the caller lacks is omitted as a row, never shown with a locked placeholder or a null, matching every other composite screen's "never a greyed-out entry" rule. cacheScope: "principal" follows from this: the row set itself depends on who is asking.

  • Filters: sessionIds. requiredFilters: sessionIds (2 to 5 sessions; fewer than 2 returns an emptyResult with warning SOURCE_NOT_CONFIGURED and no principal-gating even runs).

  • Columns: one fixed metric (text) column, plus one dynamically-named session_{sessionPublicId} (number) column per selected session — so the table's shape depends on how many sessions were compared.

  • Rows (each gated by its own domain code):

    Metric rowLabelGating codeComputation
    enrollment.activeActive enrolmentReportsStudents_READenrollmentActive(session.id)
    attendance.rateAttendance rateReportsAttendance_READattendanceRatePct(session.id, session.startDate, session.endDate)
    exams.pass_rateFinal exam pass rateReportsAcademics_READfinalExamAggregate(session.id).passRatePct
    exams.average_percentageFinal exam average %ReportsAcademics_READfinalExamAggregate(session.id).averagePercentage
    promotions.promotion_ratePromotion rateReportsPromotions_READpromotionRatePct(session)
    assignments.completion_rateAssignment completion rateReportsAssignments_READassignmentsCompletionRatePct(session.id)
    complaints.submittedComplaints submittedReportsComplaints_READcomplaintsSubmitted(session.id) — a plain count, not a formula in METRIC_DEFINITIONS
    fees.collection_rateFee collection rateReportsFinance_READfeesCollectionRatePct(session.id)
    id_cards.coverage_rateID card coverage rateReportsIdCards_READidCardsCoverageRatePct(session.id)
  • Metrics referenced: every row above except complaints.submitted (which has no entry in METRIC_DEFINITIONS — it is a plain count, not a registered formula).

  • Suppression / money: none — no field here is money or an identity-adjacent breakdown; every value is a school-wide aggregate per session.

11.15 Custom builder — 1 definition

builder.custom (dataset, category: "cross_domain", requires: [] at the top level — the chosen dataset's own domain codes and scope are resolved per-query by resolveBuilder). BUILDER_DATASETS covers people, attendance, academics, leave, and the school-only domains, each dataset carrying its own scopeMode and field list with contact/aggOnly/demographic flags.

11.16 Packages (multi-report export bundles)

package.annual_academic (a fixed twelve-part bundle spanning most domains above) and package.custom (2–12 caller-chosen parts) — both export-only, no live "run" exists for a package; each part is authorised independently and an unauthorisable part is silently omitted with a PACKAGE_PARTS_OMITTED warning.

12. Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitTradeoff
Catalog and builder-dataset lists show only what's runnable, never a "locked" placeholderNo confusing dead entries a teacher can never openSimpler client — no separate "can I run this" checkA teacher never learns a report/dataset exists if they lack a code, which is a deliberate choice, not an oversight
Live run capped at 15s wall clock (18s for a dashboard render), export given up to 2 minutesLive screens stay snappyProtects the shared connection pool from being monopolised by one heavy queryA genuinely large live query is refused rather than allowed to run slowly — the user must export
Small-group suppression is unconditional, not a toggleNobody can accidentally expose one child's ethnicity by narrowing filters far enoughOne code path (blank a cell, or drop a builder group), no per-report opt-out to get wrongSome legitimately small schools will see more suppressed cells/dropped groups than a large school would
The builder forbids combining an identifying field with a demographic field in groupBy, even though each is independently allowedPrevents the exact composition that would defeat suppressionNo per-query manual review neededA legitimate cross-tab a school genuinely wants (e.g. "list of pupils by ethnicity") is refused by design
A schedule produces at most one job per outage, not a back-fillNobody wakes up to twelve identical reports after a long weekend outageSimpler slot-uniqueness constraint ((schedule_id, scheduled_for) unique)A schedule owner does not automatically get the reports for days the server was down — they'd have to export those manually
Download re-checks permissions live rather than trusting generation-time codesA revoked role cannot retrieve data through an old, already-generated fileNo separate "invalidate old exports" sweep neededA legitimately-generated file becomes permanently unreachable to its owner the moment their role changes, even if reinstated later (a new download attempt after re-grant works again, since the check is live)
Every saved/template/dashboard/schedule mutation requires expectedVersion (except dashboard delete and schedule transitions, which accept it optionally)Prevents silent last-writer-wins overwrites between two tabs or two peopleOne consistent pattern across four surfacesA client that never fetches the current version cannot mutate at all — it must always read before writing
The pupil/guardian portal carries no permission codeNobody can accidentally (or maliciously) be granted access to a child that isn't theirs — identity is the whole gateNo permission catalogue entry to keep in sync for a purely per-person relationshipAn administrator cannot delegate "let this one teacher see this one child's portal view" — that access is always through the ordinary admin reports, gated by domain codes instead

13. Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorSource
Live runEmpty scopeTeacher with no classes this sessionEmpty table, appropriate warningPer-domain run()
Live runDuplicate rapid requestsSame filters, same user, within TTLSecond request served from cache, cached: trueReportCacheService
Live runConcurrent identical missesTwo tabs open the same report at once, cache emptyBoth share one computation (inFlight map), only one database readReportCacheService.getOrCompute
Builder previewSmall group in resultA grouped combination has fewer rows than min_group_sizeGroup dropped entirely, not blankedbuilder-compiler.ts
Dashboard renderOne widget's underlying report is heavy and multi-session by its stored default_filtersConfig predates a settings changeThat widget errors with REPORT_HEAVY_USE_EXPORT; the rest of the dashboard still rendersReportDashboardRenderService
Overview/profileCaller lacks one domain's codeReading the overview or a profileAn overview section is omitted and a profile section is returned with available: false; the rest of the screen renders normallyReportCompositeAuthorizer
Saved report/dashboard/schedule editConcurrent editTwo tabs, same starting versionSecond PATCH sees 409 REPORT_VERSION_CONFLICTrespective service
Export createConcurrent double-clickTwo rapid submits, no idempotency keyBoth may create separate jobs (no dedupe without a key) unless the active-job cap or a matching Idempotency-Key intervenesReportJobsService.create
Export createRace on the active-job capTwo tabs both at 4 active jobs, both submit a 5thThe pg_advisory_xact_lock per user serialises them — the second sees 5 active and is refusedjobs/report-jobs.service.ts:97
Export generationJob cancelled the instant it's claimedCancel request lands between claim and the first checkpointNext checkpoint write finds 0 rows, throws ReportLeaseLost, worker stops silentlyreport-job-lifecycle.service.ts
Export generationPermissions stale after a long queue waitA job waits an hour behind others, then generatesPrincipal is rebuilt with current permissions at generation time, not creation time — a role revoked in that hour produces a permission failure, not a leaked fileReportGenerationService.build
Schedule sweepSchedule deleted between candidate read and row lockdeleted_at set concurrentlySELECT ... FOR UPDATE re-checks deleted_at IS NULL; row simply not found, sweep moves onreport-schedule-sweep.service.ts:139
Schedule createRecipient eligible at creation, ineligible by the first runA permission is revoked in betweenCreate succeeds; the run silently skips them with a recipient_skipped eventreport-schedule-recipients.service.ts
DownloadJob belongs to another teacher's classNon-requester, non-recipient tries the id404 REPORT_JOB_NOT_FOUND — never 403, so job ids are not an existence oraclejobs/report-job-access.ts
PortalGuardian tries a studentId that is not their childWrong id404 STUDENT_NOT_FOUND — identical response to "does not exist"guardian-portal.service.ts
PortalPupil has no assignments yet publishedNew term, nothing assignedEmpty assignments section, no errorreports-portal.service.ts
Maintenance sweepObject delete succeeds, row delete fails (or vice versa)Storage/DB transient errorStorage always attempted first; a failure leaves the tracking row for the next sweep pass rather than losing track of an orphanreport-maintenance.service.ts:143

Completion Checklist

  • Every registered report (64 across 15 domains + the builder) listed with kind/scope.
  • Every actor's allowed/forbidden behavior documented, including the two new portal audiences.
  • Every major flow (live run, builder, dashboards, saved reports/templates, schedules, export request/download, overview/year-closing, profiles, portal, schedule sweep, maintenance sweep) has steps, branches and, where useful, a diagram.
  • All three lifecycles (job status, schedule status, saved/template/dashboard version) have transition tables.
  • Every flow links to the relevant API and backend sections.

See Also

  • Backend doc: /docs/developer/reports/backend
  • API doc: /docs/developer/reports/api

On this page

Reports & Analytics — Features and Flows1. Documentation Evidence2. Feature Summary3. Actor Matrix4. Capability Matrix5. User-Facing Flows5.1 Browsing and running a report liveSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.2 Composing a custom report with the builderSummaryPreconditionsMain FlowBranches and Edge Cases5.3 Building and rendering a dashboardSummaryMain FlowBranches and Edge Cases5.4 Saving a report configuration, and school-wide templatesSummaryMain FlowBranches and Edge Cases5.5 Scheduling a recurring exportSummaryPreconditionsMain FlowBranches and Edge Cases5.6 Requesting and downloading a background exportSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.7 Scheduled report delivery (fully automated)SummaryMain FlowBranches and Edge Cases5.8 Reviewing the cross-domain overview and closing an academic yearSummaryMain FlowBranches and Edge Cases5.9 Opening a student, class or teacher profileSummaryMain FlowBranches and Edge Cases5.10 Pupil and guardian portal reportsSummaryMain FlowBranches and Edge Cases6. Admin Flows6.1 Cancel6.2 Retry6.3 Delete artifact (early expiry)6.4 Operational oversight (ReportJobs_READ/_UPDATE)6.5 Managing dashboards, saved reports, templates, schedules and settings7. Lifecycle and State Transitions7.1 Export job (report_jobs.status)7.2 Scheduled report (report_schedules.status)7.3 Saved report / template / dashboard (status, version)8. Business Rules and Policy Traceability9. Data and Side Effects by Flow10. Error and Recovery Flows11. Report Inventory (every registered report key, verbatim from the domain files)11.1 Students & Demographics (ReportsStudents_READ) — 9 reports11.2 Calendar — 1 report (unscoped)11.3 ID Cards (ReportsIdCards_READ) — 4 reports, all school_only11.4 Attendance (ReportsAttendance_READ) — 7 reports11.5 Leave (ReportsLeave_READ) — 3 reports, teaching_and_self_staff11.6 Academics / Exams (ReportsAcademics_READ) — 8 reports11.7 Assignments (ReportsAssignments_READ) — 3 reports, teaching11.8 Promotions (ReportsPromotions_READ) — 3 reports11.9 Staff & Timetable (ReportsStaff_READ) — 5 reports11.10 Communication (ReportsCommunication_READ) — 3 reports, all school_onlynotices.summary (analytic)notices.register (dataset)notifications.delivery (analytic)11.11 Complaints (ReportsComplaints_READ) — 2 reports, both school_only, cacheScope: "principal"complaints.summary (analytic)complaints.register (dataset)11.12 Finance (ReportsFinance_READ) — 7 reports, all school_only. Money is integer paisa end to end — every paisa-typed field is a summed stored column, never a float recomputation.finance.fees_overview (analytic)finance.fee_balances (dataset)finance.aging (analytic)finance.collections (analytic)finance.fee_categories (analytic)finance.payroll (analytic)finance.assets (analytic)11.13 Cross-Domain — 7 reports, all analyticcross.attendance_vs_assignments (teaching, ReportsAttendance_READ + ReportsAssignments_READ)cross.leave_vs_attendance (teaching, ReportsLeave_READ + ReportsAttendance_READ)cross.attendance_vs_performance (teaching, ReportsAttendance_READ + ReportsAcademics_READ, requires examId)cross.assignments_vs_performance (teaching, ReportsAssignments_READ + ReportsAcademics_READ, requires examId)cross.attendance_vs_promotion (teaching, ReportsAttendance_READ + ReportsPromotions_READ, requires sessionId)cross.enrollment_vs_promotion (teaching, ReportsStudents_READ + ReportsPromotions_READ, requires sessionId)cross.complaints_by_class (school_only, ReportsComplaints_READ + ReportsStudents_READ, cacheScope: "principal")11.14 Academic Year — 1 reportyear.comparison (analytic, school_only, requires: [], cacheScope: "principal")11.15 Custom builder — 1 definition11.16 Packages (multi-report export bundles)12. Tradeoffs and Product Rationale13. Flow Edge-Case MatrixCompletion ChecklistSee Also