Naalya Handbook
Recipes

Add a Year-Scoped Entity

The full path for campus-operational data that belongs to one academic year — API entity to Education Hub page, with the year handled in exactly three places.

Some data clearly belongs to one academic year: a field trip happens in a year; next year's trips are next year's records. This recipe walks that case end to end with a fictional Field Trips feature, using the real helper names from both repos — so it doubles as a template. Concepts live in Academic-Year Scoping; this is the build order.

Phase 1 — Backend

Step 1: Entity — the year FK is the spine

apps/server/src/app/field-trip/entities/field-trip.entity.ts
@Entity('field_trip')
export class FieldTripEntity extends BaseEntity {
  @Column()
  campusAcademicYearId: string; // non-null — every row belongs to exactly one campus year

  @Column()
  campusId: string;             // denormalised for fast filters and CASL

  @Column({ nullable: true })
  termId: string | null;        // optional term link

  @Column()
  title: string;
  // ...dates, destination, etc.
}

Two rules apply by convention: no derived time flags (past/current/future always comes from academic_year.order), and no gating fields on your row — the year's lockedAt governs writes, nothing else.

Step 2: Register for delete protection

One line in academic-year-attachment.helper.ts:

apps/server/src/app/academic-year/academic-year-attachment.helper.ts
const CAMPUS_YEAR_RESOURCES = [
  // ...existing eleven
  { resource: 'field_trip', entity: FieldTripEntity },
] as const;

Now a campus year with trips can't be deleted — the caller gets the 409 attachment report (field_trip: 12), which the frontend already renders.

Step 3: DTOs — the year is required, full stop

apps/server/src/app/field-trip/dto
export class CreateFieldTripDto {
  @IsUUID() @ApiProperty()
  campusAcademicYearId: string;

  @IsUUID() @ApiProperty()
  campusId: string;

  @IsUUID() @IsOptional() @ApiPropertyOptional()
  termId?: string;
}

export class ListFieldTripsQueryDto extends CursorPaginationQueryDto {
  @IsUUID() @ApiProperty()          // required — NOT @IsOptional
  campusAcademicYearId: string;
}

Forgetting the year client-side is now a 400, never a silent all-years response. The only sanctioned exemption is a person-history read — the EnrollmentListQueryDto @ValidateIf pattern — and you must be able to say why yours qualifies.

Step 4: Service — gate on the right year

apps/server/src/app/field-trip/field-trip.service.ts
constructor(
  private readonly fieldTripRepository: FieldTripRepository,
  private readonly campusAcademicYearScope: CampusAcademicYearScopeService,
) {}

async create(payload: CreateFieldTripDto) {
  await this.campusAcademicYearScope.assertNotLocked(
    payload.campusAcademicYearId, payload.campusId);   // the REQUEST's year
  if (payload.termId) {
    await this.campusAcademicYearScope.assertTermMatchesYear(
      payload.termId, payload.campusAcademicYearId);   // term ↔ year invariant
  }
  return this.fieldTripRepository.create({ ...payload });
}

async update(id: string, payload: UpdateFieldTripDto) {
  const trip = await this.findOne(id);
  await this.campusAcademicYearScope.assertNotLocked(
    trip.campusAcademicYearId, trip.campusId);         // the ENTITY's own year
  return this.fieldTripRepository.update(id, payload);
}

The subtle one: creates gate on the year you're creating into; mutations gate on the row's own year — staff can still close out last year's trip while viewing this year, as long as last year is unlocked. Never touch isCurrent in a gate.

Step 5: Controller, tests, migration

  • Controller: @ApiOperation({ operationId: 'listFieldTrips', ... }), list takes @Query() query: ListFieldTripsQueryDto — the global ValidationPipe makes the DTO rule real at the HTTP boundary.
  • Tests (house pattern — TestingModule + mocked repos): a locked-year rejection test per write path, asserting both the throw and that the repo write never ran; plus a plainToInstance/validate() spec for the DTO.
  • Migration: pnpm run migration:generate field_trip && pnpm run migration:run.

Internal callers bypass the pipe

An AI tool or cron job calling fieldTripService.list(...) directly skips DTO validation — it must pass the year itself. A bare list({}) silently returns cross-year data.

Phase 2 — The contract handoff

In Education-Hub run bun run api:generate. You now have FieldTripDto, CreateFieldTripBody, ListFieldTripsQueryParams in api.contracts.ts. Use those named types directly — never re-derive a row type by indexing into a list response.

Phase 3 — Frontend

Step 6: Query layer — year in the key, enabled gate

src/queries/field-trip/ with the standard trio (*.query.ts, *.options.ts, use-*.tsx):

src/queries/field-trip/field-trip.options.ts
list: (campusId: string, campusAcademicYearId: string, params: ListFieldTripsQueryParams) =>
  queryOptions({
    queryKey: fieldTripQueryKeys.list(campusId, campusAcademicYearId, params), // year IN the key
    queryFn: () => listFieldTrips({ ...params, campusAcademicYearId }),
    enabled: !!campusAcademicYearId, // hold, don't 400, while the year resolves
  }),

Year in the key means switching years refetches instead of serving another year's cached rows — the stale-gradebook bug class, prevented by construction.

Step 7: The page — and everything you don't write

src/routes/_authenticated/staff-hub/campus.$campusId/field-trips/index.tsx
function RouteComponent() {
  const { campusId } = Route.useParams();
  const { campusAcademicYearId, isUnlocked } = useViewingAcademicYear(); // the one hook
  // ...
}

Note what's absent: no year syncing, no param preservation, no store, no ring logic. The platform already does it — the ViewingAcademicYearProvider defaults the URL's campusAcademicYearId search param to the current year, retainSearchParams middleware carries it across every navigation, the sky ring appears on non-current years, and CampusNoCurrentYearShell covers the no-current-year case — all before your page renders a line. (The year switcher itself lives in the sidebar header dropdown; lock/unlock lives in the academic-year list tab, not the switcher.)

Step 8: The datatable — year as resetKey, never in params

const { params, onNext, onPrev } =
  useCursorParams<FieldTripFilters>(20, undefined, campusAcademicYearId); // 3rd arg = resetKey

const { fieldTripsPage, loadingFieldTrips } =
  useFieldTrips(campusId, campusAcademicYearId ?? '', params);            // year at the call

Switching years synchronously rewinds pagination to page 1 — no effect, no mirror.

Step 9: Writes — permission gate outside, lock gate inside

<Can action={Action.CREATE} resource={Resource.FIELD_TRIP}>
  {isUnlocked && (
    <Button onClick={() => openSurface(Surface.CreateFieldTrip)}>Create Trip</Button>
  )}
</Can>

The create surface stamps campusAcademicYearId from the hook into the payload; row actions pass readOnly: !isUnlocked through table meta. The UI gate is UX, the API gate is truth — the server rejects anyway if bypassed. New resource = add Resource.FIELD_TRIP to the permission enum + role seeds on the backend; CASL rules hydrate from /auth/me automatically.

If the entity renders in a persisted side panel, the panel opens with useViewingAcademicYearSafe() + null-return and declares routeParams: ['campusId'] — panels outlive the campus layout, so they defend themselves.

Step 10: Finishing touches

Sidebar entry in staff.sidebar.tsx (plain navigate — the year rides along automatically), a SEGMENT_LABELS breadcrumb entry, a .page__empty-container empty state, then bun run test and bun run check (never bun test).

The litmus test

Your feature code mentions the academic year in exactly three places — the query key, the create payload, and the isUnlocked gate. If you're writing more year-handling than that, you're rebuilding something the platform already does.

Where to go next

On this page