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 Type | Files or Docs | What Was Extracted |
|---|---|---|
| Backend doc | /docs/developer/reports/backend | Service behavior, persistence, cache, jobs, state transitions |
| API doc | /docs/developer/reports/api | Route surface, actors, auth |
| Domain report files | apps/api/src/modules/reports/domains/*/*.reports.ts, builder/builder.reports.ts | Every registered report key, scoped to each domain and read this session |
| Controllers | All 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
| Field | Value |
|---|---|
| Module | Reports & Analytics |
| Primary user value | A 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. |
| Actors | Admin-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 points | The 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 outputs | On-screen KPIs/charts/tables; downloadable CSV/XLSX/PDF files; in-app and email notifications when a background export finishes or fails |
| Related docs | API, Backend |
3. Actor Matrix
| Actor | Can Do | Cannot Do | Auth Requirement |
|---|---|---|---|
| Superadmin | Run 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_READ | Run/export that domain's reports school-wide; build custom queries on that domain's datasets; author school-wide templates and shared dashboards | Reports 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_READ | Run/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 way | Widen a filter beyond their teaching scope (403), see school-wide totals | JWT + active role + the listed codes |
A caller without ReportsContacts_READ | Everything above, minus phone/email columns/fields on directory reports and the builder | Request a contact column/field or sort by one (403 if explicitly requested) | as above |
| Dashboard owner | Create/edit/delete their own personal dashboards; read (never edit) school dashboards without ReportDashboardsShared | Author a school-scope dashboard without the shared code | ReportDashboards_* |
| Saved-report owner | Save, edit, archive, restore, delete their own configurations, up to 100 live | Edit or see another user's saved reports | SavedReports_* |
Template author (ReportTemplates_*) | Create/edit/delete school-wide templates | — | ReportTemplates_*; reading any template needs only Reports_READ |
| Schedule owner | Create, edit, activate/pause/archive, run-now, and delete their own scheduled reports; pick eligible recipients | Schedule a recipient who could not legitimately see the report (refused up front) | ReportSchedules_* |
| Settings admin | Read/update the one report_settings row | — | ReportSettings_* |
| Export-job requester | Create, list, cancel, retry, delete and download their own jobs | Download a job generated in another person's teaching/principal scope | ReportExports_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 needed | Cancel/retry someone else's job; download a teaching-scoped job that isn't theirs | ReportExports_READ |
Operator holding ReportJobs_READ/_UPDATE | See 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 separate | ReportJobs_READ/_UPDATE |
| Background worker (schedule sweep, generation, maintenance) | Turn a due schedule into a job; generate a job's file; expire/purge/recover jobs | Bypass the same authorisation a live run would face — every background run re-authorises against the requester's current permissions | Internal (no HTTP identity) |
| Pupil (portal) | See their own placements, attendance, results, assignments and promotions | See any other pupil's data, or anything permission-gated | JWT + active role, audience = student, no permission code |
| Guardian (portal) | See their own child's same five sections | See any child that is not theirs | JWT + active role, audience = guardian, no permission code |
4. Capability Matrix
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| Browse available reports | Admin | Any with Reports_READ | GET /reports/catalog | Registry + permissions | none | API §8.1 |
| Read the metric glossary | Admin | Any with Reports_READ | GET /reports/metrics | Static catalogue | none | API §8.2 |
| Populate the filter bar | Admin | Any with Reports_READ | GET /reports/filter-options | Reference tables, teaching scope | none | API §8.3 |
| Run a report live | Admin | Domain-code holder | POST /reports/run | Domain tables (one snapshot) | Redis cache (write) | API §8.4 |
| Bypass the cache | Admin | Domain-code holder | POST /reports/run with refresh: true | as above | Redis cache + rate-limit key | API §8.4 |
| See the cross-domain overview | Admin | Any with Reports_READ | GET /reports/overview | Every domain's summary tables | none | API §8.5 |
| Review year-closing checks | Admin | + ReportsSchoolWide_READ | GET /reports/year-closing | Every domain's summary tables | none | API §8.6 |
| Open a student/class/teacher profile | Admin | Any with Reports_READ | GET /reports/profiles/... | Several underlying reports | none | API §8.7 |
| Discover buildable datasets | Admin | ReportBuilder_READ | GET /reports/builder/datasets | Registry + permissions | none | API §8.8 |
| Preview a custom query | Admin | ReportBuilder_READ | POST /reports/builder/preview | The chosen dataset's tables | Redis cache | API §8.9 |
| List/create/edit/delete a dashboard | Admin | ReportDashboards_* | /reports/dashboards* | report_dashboards | insert/update/soft-delete | API §8.10 |
| Render a dashboard | Admin | ReportDashboards_READ | POST /reports/dashboards/:id/render | Every widget's underlying report | Redis cache (per widget) | API §8.10 |
| Save/edit/archive/restore/delete a saved report | Admin | SavedReports_* | /reports/saved* | report_definitions (kind=saved) | insert/update/soft-delete | API §8.11 |
| Create/edit/delete a school template | Admin | ReportTemplates_* | /reports/templates* | report_definitions (kind=template) | insert/update/soft-delete | API §8.11 |
| Create/edit/activate/pause/archive/run-now/delete a schedule | Admin | ReportSchedules_* | /reports/schedules* | report_schedules(+recipients) | insert/update/soft-delete, report_jobs insert (run-now) | API §8.12 |
| Read eligible recipients | Admin | ReportSchedules_CREATE | GET /reports/schedules/recipient-options | Users, roles, canDownload predicate | none | API §8.12 |
| Read/update report settings | Admin | ReportSettings_* | /reports/settings | report_settings | update + version bump | API §8.13 |
| Request a background export | Admin | ReportExports_CREATE holder | POST /reports/jobs | as above | report_jobs row, outbox_events row | API §8.15 |
| Request a multi-report package | Admin | ReportExports_CREATE holder | POST /reports/jobs with package.* key | as above, per part | report_jobs row | API §8.15 |
| Track an export's progress | Admin | Requester/recipient/ReportJobs_READ | GET /reports/jobs, GET /reports/jobs/:id | report_jobs | none | API §8.14, §8.16 |
| See why an export failed | Admin | Requester/recipient/ReportJobs_READ | GET /reports/jobs/:id/events | report_job_events | none | API §8.17 |
| Cancel a running export | Admin | Requester/ReportJobs_UPDATE | POST /reports/jobs/:id/cancel | report_jobs | report_jobs.status | API §8.18 |
| Retry a failed export | Admin | Requester/ReportJobs_UPDATE | POST /reports/jobs/:id/retry | report_jobs | report_jobs.status, retry_count, new outbox event | API §8.19 |
| Delete a file early | Admin | Requester/ReportJobs_UPDATE | DELETE /reports/jobs/:id/artifact | report_jobs | report_jobs.status → expired, storage delete | API §8.20 |
| Download a finished export | Admin | Requester or eligible recipient | GET /reports/jobs/:id/download | report_jobs, storage | download_count, event, audit record | API §8.21 |
| See my own reports | Pupil | Self (audience student) | GET /api/mobile/student/reports | Own attendance/results/assignments/promotions | none | API §8.22 |
| See my child's reports | Guardian | Self (audience guardian) | GET /api/mobile/guardian/children/:id/reports | Child's attendance/results/assignments/promotions | none | API §8.23 |
| Automatic scheduled export | Worker | Schedule sweep | Cron, every minute | report_schedules | report_jobs row, report_schedules.next_run_at/last_run_at | Backend §7.5 |
| Automatic file expiry | Worker | Maintenance sweep | Cron, every 15 minutes | report_jobs | status → expired, artifact_key → null | Backend §7.6 |
| Automatic orphan cleanup | Worker | Maintenance sweep | Cron, every 15 minutes | report_job_objects | row deleted, storage object deleted | Backend §7.6 |
| Automatic history purge | Worker | Maintenance sweep | Cron, every 15 minutes | report_jobs | row deleted (once no objects remain) | Backend §7.6 |
| Automatic lost-delivery recovery | Worker | Maintenance sweep | Cron, every 15 minutes | report_jobs | status → queued, recovery_count incremented | Backend §7.6 |
| Automatic schedule pause on repeated failure | Worker | Schedule sweep / generation | On the Nth consecutive failure | report_schedules, report_settings.schedule_failure_alert_threshold | status → paused, notification | Backend §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_READand 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
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | User | Opens Reports, browses the catalog | GET /reports/catalog returns only runnable reports | API §8.1 |
| 2 | User | Opens GET /reports/filter-options implicitly (page load) | Session/grade/class/subject/etc. options populate, classes narrowed to teaching scope | API §8.3 |
| 3 | User | Chooses filters, clicks "Run" | POST /reports/run | API §8.4 |
| 4 | Backend | Authorises, resolves filters, checks cache | Cache hit → instant; miss → computes | Backend §7.1–7.2 |
| 5 | Backend | Runs the provider inside one read-only snapshot | KPIs, charts, table, warnings assembled | Backend §6.4 |
| 6 | User | Reads 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
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| No teaching assignment | A teacher with no class this session runs a teaching-scoped report | Empty result, appropriate warning | 200, empty table |
Report is heavy and >1 session selected | e.g. comparing three academic years live | Refused; the user must export instead | 409 REPORT_HEAVY_USE_EXPORT |
| Cache hit | Identical request within the TTL | cached: true, no database read | 200 |
| Second refresh within 10s | User clicks "refresh" twice quickly | Refused | 429 REPORT_REFRESH_RATE_LIMITED |
| Database under load | Semaphore or timeout exhausted | Refused with a message suggesting export | 503 REPORT_BUSY / REPORT_QUERY_TIMEOUT |
| Small demographic group | A gender/ethnicity/mother-tongue/disability/age cell has fewer than min_group_size people | Cell blanked, suppression warning | 200, 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_READplus the domain codes of every dataset they want to query.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | User | Opens the builder | GET /reports/builder/datasets returns queryable datasets and fields | API §8.8 |
| 2 | User | Picks a dataset, columns, filters, grouping | Client validates shape only | — |
| 3 | User | Clicks "Preview" | POST /reports/builder/preview | API §8.9 |
| 4 | Backend | Validates the body against the dataset's frozen field list, authorises, runs | Same authorise → cache → query path as any report | Backend §6.9 |
| 5 | User | Saves the query as a saved report, or schedules it | POST /reports/saved / POST /reports/schedules with reportKey: "builder.custom" and the query in config.builder | Flows 5.4/5.5 |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Aggregate-only field used as a plain column | e.g. a running total outside aggregates | Refused | 400 REPORT_BUILDER_INVALID |
| Demographic field in a row filter | e.g. filtering individual rows by ethnicity | Refused — filtering, not grouping, on identity re-identifies individuals | 400 REPORT_BUILDER_INVALID |
| Identifying field grouped alongside a demographic field | e.g. grouping by name and ethnicity together | Refused — defeats suppression | 400 REPORT_BUILDER_INVALID |
| Small group in the result | A group's row count is under min_group_size | The whole group is dropped, not blanked | 200, fewer rows than expected |
school_only dataset, non-school-wide caller | e.g. staff distribution as a builder dataset | Dataset 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
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | User | Creates a dashboard, adding widgets with a grid position each | POST /reports/dashboards | API §8.10 |
| 2 | User | Opens the dashboard | POST /reports/dashboards/:id/render with any filter overrides | API §8.10 |
| 3 | Backend | Runs every non-shortcut widget concurrently (bounded), each through the normal report path | Per-widget result or error | Backend §6.10 |
| 4 | User | Clicks a shortcut widget | Client navigates to the widget's stored target (a report, saved report, or template) | — |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| One widget's report fails (timeout, forbidden) | e.g. a permission revoked since the widget was added | That widget's tile shows its own error | The rest of the dashboard still renders |
| School-scope dashboard, caller lacks the shared code | Trying to create/edit one | Refused | 403 REPORT_DASHBOARD_SCOPE_FORBIDDEN |
| Reading someone else's school dashboard | Caller holds ReportDashboards_READ but not the shared code | Visible, editable: false | 200 |
Deleting with a stale expectedVersion | Someone else edited the dashboard first | Refused | 409 REPORT_VERSION_CONFLICT (only if expectedVersion was supplied — it is optional on delete) |
| Name collision within scope | Two personal dashboards, same owner, same name | Refused | 409 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
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | User | Runs a report, clicks "Save" | POST /reports/saved | API §8.11 |
| 2 | User | Reopens it later | GET /reports/saved/:id, or lists via GET /reports/saved | API §8.11 |
| 3 | User | Edits it | PATCH /reports/saved/:id with the current version | API §8.11 |
| 4 | User | No longer needs it but wants to keep history | POST /reports/saved/:id/archive | API §8.11 |
| 5 | User | Changes their mind | POST /reports/saved/:id/restore | API §8.11 |
| 6 (template author) | User | Publishes the same configuration for everyone | POST /reports/templates | API §8.11 |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Name already used (same owner, same kind, live) | Case-insensitive collision | Refused | 409 REPORT_SAVED_NAME_TAKEN / REPORT_TEMPLATE_NAME_TAKEN |
| 100 saved reports already | Trying to save a 101st | Refused | 409 REPORT_SAVED_LIMIT_REACHED |
| Stale edit | Someone/something else changed it first (two tabs) | Refused | 409 REPORT_VERSION_CONFLICT |
| A permission was later revoked | Listing a saved report/template whose codes the caller no longer holds | Still listed | runnable: false, not deleted |
| Restoring a report that isn't archived | Wrong state for the action | Refused | 409 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_CREATEand every code the target report needs.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | User | Opens the recipient picker | GET /reports/schedules/recipient-options?reportKey=... | API §8.12 |
| 2 | User | Creates the schedule with cadence + recipients | POST /reports/schedules | API §8.12 |
| 3 | Backend | Validates config, cadence fields, and every recipient's eligibility | Refused up front if any is ineligible | Backend §6.12 |
| 4 | User (optional) | Wants an immediate copy without waiting for the next slot | POST /reports/schedules/:id/run-now | API §8.12 |
| 5 | Cron | The schedule's own cadence fires it automatically | See Flow 5.7 | Backend §7.5 |
| 6 | User | Pauses, reactivates, or archives it later | POST .../pause / .../activate / .../archive | API §8.12 |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Named recipient cannot legitimately see the report | e.g. a role with no matching teaching assignment | Refused at create/update time, before the row is written | 400 REPORT_SCHEDULE_RECIPIENTS_INVALID |
Weekly cadence with no weekday | Missing the field the frequency requires | Refused | 400 REPORT_SCHEDULE_INVALID |
| Run-now on a paused schedule | Valid — run-now does not require active | Creates a manual-origin job immediately | 202 Accepted |
| Activating an already-archived schedule | Invalid transition | Refused | 409 REPORT_SCHEDULE_TRANSITION_INVALID |
| Deleting a schedule | Any state | Always ends archived, soft-deleted | 200 |
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_CREATEand every code the target report(s) need.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | User | Clicks "Export", chooses format | POST /reports/jobs | API §8.15 |
| 2 | Backend | Plans and authorises (same rules as a live run) | AuthorizedRun[] built | Backend §7.3 |
| 3 | Backend | Inserts the job row + outbox event in one transaction | Job queued | Backend §5.2 |
| 4 | Worker | Outbox dispatcher relays to BullMQ; ReportsQueueProcessor claims the job | Job processing | Backend §7.4 |
| 5 | Worker | Reads every part in one snapshot, streams dataset rows in 1000-row batches, renders CSV/XLSX/PDF, uploads | Job completed/completed_with_warnings | Backend §7.4 |
| 6 | Backend | Sends report.ready (or report.scheduled_ready) notification | User notified in-app (+ email for scheduled) | Backend §10 |
| 7 | User | Downloads the file | GET /reports/jobs/:id/download | API §8.21 |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Duplicate submit | Same Idempotency-Key, same body, double-click | Returns the existing job, no second job created | 202, "Export already requested." |
| Duplicate submit, different body | Same key reused for a different request | Refused | 409 REPORT_JOB_IDEMPOTENCY_CONFLICT |
| 5 active jobs already | User tries a 6th | Refused; must wait for one to finish | 429 REPORT_JOB_LIMIT_REACHED |
| Generation fails, retryable | A transient database/storage error, attempts remain, not a repeat timeout | Job returns to queued; BullMQ redelivers with backoff | No user action needed |
| Generation fails, terminal | Permission gone, validation failure, or a second timeout in a row | Job failed, notified, dead-letter record written | User can retry manually |
| Worker dies mid-generation | Lease expires with no finalize | Maintenance sweep reclaims it 5 minutes later and resends | Job returns to queued automatically |
| Cancel while processing | User cancels a running job | Row flips to cancelled immediately; the worker notices at its next 5-second checkpoint and cleans up | Downloaded file (if any) is discarded |
| Artifact expired | User waits past artifact_retention_days (default 7) | File deleted, row becomes expired, still shows historical row/warning counts | 410 REPORT_JOB_EXPIRED on download attempt |
| Package part unavailable to caller | Annual package includes a report the caller cannot see | That sheet is silently omitted, others still generated | PACKAGE_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
| Step | Actor/System | Action | Result |
|---|---|---|---|
| 1 | Cron | ReportsScheduler.scheduleTick fires every minute | reports.schedule_sweep enqueued (deduplicated by minute bucket) |
| 2 | Worker | ReportScheduleSweepService.run finds due schedules | Candidates locked one at a time, SKIP LOCKED |
| 3 | Worker | Rebuilds the owner's principal as of now | If the owner is inactive or no longer holds the run-as role, the schedule pauses instead of running |
| 4 | Worker | Plans the job exactly as a manual create would | Any authorisation failure pauses the schedule with a specific reason |
| 5 | Worker | Resolves recipients: expands role recipients to live members, drops anyone who could not download this exact job | Ineligible recipients recorded as recipient_skipped events, never silently dropped without a trace |
| 6 | Worker | Inserts the job, advances next_run_at to the next slot | Job proceeds through the normal generation flow (5.6) |
| 7 | Backend | On success, notifies the owner and eligible recipients (report.scheduled_ready, in-app + email) | |
| 8 | Backend | On repeated failure (configurable threshold, default 3), pauses the schedule and notifies only the owner | report.schedule_attention |
Branches and Edge Cases
| Branch | Condition | Behavior |
|---|---|---|
| Server was down for days | Schedule's next_run_at is far in the past | Only the latest due slot produces one job; no back-fill of missed runs |
| Recipient is a role | e.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 data | e.g. a teaching-scoped report and a recipient with no matching teaching assignment | Skipped, counted under recipient_skipped with a reason code, never sent a broken link |
| Requester-only content | The report's scope makes it meaningful only to the person who ran it | No recipients are ever notified or permitted to download it — only the schedule owner |
| Two API replicas sweep at once | Both pick up the same batch | SELECT ... 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
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | User | Opens the admin landing page | GET /reports/overview | API §8.5 |
| 2 | User | Clicks an alert (e.g. "3 classes below attendance threshold") | Navigates to the alert's reportKey pre-filtered | — |
| 3 | User | Before closing the year, opens the closing checklist | GET /reports/year-closing | API §8.6 |
| 4 | User | Resolves each blocking check | Re-opens year-closing to confirm it cleared | — |
Branches and Edge Cases
| Branch | Condition | Behavior |
|---|---|---|
| Caller lacks a domain's codes | e.g. no ReportsFinance_READ | That overview section is omitted from the response; that check/summary is omitted from year-closing entirely |
| Few registers were taken | The attendance rate is high but most class-day registers in the last 30 days were never taken | The 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_READ | Opening year-closing | Refused entirely — 403 REPORT_SCOPE_FORBIDDEN |
| A blocking check is still open | e.g. unpublished exam results | Shown 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
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | User | Opens a student/class/teacher from elsewhere in the panel | GET /reports/profiles/{students|classes|teachers}/:id | API §8.7 |
| 2 | Backend | Authorises and runs each section independently | Sections the caller cannot see are available: false | Backend §6.13 |
| 3 | User | Switches session (student/teacher profile only) | ?sessionId= re-fetches session-scoped sections | — |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Student profile, unknown/out-of-scope session | ?sessionId= not visible to caller | Refused | 404 REPORT_PROFILE_NOT_FOUND |
| Class profile, mismatched session | A class belongs to exactly one session | Refused (the class exists, the filter is incoherent) | 400 REPORT_FILTER_INVALID |
| Teacher profile, any session | Teacher identity is not session-scoped | Always resolves if the session itself exists | 200 |
| Subject teacher viewing a student's results | Caller is not the class teacher | Sees only published results; overall (cross-subject) results hidden unless caller is the class teacher | 200, narrowed sections |
| Class sections grid | Non-school-wide caller | Grid limited to their own visible sibling classes; small groups suppressed | 200 |
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
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Pupil | Opens "My Reports" | GET /api/mobile/student/reports | API §8.22 |
| 1' | Guardian | Opens a child's reports | GET /api/mobile/guardian/children/:studentId/reports | API §8.23 |
| 2 | Backend | Asserts audience, resolves identity (own row / resolveOwnChildId) | Own student row found, or 404 | Backend §6.14 |
| 3 | Backend | Assembles the five sections through the normal report path, as the pupil's own record | No permission gating — audience is the entire control | Backend §6.14 |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Guardian requests a child that is not theirs | studentId belongs to someone else's child | Refused, indistinguishable from "does not exist" | 404 STUDENT_NOT_FOUND |
Caller's role is not student/guardian | Wrong audience for the route | Refused | 403 (audience assertion) |
| Assignment not yet due | Due date in the future | Not counted as outstanding | Excluded from the count |
| Assignment still a draft | Teacher has not published it | Never shown to the pupil at all | Excluded from the count and section |
| Attendance "expected days" | Any school day in the future | Never counted | Bounded 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)
| From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|
| — | Create (manual/run-now) or schedule sweep | queued | Active-job cap not exceeded (manual only) | Outbox event written |
queued | Worker claims | processing | Row is still queued | Lease token issued, attempt_count+1 |
processing | Finalize, no warnings | completed | Lease still valid | Artifact stored, expires_at set, notification sent |
processing | Finalize, with warnings | completed_with_warnings | Lease still valid | Same, plus warnings recorded |
processing | Retryable failure, attempts remain, not 2nd timeout | queued | Lease still valid | attempt_failed event; BullMQ backoff redelivers |
processing / queued | Terminal failure | failed | Lease valid (processing) or none (queued, sweep giving up) | Notification sent; schedule failure counter incremented if scheduled |
queued / processing | User cancel | cancelled | Row still active | Worker notices at next checkpoint if processing |
failed | User retry | queued | retry_count < 10 | New outbox delivery |
completed* | Retention elapsed, or user deletes artifact | expired | — | Storage object removed, artifact_key nulled |
| any terminal | History retention elapsed, no objects remain | (row deleted) | — | — |
7.2 Scheduled report (report_schedules.status)
| From | Event | To | Guard Condition | Side Effects |
|---|---|---|---|---|
| — | Created | draft, or active if activate: true | Valid cadence | next_run_at computed if active |
draft / paused | POST .../activate | active | Valid cadence, config still valid | next_run_at computed |
active | POST .../pause | paused | — | — |
active | Consecutive failures reach the configured threshold | paused | needs_attention = true, attention_reason = 'failures' | Owner notified once per pause episode |
active | Owner becomes inactive at sweep time | paused | attention_reason = 'owner_inactive' | Owner notified |
active | Owner no longer holds the run-as role, or lost the required permission | paused | attention_reason = 'owner_permission' | Owner notified |
active | The stored config no longer validates against the report | paused | attention_reason = 'invalid_config' | Owner notified |
active | An unexpected error during planning | paused | attention_reason = 'schedule_error', consecutive_failures+1 | Owner notified |
| any non-archived | POST .../archive, or deleted | archived (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)
| From | Event | To | Guard Condition | Side Effects |
|---|---|---|---|---|
| — | Create | active (or draft for a saved report explicitly requesting it) | Name unique in scope, under the per-owner cap (saved only) | version = 1 |
active/draft | PATCH | same status (or a new one, if included) | expectedVersion matches | version+1 |
active | POST .../archive (saved reports only; templates/dashboards are deleted, not archived) | archived | expectedVersion matches | archived_at set |
archived | POST .../restore | active | expectedVersion matches, currently archived | archived_at cleared |
| any | DELETE | (soft-deleted) | expectedVersion matches (dashboards: optional) | deleted_at set |
8. Business Rules and Policy Traceability
| Rule | Business Reason | Enforced In | Tests/Source |
|---|---|---|---|
A report requires Reports_READ and every domain code it names | One reporting area over every domain, gated per-domain rather than all-or-nothing | ReportAuthorizer.requiredCodes/holdsAll | Backend §7.1 |
A school_only report or builder dataset needs ReportsSchoolWide_READ regardless of domain codes | Some facts (ID card print runs, staff-wide distributions, cross-year progression, finance totals) are never meaningful per-class | ReportAuthorizer.authorize, ReportBuilderService.datasets | shared/report-authorizer.service.ts:108 |
| Filters can only narrow the caller's scope, never widen it | A teacher must not be able to type a class id they don't teach into a filter and see it anyway | ReportAuthorizer.checkNarrowingFilters | shared/report-authorizer.service.ts:290 |
| A contact column/field is excluded from defaults and 403s if explicitly requested without the contacts code | Phone/email are more sensitive than the rest of a directory row | ReportAuthorizer.resolveColumns/resolveSort, builder-compiler.ts | shared/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 builder | A single pupil's ethnicity, disability or mother tongue must not be inferable from a "group of 1" | suppressTable, builder-compiler.ts | shared/report-result.ts:169 |
A heavy report cannot run live across more than one session | Multi-year comparisons are expensive; force them through the export path, which has a longer timeout and doesn't compete with live screens | ReportAuthorizer.authorize | shared/report-authorizer.service.ts:173 |
A download re-checks the caller's current permissions against the job's stored required_codes | A role revoked after a file was generated must not still be able to fetch that file | canDownload | jobs/report-job-access.ts:77 |
| A teaching-scoped or principal-scoped job is downloadable only by its requester | The file's content depends on who ran it; a recipient seeing it out of context would be seeing data outside their own entitlement | isRequesterOnlyJob | jobs/report-job-access.ts:26 |
| A schedule recipient is checked against the same download rule twice: at create/update, and again before every run | Never send a link that then 404s for the recipient, and never let a since-revoked permission silently keep a recipient on a list | ReportScheduleRecipientsService, ReportScheduleSweepService.resolveRecipients | jobs/workers/report-schedule-sweep.service.ts:279 |
| A schedule only ever produces one job per due slot | A server outage must not turn into a burst of duplicate reports on restart | report_jobs_schedule_slot_unique, SKIP LOCKED per-row transaction | packages/db/src/schema/school/reports.ts |
| An idempotency key with a mismatched body is refused, not silently accepted | A double-submit with different filters must not resolve to whichever request happened to be first | ReportJobsService.create | jobs/report-jobs.service.ts:112 |
A job's filter_summary never contains a student/staff name or free-text search term | The persisted, widely-visible summary field must not leak identity | cleanText + PERSONAL_FILTER_KEYS exclusion | jobs/report-job-planner.service.ts:59 |
| Every export queue and sweep queue is isolated at concurrency 1, on its own BullMQ queue | A two-minute export must never delay notifications or the next minute's schedule tick | ReportsQueueProcessor/ReportsSweepProcessor @Processor(..., {concurrency:1}) | jobs/workers/*.processor.ts |
Every saved report, template, dashboard and schedule mutation requires the caller's expectedVersion | Two tabs editing the same saved query must not silently overwrite each other | SavedReportsService, ReportTemplatesService, ReportDashboardsService, ReportScheduleLifecycleService | saved/saved-reports.service.ts:282-296 |
| A builder query cannot filter rows on a demographic field, or group an identifying field alongside a demographic one | The row-level filter and the combined grouping are exactly the shapes that would re-identify a suppressed small group | resolveBuilderQuery | builder/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 composite | A caller missing one domain's codes should still see every section they can see | ReportCompositeAuthorizer | shared/report-composite-authorizer.service.ts |
| A guardian's/pupil's own reports carry no permission code at all | The 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 data | assertPortalAudience + identity-layer row lookup | portal/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 days | A pupil's own dashboard must not show a phantom missing assignment or an attendance shortfall for a day that hasn't happened yet | ReportsPortalService | portal/reports-portal.service.ts |
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Notifications |
|---|---|---|---|---|
| Live run / builder preview | none (read-only) | write on miss, read on hit | none | none |
| Dashboard render | none | per-widget write on miss, read on hit | none | none |
| Overview / year-closing / profile read | none | per-section write on miss, read on hit | none | none |
| Save/edit/archive/restore/delete saved report or template | report_definitions insert/update | none | none | none |
| Create/edit/delete dashboard | report_dashboards insert/update | none | none | none |
| Create/edit/activate/pause/archive/run-now/delete schedule | report_schedules(+recipients) insert/update; run-now also report_jobs+outbox_events insert | none | run-now enqueues reports.generate | none from these routes directly |
| Update settings | report_settings update, version+1 | invalidates every report cache key implicitly (version is part of the key) | none | none |
| Export create | report_jobs insert, outbox_events insert | none | enqueues reports.generate (via outbox) | none yet |
| Export generation success | report_jobs update (finalize), report_job_events insert, report_job_objects insert | none | none (terminal) | report.ready or report.scheduled_ready |
| Export generation failure (retryable) | report_jobs update (back to queued), report_job_events insert | none | BullMQ redelivers | none |
| Export generation failure (terminal) | report_jobs update (failed), report_job_events insert, job_failures insert | none | none | report.failed |
| Download | report_jobs update (download_count), report_job_events insert | none | none | none |
| Schedule sweep (success) | report_jobs insert, report_schedules update (next_run_at/last_run_at), report_job_events insert (skipped recipients, if any) | none | enqueues generation | (deferred to generation outcome) |
| Schedule sweep (pause) | report_schedules update (status/attention fields) | none | none | report.schedule_attention |
| Maintenance sweep | report_jobs update (expired/failed), report_job_objects delete, report_jobs delete (purge) | none | none | none |
| Portal read (student/guardian) | none | write on miss, read on hit (per underlying report) | none | none |
10. Error and Recovery Flows
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| Report capacity exhausted | Too many concurrent live runs | 503 REPORT_BUSY on the run request | User retries shortly, or exports instead | ReportQueryService |
| Query too slow | Statement or wall-clock timeout | 503 REPORT_QUERY_TIMEOUT, message suggests exporting | User exports (longer timeout, keyset streaming) | ReportQueryService |
| Worker crash mid-export | Process dies holding a lease | Job appears stuck at some progress percentage | Maintenance sweep reclaims it after 5 minutes' grace and resends | report-maintenance.service.ts |
| Delivery lost between outbox and queue | Outbox row relayed but the BullMQ job vanished (broker restart, etc.) | Job stuck queued past 15 minutes | Maintenance sweep detects no live BullMQ job and resends | report-maintenance.service.ts |
| Undeliverable job | 5+ recoveries and still not generated after 24h | Job would otherwise loop forever | Marked failed with error_category: internal, owner notified | report-maintenance.service.ts |
| Redis unavailable | Cache/rate-limit backend down | Reports/builder/dashboards still run, just uncached; refresh rate limit fails open | Automatic — no manual recovery needed | ReportCacheService |
| Schedule repeatedly fails | Consecutive-failure threshold reached | Schedule auto-pauses rather than retrying forever | Owner reviews and reactivates manually | report-schedule-sweep.service.ts, report-job-lifecycle.service.ts |
| Two tabs edit the same saved report/dashboard/schedule | Concurrent PATCH with the same starting expectedVersion | The second request's version no longer matches | 409 REPORT_VERSION_CONFLICT — client refetches and reapplies | saved-reports.service.ts and equivalents |
| Recipient's eligibility changes between schedule creation and a run | A role or permission was revoked in the meantime | Recipient silently skipped for that run, recorded as an event | Owner sees no missing-recipient error; the event log explains it | report-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
| Key | Kind | Scope |
|---|---|---|
students.directory | dataset | teaching |
students.status_summary | analytic | teaching |
students.admissions | analytic | teaching |
demographics.gender | analytic | teaching |
demographics.age | analytic | teaching |
demographics.grade_distribution | analytic | teaching |
demographics.section_occupancy | analytic | teaching |
demographics.enrollment_trend | analytic | school_only |
demographics.classification | analytic | school_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 whoseexpiresAthas passed as ofctx.now),draft(count, backlog),readRatePct(percent,metricKey: "notices.read_rate"). - Charts:
byPriority(bar, one bar per notice priority);byBsMonth(bar, two series —publishedcount andreadRatePct— bucketed by the BS month ofpublishedAtin 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_ROWSwhen 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 asreadRatePct, not recomputed as a KPI here). - Supports
streamRowsfor 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_ROWSwhen 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 pastdueAt),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 ofsubmittedAt),bySubmitterKind(bar — anonymous complaints are grouped under"anonymous"rather than their real submitter kind),byGradeSection(bar, suppressed: built viasuppressTableoncountwithmin_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:
byGradeSectionchart data is small-group suppressed; aGROUPS_SUPPRESSEDwarning is added when any cell is blanked. No money fields. - Empty case:
NO_SOURCE_ROWSwhen there are zero visible rows.
complaints.register (dataset)
- Requires:
ReportsComplaints_READandComplaints_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;submittedAtsortable),isOverdue(boolean),reopenCount(number),escalationLevel(number),gradeName(text),sectionName(text). - Sortable:
reference,submittedAt. - Every row is filtered through the same
ComplaintAccessService.listVisibilityWherethe summary uses, so the register never lists a complaint the caller could not otherwise see; supportsstreamRowsfor export. - Empty case:
NO_SOURCE_ROWSwhen 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 intofinance.fee_balancesfiltered ongradeIds),grossBilled,scholarships,charges,adjustments,waivers,netBilled,collected,outstanding,overdue(allpaisa),collectionRatePct(percent),studentsWithOutstanding(number). - KPIs:
grossBilled,scholarships,charges,adjustments,waivers,netBilled,collected(allpaisa),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_GENERATEDwarning when there are zero fee obligations for the selection (a distinct warning fromNO_SOURCE_ROWS, naming the specific missing precondition — fee generation, not merely empty data).
finance.fee_balances (dataset)
- Requires:
ReportsFinance_READandFeeExports_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 nosummary). - Empty case:
NO_SOURCE_ROWS.
finance.aging (analytic)
- Filters:
sessionId,gradeIds. - Columns (one row per grade):
gradeName(text),notYetDue,d1To30,d31To60,d61To90,d91Plus,noDueDate(allpaisa— 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 centralMETRIC_DEFINITIONScatalogue. - Empty case:
NO_SOURCE_ROWSwhen bothpaymentsandreversalstotals 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_READandSalaryDashboard_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(allpaisa). - 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 ofheadcount/gross/deductions/net/paid(all set tonull) for any period whose headcount falls undermin_group_size, because a payroll figure for a handful of staff would otherwise reveal an individual's pay. AGROUPS_SUPPRESSEDwarning (with the suppressed-cell count) is added whenever this fires, alongsideNO_SOURCE_ROWSif there are no periods at all.
finance.assets (analytic)
- Requires:
ReportsFinance_READandAssets_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_ROWSif 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_ROWSif no paired pupils; otherwise the sharedcorrelationWarnings(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:
emptyResultwithNO_SOURCE_ROWSwhen 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(notNO_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_ROWSwhen 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 onComplaintAccessService. - 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:
complaintsand its dependentper100Studentsare 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:
emptyResultwhen 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 againstctx.principaldirectly (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 anemptyResultwith warningSOURCE_NOT_CONFIGUREDand no principal-gating even runs). -
Columns: one fixed
metric(text) column, plus one dynamically-namedsession_{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 row Label Gating code Computation enrollment.activeActive enrolment ReportsStudents_READenrollmentActive(session.id)attendance.rateAttendance rate ReportsAttendance_READattendanceRatePct(session.id, session.startDate, session.endDate)exams.pass_rateFinal exam pass rate ReportsAcademics_READfinalExamAggregate(session.id).passRatePctexams.average_percentageFinal exam average % ReportsAcademics_READfinalExamAggregate(session.id).averagePercentagepromotions.promotion_ratePromotion rate ReportsPromotions_READpromotionRatePct(session)assignments.completion_rateAssignment completion rate ReportsAssignments_READassignmentsCompletionRatePct(session.id)complaints.submittedComplaints submitted ReportsComplaints_READcomplaintsSubmitted(session.id)— a plain count, not a formula inMETRIC_DEFINITIONSfees.collection_rateFee collection rate ReportsFinance_READfeesCollectionRatePct(session.id)id_cards.coverage_rateID card coverage rate ReportsIdCards_READidCardsCoverageRatePct(session.id) -
Metrics referenced: every row above except
complaints.submitted(which has no entry inMETRIC_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 Decision | User Benefit | Engineering Benefit | Tradeoff |
|---|---|---|---|
| Catalog and builder-dataset lists show only what's runnable, never a "locked" placeholder | No confusing dead entries a teacher can never open | Simpler client — no separate "can I run this" check | A 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 minutes | Live screens stay snappy | Protects the shared connection pool from being monopolised by one heavy query | A genuinely large live query is refused rather than allowed to run slowly — the user must export |
| Small-group suppression is unconditional, not a toggle | Nobody can accidentally expose one child's ethnicity by narrowing filters far enough | One code path (blank a cell, or drop a builder group), no per-report opt-out to get wrong | Some 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 allowed | Prevents the exact composition that would defeat suppression | No per-query manual review needed | A 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-fill | Nobody wakes up to twelve identical reports after a long weekend outage | Simpler 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 codes | A revoked role cannot retrieve data through an old, already-generated file | No separate "invalidate old exports" sweep needed | A 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 people | One consistent pattern across four surfaces | A client that never fetches the current version cannot mutate at all — it must always read before writing |
| The pupil/guardian portal carries no permission code | Nobody can accidentally (or maliciously) be granted access to a child that isn't theirs — identity is the whole gate | No permission catalogue entry to keep in sync for a purely per-person relationship | An 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
| Flow | Edge Case | Trigger | Expected Behavior | Source |
|---|---|---|---|---|
| Live run | Empty scope | Teacher with no classes this session | Empty table, appropriate warning | Per-domain run() |
| Live run | Duplicate rapid requests | Same filters, same user, within TTL | Second request served from cache, cached: true | ReportCacheService |
| Live run | Concurrent identical misses | Two tabs open the same report at once, cache empty | Both share one computation (inFlight map), only one database read | ReportCacheService.getOrCompute |
| Builder preview | Small group in result | A grouped combination has fewer rows than min_group_size | Group dropped entirely, not blanked | builder-compiler.ts |
| Dashboard render | One widget's underlying report is heavy and multi-session by its stored default_filters | Config predates a settings change | That widget errors with REPORT_HEAVY_USE_EXPORT; the rest of the dashboard still renders | ReportDashboardRenderService |
| Overview/profile | Caller lacks one domain's code | Reading the overview or a profile | An overview section is omitted and a profile section is returned with available: false; the rest of the screen renders normally | ReportCompositeAuthorizer |
| Saved report/dashboard/schedule edit | Concurrent edit | Two tabs, same starting version | Second PATCH sees 409 REPORT_VERSION_CONFLICT | respective service |
| Export create | Concurrent double-click | Two rapid submits, no idempotency key | Both may create separate jobs (no dedupe without a key) unless the active-job cap or a matching Idempotency-Key intervenes | ReportJobsService.create |
| Export create | Race on the active-job cap | Two tabs both at 4 active jobs, both submit a 5th | The pg_advisory_xact_lock per user serialises them — the second sees 5 active and is refused | jobs/report-jobs.service.ts:97 |
| Export generation | Job cancelled the instant it's claimed | Cancel request lands between claim and the first checkpoint | Next checkpoint write finds 0 rows, throws ReportLeaseLost, worker stops silently | report-job-lifecycle.service.ts |
| Export generation | Permissions stale after a long queue wait | A job waits an hour behind others, then generates | Principal is rebuilt with current permissions at generation time, not creation time — a role revoked in that hour produces a permission failure, not a leaked file | ReportGenerationService.build |
| Schedule sweep | Schedule deleted between candidate read and row lock | deleted_at set concurrently | SELECT ... FOR UPDATE re-checks deleted_at IS NULL; row simply not found, sweep moves on | report-schedule-sweep.service.ts:139 |
| Schedule create | Recipient eligible at creation, ineligible by the first run | A permission is revoked in between | Create succeeds; the run silently skips them with a recipient_skipped event | report-schedule-recipients.service.ts |
| Download | Job belongs to another teacher's class | Non-requester, non-recipient tries the id | 404 REPORT_JOB_NOT_FOUND — never 403, so job ids are not an existence oracle | jobs/report-job-access.ts |
| Portal | Guardian tries a studentId that is not their child | Wrong id | 404 STUDENT_NOT_FOUND — identical response to "does not exist" | guardian-portal.service.ts |
| Portal | Pupil has no assignments yet published | New term, nothing assigned | Empty assignments section, no error | reports-portal.service.ts |
| Maintenance sweep | Object delete succeeds, row delete fails (or vice versa) | Storage/DB transient error | Storage always attempted first; a failure leaves the tracking row for the next sweep pass rather than losing track of an orphan | report-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