Naalya Handbook
Academic-Year Scoping

Scoped Entities

The eleven entities pinned to a campus academic year, the structural data that deliberately is not, and children that scope through their parent.

An entity is year-scoped by convention, not by decorator. Where tenancy has @TenantScoped() wiring an entity into an automatic engine, year scope is two plain facts about the entity: it carries a non-null campusAcademicYearId FK, and it's registered in CAMPUS_YEAR_RESOURCES so delete protection counts it. That's the whole marking — which is exactly why the DTO rule and service gates from Request Scoping have to carry the enforcement weight.

The scope unit itself

Everything points at campus_x_academic_year, so its shape is worth knowing precisely:

apps/server/src/app/academic-year/entities/campus-x-academic-year.entity.ts
@Entity('campus_x_academic_year')
@TenantScoped()
export class CampusXAcademicYearEntity extends DatabaseEntity {
  @PrimaryColumn() campusId: string;        // composite key with academicYearId
  @PrimaryColumn() academicYearId: string;

  @Column() startDate: Date;                // campus-local dates —
  @Column() endDate: Date;                  // not the global year's

  @Column({ default: false }) isCurrent: boolean;

  /** Null means unlocked; locked years reject year-scoped mutations. */
  @Column({ type: 'timestamptz', nullable: true }) lockedAt: Date | null;
  @Column({ nullable: true }) lockedById: string | null;

  @Column({ type: 'jsonb' }) _campusSnapshot: Campus;          // frozen at creation
  @Column({ type: 'jsonb' }) _academicYearSnapshot: AcademicYear;
}

Three details that bite if you miss them: the (campusId, academicYearId) pair is a logical uniqueness (composite primary columns), enforced in service code rather than a dedicated DB constraint; schoolId is still nullable from the first rollout PR (the binding relies on @TenantScoped for isolation); and the JSONB snapshots are frozen at creation — they exist so historical rows keep rendering sensibly even if the campus or year is later renamed, and they are never refreshed.

The eleven

EntityModuleWhat the year means for it
campus_termtermTerms belong to the binding, so a term is campus-and-year specific by construction. Notably not @TenantScoped — it has no school_id column and isolates transitively through its parent binding.
enrollmentenrollmentA student's membership in a class for that year — re-enrollment each year is what "promotion" currently means. Carries the only list exemption (person history).
gradegradeAlso cross-checks that its enrollment belongs to the same year before writing.
assessment, grading_configgradingConfig is unique per (cay, level); both FKs are pinned with referencedColumnName: 'id' because TypeORM otherwise emits a broken composite FK against the cay's composite key — copy that @JoinColumn shape.
cbt_examinationcbtExam writes additionally require the year to be current; attempts and stream assignments hang off the exam.
payment_item, payment_chargepayment-itemCharge generation targets only that year's active enrollments; compulsory-sibling deactivation stays within the year.
reportreportReport cards are per year by definition.
scheme_of_work, lesson_planlesson-planningPlan scope is stamped from the parent scheme; both reject writes once the year locks.

Deliberately not scoped

The catalog a school teaches with persists across years — scoping it would force re-creating your whole structure every August:

DataWhy it has no year
class, stream, subject, department, curriculumStructural catalog. The link between a student and this structure (enrollment) is what's yearly, not the structure itself.
Profiles (students, staff, guardians)People outlive school years; their year-bound footprint lives in enrollments, grades, attempts.
academic_yearGlobal and cross-tenant — "2025/2026" exists once for every school; not even school-scoped.
student_x_subject, cbt_attempt + answers, transactionChildren — each hangs off a year-owned parent (enrollment, exam, charge) and inherits its scope transitively. Adding a redundant year column to a child invites drift.

Making an entity year-scoped

The four-step convention — the recipe walks it end to end with a worked example:

  1. Column + migration: non-null campusAcademicYearId FK → campus_x_academic_year(id), plus a denormalised campusId for fast filters and CASL. No derived time flags, no gating fields — the year's lockedAt governs writes, nothing on your row.
  2. Registry: one line in CAMPUS_YEAR_RESOURCES (academic-year-attachment.helper.ts) — this is what makes a year with your data undeletable (409 + attachment report).
  3. DTOs: campusAcademicYearId required (@IsUUID(), no @IsOptional) on create and list.
  4. Service gates: inject CampusAcademicYearScopeService; assertNotLocked on writes — the request's year for creates, the row's own year for mutations — and assertTermMatchesYear whenever a termId rides along.

Where to go next

On this page