Naalya Handbook

Analytics

Read-only aggregates behind one controller — computed in domain services over tenant-scoped repositories, with a shared 30-day date-range convention.

Analytics is a thin fan-out: one controller at analytics/*, guarded by READ on Resource.ANALYTICS (audit-log analytics alone checks Resource.AUDIT_LOG), delegating every route to the owning domain service — students/staff/guardians/guests to the profile services, applications to admissions, classes to the class service, inquiries to enquiry, audit logs to the audit service. There's no cache layer; each request aggregates live.

Route familyDelegates to
students, staff, guardians, guests (+ /campus/:campusId)Profile services' getAnalytics
applications (+ campus)Admission application service
classes, classes/:classId (+ campus)Class service (overview + per-class detail)
inquiries, inquiries/by-period, inquiries/by-staffEnquiry service
audit-logsAudit-log service (allow/deny split, top actors, timeline)

The tenant-safety pattern

This module's real convention — the one to copy — came out of an overhaul that replaced raw SQL with tenant-scoped repository query builders. Aggregations are built on this.enrollmentRepository.createQueryBuilder(...) and friends, so the fail-closed school_id injection applies to every COUNT exactly as it does to every list read. Contrast the two places that legitimately can't do this — the knowledge-base chunk search and the payment-charge worker — where raw SQL binds school_id by hand. If you're writing an aggregate and reaching for dataSource.query, stop and build it on a scoped repository instead.

The queries themselves follow a house shape: parallel Promise.all aggregations, innerJoin to campus_x_academic_year with is_current = true where "the current year" matters, COUNT(DISTINCT …), explicit deleted_at IS NULL guards, and an optional campus_id filter when the campus-scoped route is used.

The shared date-range convention

Every timeline endpoint extends AnalyticsTimelineQueryDto — optional ISO startDate / endDate — and resolves it through resolveAnalyticsDateRange: defaults to the last 30 days, and hands back endDatePlusOne so queries can use the half-open [start, end + 1) range instead of fiddling with end-of-day timestamps. Eight services share it (audit-log, the four profile analytics, class ×2, admissions, enquiry ×2); response shapes are shared DTOs too (TimelineDataPointDto { date, count }, campus/grade-level distributions, status breakdowns). A new analytics endpoint should reuse all three pieces rather than inventing its own range logic.

Where to go next

On this page