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:
@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
| Entity | Module | What the year means for it |
|---|---|---|
campus_term | term | Terms 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. |
enrollment | enrollment | A 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). |
grade | grade | Also cross-checks that its enrollment belongs to the same year before writing. |
assessment, grading_config | grading | Config 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_examination | cbt | Exam writes additionally require the year to be current; attempts and stream assignments hang off the exam. |
payment_item, payment_charge | payment-item | Charge generation targets only that year's active enrollments; compulsory-sibling deactivation stays within the year. |
report | report | Report cards are per year by definition. |
scheme_of_work, lesson_plan | lesson-planning | Plan 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:
| Data | Why it has no year |
|---|---|
class, stream, subject, department, curriculum | Structural 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_year | Global and cross-tenant — "2025/2026" exists once for every school; not even school-scoped. |
student_x_subject, cbt_attempt + answers, transaction | Children — 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:
- Column + migration: non-null
campusAcademicYearIdFK →campus_x_academic_year(id), plus a denormalisedcampusIdfor fast filters and CASL. No derived time flags, no gating fields — the year'slockedAtgoverns writes, nothing on your row. - 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). - DTOs:
campusAcademicYearIdrequired (@IsUUID(), no@IsOptional) on create and list. - Service gates: inject
CampusAcademicYearScopeService;assertNotLockedon writes — the request's year for creates, the row's own year for mutations — andassertTermMatchesYearwhenever atermIdrides along.
Where to go next
Locking & Lifecycle
Creating, starting, and sealing academic years — the lock write-gate, transactional set-current, order-derived phase, and delete protection.
Auditing Actions
How every write leaves a redacted, append-only trace — captured on the server, written off the request path by a separate audit microservice.