Naalya Handbook
Academic-Year Scoping

Request Scoping

How campusAcademicYearId travels on every year-scoped request — the required-DTO rule, the one exemption, and the scope service behind every write.

The year is request data, not ambient context. Where the tenant rides invisibly in AsyncLocalStorage (see Multi-Tenancy), campusAcademicYearId is carried explicitly on each request — a query param on lists, a body field on writes — and every layer between HTTP and the database has a specific job in making that safe. This page walks that path.

Step 1: The DTO makes the year unforgettable

A DTO (Data Transfer Object — the typed, validated shape of a request) is where the rule lives. On every year-scoped list and create DTO, campusAcademicYearId is a required @IsUUID() with no @IsOptional:

apps/server/src/app/payment-item/dto/create-payment-item.dto.ts (same shape everywhere)
@IsUUID()
@ApiProperty()
campusAcademicYearId: string;

The global ValidationPipe enforces this at the HTTP boundary, which changes the failure mode in the way that matters: a client that forgets the year gets a 400 Validation failednever a silent all-years response. Compare that with tenant scoping, where forgetting is impossible because the engine injects the filter; here forgetting is impossible because the request won't parse. Two mechanisms, same fail-closed philosophy.

This is why there's no repository magic to learn: once the DTO guarantees the field exists, the service simply threads it into the query —

the service-side half of the contract
// reads: the year is just another predicate you merge into where
return this.gradeRepository.findAll({ where: { campusAcademicYearId, termId } });

BaseRepository adds school_id on top of whatever you pass; it never adds the year. If you leave campusAcademicYearId out of a where, you will get cross-year rows — the DTO rule is what makes that a can't-happen for HTTP callers.

Step 2: The one sanctioned exemption

There is exactly one read allowed to omit the year: a person-history view — one student's records across years, e.g. the enrollment history on a student's profile. EnrollmentListQueryDto encodes it with @ValidateIf, and the shape is worth reading closely because you must copy it exactly if you ever add another exemption:

apps/server/src/app/enrollment/dto/enrollment-list-query.dto.ts
// Required for every list except a single student's cross-year history —
// the person-record view is the one legitimate unscoped read. Still
// format-validated whenever a value is present, even alongside studentId.
@ValidateIf(
  (o: EnrollmentListQueryDto) =>
    !o.studentId || o.campusAcademicYearId !== undefined,
)
@IsUUID()
@ApiPropertyOptional({ description: 'Required unless studentId is provided.' })
campusAcademicYearId?: string;

Read the condition twice: the year is validated when there's no studentId (so it's effectively required), and also whenever a value was actually sent (so a malformed UUID next to a studentId still fails). If you copy this pattern, you must be able to say why your read qualifies as a person-history view — "the list is annoying to scope" does not qualify.

Step 3: The scope service gates the write

CampusAcademicYearScopeService (apps/server/src/app/academic-year/campus-academic-year-scope.service.ts) is injected into every year-scoped service. It's small enough to read whole — three asserts:

apps/server/src/app/academic-year/campus-academic-year-scope.service.ts
/** Write gate for year-scoped mutations: year must belong to campus and be unlocked. */
async assertNotLocked(campusAcademicYearId: string, campusId: string) {
  const cay = await this.assertBelongsToCampus(campusAcademicYearId, campusId);
  if (cay.lockedAt) {
    throw new BadRequestException('Academic year is locked');
  }
  return cay;
}
MethodChecksThrows
assertBelongsToCampus(cayId, campusId)Binding exists and belongs to the campus404 / 400
assertNotLocked(cayId, campusId)The above, then lockedAt == null400 "Academic year is locked"
assertTermMatchesYear(termId, cayId)The term belongs to that campus year404 / 400

The quiet security property: assertBelongsToCampus loads the binding through the tenant-scoped repository. A campusAcademicYearId belonging to another school simply doesn't exist from this tenant's point of view — the lookup 404s. That makes the scope service double as the IDOR guard (IDOR = Insecure Direct Object Reference: reaching another tenant's row by guessing its id) with zero extra code.

Step 4: Gate on the right year

The subtle rule, and the one reviewers check first. Creates gate on the request's year; mutations of existing rows gate on the row's own year. Both appear in GradeService.create:

apps/server/src/app/grade/grade.service.ts
// the enrollment must live in the same year the request claims
if (enrollment.campusAcademicYearId !== payload.campusAcademicYearId) {
  throw new BadRequestException(
    'Enrollment does not belong to the selected campus academic year',
  );
}
await this.campusAcademicYearScope.assertNotLocked(
  payload.campusAcademicYearId,   // ← the REQUEST's year: you're writing INTO it
  enrollment.campusId,
);
await this.campusAcademicYearScope.assertTermMatchesYear(
  payload.termId,
  payload.campusAcademicYearId,   // ← term must belong to the same year
);

…while a mutation loads the row first and gates on what it finds:

apps/server/src/app/enrollment/enrollment.service.ts
/** Rejects mutations when the enrollment's campus academic year is locked. */
private async assertEnrollmentYearUnlocked(enrollment: EnrollmentEntity) {
  await this.campusAcademicYearScope.assertNotLocked(
    enrollment.campusAcademicYearId,  // ← the ROW's own year, not the viewer's
    enrollment.campusId,
  );
}

Why the split matters: a teacher viewing the current year can still fix a record from last year — the write is judged by last year's lock, not by which year they happen to be looking at. And nothing anywhere gates on isCurrent; the lock is the only write authority (see Locking & Lifecycle).

Internal callers bypass the pipe

The ValidationPipe only guards HTTP. AI tools, cron jobs, and any code calling a service method directly skip DTO validation entirely — they must pass campusAcademicYearId themselves. A bare service.list({}) from an RPC tool silently returns cross-year data; that exact gap is why year-scoped agent tools take the year as a required input. See Adding an RPC tool.

Where to go next

On this page