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
@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:
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
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
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 globalValidationPipemakes 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 aplainToInstance/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):
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
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 callSwitching 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.