Unified Students API — Record-Level Access via Context¶
Status: Design approved, awaiting implementation plan Date: 2026-04-20 Author: brainstormed with Fabio Barbieri
Problem¶
GET /students is currently restricted via @RequireRoles('admin', 'teacher'). Referents (parent/guardian users, soon mobile-first) fetch their children through a parallel API surface:
GET /referents/me/students— a referent's own linked studentsGET /referents/:id/students— admin/teacher looking at a specific parent's children
With the referent mobile app landing, duplicating student read logic across two surfaces is untenable. Every change to student shape, filtering, or pagination must be made twice. The existing RBAC doc (chapter 04) already anticipates a single endpoint narrowed by role-aware record-level filtering — we just haven't built it.
Decision: unify. GET /students becomes the single entry point; which records are visible is driven by the caller's access context (tenantId + userId + roles). The handler never branches by role; the Prisma where clause is composed from a role-aware filter function.
Scope¶
In scope¶
GET /students— listed records restricted by the caller's access context.GET /students/:id— returns the record if visible to the caller, 404 otherwise.- New
RecordAccessContextinterface. - New
@AccessContext()parameter decorator. - New
studentsForAccessContext(ctx)query helper insrc/students/students.queries.ts. @RequireRoleson both read routes widens to('admin', 'teacher', 'referent').- Deletion of
GET /referents/me/students,GET /referents/:id/students, theirReferentsServicemethods (findStudentsByUserId,findStudentsByReferentId), theirreferents.queries.tshelpers (studentsForReferentByUserId,studentsForReferentById), their swagger decorators (ApiGetMeStudents,ApiGetReferentStudents), and the tests covering them. - Update to chapter 04 RBAC doc: the paragraph at line 402 is rewritten; the "Record-Level Access" sketch (lines 594–634) is replaced with the implemented pattern.
Out of scope (explicitly deferred)¶
POST/PATCH/DELETE /students/:idscoping. No current role has write on students without being admin-equivalent; no record-level filter is needed on writes today. Revisit when a write-capable non-admin role is introduced.- Admin-facing "show me this specific parent's children" view. A future spec may add this as a relation on the referent DTO or as a dedicated lookup — not this spec.
- Pushing
RecordAccessContextintoBaseTenantedCrudService. Premature until a second entity joins the pattern. - Postgres RLS policies. The query helpers are shaped to translate directly, but RLS itself is a separate migration.
- Teacher-specific filtering. The
teacherbranch ofstudentsForAccessContextinitially returns{ tenantId }(same as admin) with aTODOto narrow once class assignments are modeled. Matches current behavior — teachers already see all tenant students today. - Consolidating
GET /referents/me/GET /users/meinto a single app-wide/me. Captured in the pattern library as a rule; acting on it is future work.
Decisions log¶
Ordered answers to the clarifying questions during brainstorming. Each links to the design section enforcing the decision.
- Unlinked-student access by referent → 404 Not Found. Falls out of
findFirst({ where: { id, ...filter } })and matches the tenant-boundary pattern. See §3. - Same DTO for every role. Roles differ only in which records and which scope groups survive; never in response shape. See §3.
- Filter lives as a named function in
queries.ts. Rejected the inline and Prisma-middleware alternatives. See §2. RecordAccessContextthreaded via@AccessContext()param decorator +StudentsServiceoverride.BaseTenantedCrudServiceis not modified. See §2.@RequireRoleswidened to explicit allowlist('admin', 'teacher', 'referent'). Kept in lockstep with filter branches. See §2 and §5.- No alias period; delete the
/referents/*/studentsendpoints outright. Web and mobile clients migrate in the same release window. See §4. - Generalization to other entities is the future direction but not this spec's job. Design documents the pattern but touches only
students. See §6. /meis a single app-wide endpoint (GET /auth/me). No per-entity/meroutes. Self-data is strictly through/me; everything else is collection access viaGET /<entity>+ ctx filter. See §6.7.
1. Architecture summary¶
Three new primitives, shared across future entities that adopt the pattern:
@AccessContext() → RecordAccessContext → <entity>ForAccessContext(ctx)
↓
Prisma.WhereInput fragment
↓
merged into service where
- The decorator reads from
request.user— no DB work. - The context is a narrow, immutable value object:
{ tenantId, userId, roles }. - The entity helper is a pure function: one branch per role, defensive fallback, always includes
tenantId.
1.1 RecordAccessContext (interface, src/common/interfaces)¶
export interface RecordAccessContext {
tenantId: string;
userId: string;
roles: string[]; // role keys from JWT, same shape as request.user.roles
isPlatformAdmin: boolean; // mirrors request.user.isPlatformAdmin
}
Kept narrow on purpose: contains only what record-level filtering needs. email and bearer-token metadata stay on request.user where they belong. isPlatformAdmin is included because platform admins bypass every guard (chapter 04) — they reach the filter with arbitrary (often empty) tenant roles, and without this flag they'd fall through to the defensive __never_matches__ branch and get []. The filter short-circuits on the flag before role-branch evaluation.
1.2 @AccessContext() (param decorator, src/common/decorators)¶
export const AccessContext = createParamDecorator(
(_: unknown, ctx: ExecutionContext): RecordAccessContext => {
const req = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
return {
tenantId: req.user.tenantId,
userId: req.user.userId,
roles: req.user.roles,
isPlatformAdmin: req.user.isPlatformAdmin === true,
};
},
);
Sibling of the existing @TenantId(). Additive — existing controllers that only need tenantId stay unchanged.
1.3 studentsForAccessContext (named function, src/students/students.queries.ts)¶
export function studentsForAccessContext(
ctx: RecordAccessContext,
): Prisma.StudentWhereInput {
const base = { tenantId: ctx.tenantId };
if (ctx.isPlatformAdmin) return base; // platform admins bypass role-branch evaluation
if (ctx.roles.includes('admin')) return base;
if (ctx.roles.includes('teacher')) return base; // TODO: narrow to teacher's classes
if (ctx.roles.includes('referent')) return {
...base,
referents: {
some: { referent: { userId: ctx.userId, tenantId: ctx.tenantId } },
},
};
return { id: '__never_matches__' }; // defensive: unknown role → empty set
}
Kept intentionally boring — one function, one file, one owner. The __never_matches__ branch is belt-and-suspenders in case @RequireRoles ever drifts out of sync.
1.4 Controller-side changes¶
@Get()
@RequireScopes(EntityKey.STUDENTS, 'read')
@RequireRoles('admin', 'teacher', 'referent') // lockstep with studentsForAccessContext
@ApiListStudents()
async findAll(
@Query() query: AcademicYearPaginationQueryDto,
@AccessContext() ctx: RecordAccessContext,
) {
return this.studentsService.findAll(ctx, query.page, query.limit, query.academicYearId);
}
@Get(':id')
@RequireScopes(EntityKey.STUDENTS, 'read')
@RequireRoles('admin', 'teacher', 'referent')
@ApiGetStudent()
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@AccessContext() ctx: RecordAccessContext,
) {
return this.studentsService.findOne(id, ctx);
}
@TenantId() is removed from these two routes — ctx.tenantId subsumes it. All other routes (create, update, delete, import) keep @TenantId() unchanged.
1.5 Service-side changes¶
StudentsService.findAll and findOne are overridden (they currently inherit from BaseTenantedCrudService). New signatures take ctx instead of tenantId. Internally:
async findAll(ctx: RecordAccessContext, page?: number, limit?: number, academicYearId?: string) {
const where = {
...studentsForAccessContext(ctx),
...(academicYearId ? { academicYearId } : {}),
};
// pagination + include as today
}
async findOne(id: string, ctx: RecordAccessContext) {
const record = await this.prisma.student.findFirst({
where: { id, ...studentsForAccessContext(ctx) },
include: studentQueryInclude,
});
if (!record) throw new AppException(ErrorCode.STUDENT_NOT_FOUND, 'Student not found', HttpStatus.NOT_FOUND);
return this.toScopedResponse(record);
}
findFirst with the merged where is what gives us 404-not-403 for free when a referent queries an unlinked student — the record simply doesn't match.
BaseTenantedCrudService is not modified in this spec.
1.6 What stays the same¶
ScopeGuard,ActionGuard,FieldWriteGuard,FieldFilterInterceptor,RolesGuard— all unchanged.StudentResponseDto— unchanged. Referent callers get the same shape;FieldFilterInterceptorstrips scopes they lack (referent preset has READ on anagraphic/contacts/enrollment/documents/sensitive, so most fields survive).- All student write paths (
POST,PATCH,DELETE,POST /import) — unchanged. GET /referents/me,GET /referents/:id— unchanged. Only the two*/studentschild routes are deleted.
2. Approaches considered¶
Approach A: Inline per service¶
StudentsService.findAll(tenantId, ctx) builds where in-place: if (ctx.roles.includes('referent')) { where.referents = { some: ... } }.
- ✅ Zero new abstractions.
- ❌ Logic scatters across every entity that eventually needs record-level filtering. Easy to forget a path. Defense-in-depth risk.
Approach B: Named query helpers in queries.ts (SELECTED)¶
One function per entity returns a Prisma.WhereInput fragment. Services compose: prisma.student.findMany({ where: studentsForAccessContext(ctx), ... }).
- ✅ Matches the existing
queries.tsconvention (mandatory per-module queries file, named functions, no repository classes). - ✅ One visible function per entity — easy to review, easy to unit-test in isolation.
- ✅ Trivial to extend: add
teachersForAccessContext,attendanceForAccessContext, etc. when those entities need it. - ✅ Maps 1:1 onto a future RLS policy — the WHERE fragment becomes the policy predicate.
- ❌ Relies on convention: services must use the helper. Mitigation: the service's typed
ctxparameter makes it impossible to callfindAllwithout access context.
Approach C: Prisma $extends / middleware auto-injection¶
Request-scoped Prisma client rewrites queries to append the access filter.
- ✅ Impossible to forget.
- ❌ Magic; harder to trace. Requires AsyncLocalStorage or per-request client instances — both add moving parts the rest of the codebase doesn't have.
B selected for alignment with the existing convention and inspectability. C stays on the table as a future move if the pattern proves too easy to bypass, but we're not starting there.
3. Error semantics¶
| Scenario | Response | Reasoning |
|---|---|---|
Referent calls GET /students/:id for linked child |
200 + filtered DTO | Record matches filter. FieldFilterInterceptor strips scopes the referent lacks. |
Referent calls GET /students/:id for an unlinked student |
404 STUDENT_NOT_FOUND |
Record doesn't match the merged where. Doesn't leak existence. |
Referent calls GET /students with no linked children |
200 + empty page | Not an error. |
Role not in the role branches (hypothetical principal) |
403 | Blocked by @RequireRoles. Safety net: __never_matches__ returns [] if decorator drifts out of sync. |
| Unauthenticated | 401 | JWT guard, unchanged. |
| Cross-tenant access | 404 | tenantId is in every filter branch. |
No new error codes. STUDENT_NOT_FOUND already exists in the ErrorCode enum (chapter 06).
Response shape contract (non-negotiable): every role receives the same StudentResponseDto. Roles differ only in (a) which records appear and (b) which scope groups the FieldFilterInterceptor preserves. Never role-branched field additions. Role-specific enrichment (e.g. referent "is primary contact" flags) belongs on a future /referents-namespaced resource, not on StudentResponseDto.
4. Deprecation mechanics¶
Single cutover, no transition window.
Delete:
- GET /referents/me/students handler in ReferentsController
- GET /referents/:id/students handler in ReferentsController
- ReferentsService.findStudentsByUserId
- ReferentsService.findStudentsByReferentId
- studentsForReferentByUserId in src/referents/referents.queries.ts
- studentsForReferentById in src/referents/referents.queries.ts
- ApiGetMeStudents, ApiGetReferentStudents in src/referents/referents.swagger.ts
- Corresponding tests in referents.controller.spec.ts and referents.service.spec.ts
Keep:
- GET /referents/me — referent's own profile read. Flagged in pattern library as future /me consolidation work, out of scope here.
- GET /referents/:id — admin/teacher lookup of a specific referent. Untouched.
Client coordination:
- Web app — re-point any caller from /referents/me/students to GET /students in the same release window as backend merge.
- Mobile app — ship against GET /students before backend merge.
No alias layer, no @Deprecated swagger markers, no feature flags. Codebase stays clean.
5. Decorator-filter synchronization rule¶
The role list in @RequireRoles(...) on GET /students and GET /students/:id and the if (ctx.roles.includes('...')) branches in studentsForAccessContext are two halves of one access policy.
Rule (documented in chapter 04 and in code comments on both sides):
When adding or removing a role from
@RequireRolesonGET /studentsorGET /students/:id, you MUST add or remove the matching branch instudentsForAccessContext. Conversely, every role with a filter branch must appear in@RequireRoles. The two are reviewed together as a single change.
The __never_matches__ fallback exists as runtime safety but is not the primary enforcement — silent empty results are a development-time bug, not a production data risk.
Why not automate? A grep-based lint rule is doable but brittle. Social enforcement + runtime fallback is sufficient at two entities; reassess once five+ entities adopt the pattern.
6. Reusable patterns (pattern library)¶
The primitives introduced by this spec generalize to every future record-level filtering concern. This section documents the pattern explicitly so downstream specs (attendance, grades, invoices, …) have a recipe instead of a puzzle.
6.1 RecordAccessContext — the per-request access shape¶
- What it is. Immutable value object:
{ tenantId, userId, roles, isPlatformAdmin }. Built once per request fromrequest.user. No DB work. - Why these fields.
tenantIdis the mandatory outer boundary.userIdis the only identity dimension filters actually need.rolesdrives branch selection.isPlatformAdminis carried because platform admins bypass every guard and must short-circuit the filter — without the flag they fall through to the defensive branch and get[]. - Why not pass the whole
request.user. Narrow shape is easier to type, mock in tests, and reason about. Prevents accidental dependence on session metadata or bearer tokens. - Evolving the shape. If a filter ever needs an additional fact (e.g.
departmentIdsfor a principal), add it here and populate in the decorator. Never add role-specific fields — the shape stays role-agnostic. Facts on the context should be orthogonal to role; they describe the caller, not the role's logic.
6.2 @AccessContext() — the single construction site¶
- Rule. Anywhere a controller needs
RecordAccessContext, use this decorator. Never reconstruct it ad-hoc from@Req(). - Why. One construction site means one place to change if the shape grows. Ad-hoc construction is how drift starts.
- Coexistence with
@TenantId(). Both decorators coexist. Routes that only needtenantIdkeep using@TenantId(). No migration sweep.
6.3 <entity>ForAccessContext(ctx) — the chokepoint convention¶
The load-bearing pattern. Every future entity that needs record-level filtering follows this recipe.
Contract:
- Lives in the entity's
queries.tsas a named exported function. - Takes
RecordAccessContext, returns aPrisma.<Entity>WhereInput(or fragment composable into one). - Always includes
tenantIdin every returned branch. Tenant isolation is never optional. - One branch per role key that may call the gated route.
- Ends with a defensive fallback (
{ id: '__never_matches__' }or equivalent) producing an empty result set when no branch matched. - Never throws. Never does DB work. Pure function from ctx to WHERE fragment.
- Never role-gates at the function level — that is
@RequireRoles's job. This function assumes the caller is already authorized to invoke the route; its job is narrowing records, not authorizing access.
Composition in services:
Always spread first. Caller criteria may narrow further but must not widen past the access filter.
Recipe: adding record-level filtering to a new entity.
- Add
<entity>ForAccessContext(ctx)tosrc/<entity>/<entity>.queries.tsfollowing the contract. - On read routes: add
@RequireRoles(...)listing every role with a branch; add@AccessContext() ctx: RecordAccessContextto the handler signature. - Override
findAllandfindOnein the entity service to acceptctxand merge the helper intowhere. UsefindFirstforfindOneso 404 falls out of the query layer naturally. - Leave
BaseTenantedCrudServicealone until multiple entities are on the pattern (see 6.5). - Add tests: one per role branch, plus the "no matching role" defensive case (see §7).
- Update chapter 04's "Record-Level Access" table to list the new entity + role-branch matrix.
6.4 Decorator–filter synchronization as a reviewable invariant¶
- What it prevents. A future PR that adds a role to
@RequireRolesbut forgets the filter branch (new role silently sees nothing or everything) or vice versa (dead code). - How it's enforced. Social: both pieces live adjacent (
<entity>.controller.tsand<entity>.queries.ts), both carry pointer comments, reviewer checks for drift. Runtime fallback catches the worst silent-failure mode. - Upgrade path. If the pattern reaches five+ entities, write a unit test that introspects route metadata vs. filter helper role keys.
6.5 Future generalization — ctx in BaseTenantedCrudService¶
Today only StudentsService is on the pattern. When a second entity adopts it, revisit:
- Trigger. Two or more entities with identical
findFirst({ where: { id, ...helper(ctx) } })patterns. - Shape of the future refactor.
BaseTenantedCrudServicegains a protectedgetRecordAccessFilter(ctx): Prisma.WhereInputhook defaulting to{ tenantId: ctx.tenantId }and overridden per entity.findAll,findOne,update,removecall it. Subclasses overridingfindAll/findOnebecome thin or disappear. - Why wait. Generalizing on one data point tends to pick the wrong abstraction. The second entity often reveals nuance (soft-delete, archival state) that changes the hook signature. Cheaper to refactor once informed.
6.6 RLS migration mapping¶
Every filter branch is shaped so it maps directly onto a future Postgres RLS policy:
- The WHERE fragment becomes the policy's
USINGclause. ctx.userId→current_setting('app.user_id')::uuid.ctx.tenantId→current_setting('app.tenant_id')::uuid.
Convention. Branches use only tenantId, userId, and plain joins. No precomputed in-memory sets, no runtime computation. Anything else wouldn't translate.
Migration plan. When RLS lands, service-layer helpers stay as primary enforcement; RLS is defense-in-depth. A global middleware sets per-request Prisma session variables; services call the helpers exactly as before. No app-code migration.
Reference. Chapter 04's "Record-Level Access" section documents the two-phase rollout. This spec is the Phase 1 implementation.
6.7 Relationship to /me — the rule¶
Two orthogonal mechanisms, no overlap:
- Self-data (the caller's own record, identity, session payload) → single app-wide
/meendpoint (GET /auth/me). The/mepayload is ctx-driven: composes the caller's profile based on roles/user. No<entity>/meroutes — those duplicate identity lookup and fragment the/mesurface. - Collection access (records the caller can see that are NOT themselves) →
GET /<entity>+<entity>ForAccessContextfilter.
GET /students is purely collection access: a referent's children are not the referent's self-data, they are records the referent has visibility into. So the full pattern applies and /me is untouched.
GET /referents/me exists today but is an anti-pattern under this rule. Out of scope for this spec; flagged for a future /me consolidation.
6.8 When NOT to reach for this pattern¶
- Entity-level visibility ("can this role hit this endpoint at all?") →
@RequireRoles+@RequireScopes. Not the filter helper. - Field-level stripping ("can this role see the
disabilityInfofield?") → scope model +FieldFilterInterceptor. - Action-level gates ("can this role delete students?") →
@RequireAction. - Identity / session payload →
/auth/me. Self-data is not collection access.
The ctx-filter pattern earns its complexity when the same endpoint must return different subsets of the same collection to different roles. That is its purpose. For anything else, use the purpose-built existing mechanism.
7. Testing¶
Per chapter 09 conventions: unit tests with mocks, e2e through supertest and the real guard stack.
7.1 students.queries.spec.ts (new file)¶
Pure-function tests on studentsForAccessContext:
- admin role →
{ tenantId }only - teacher role →
{ tenantId }only (document theTODOon narrowing) - referent role →
{ tenantId, referents: { some: { referent: { userId, tenantId } } } } isPlatformAdmin: truewith emptyroles→{ tenantId }(short-circuit ahead of role-branch evaluation)isPlatformAdmin: truewith conflictingroles(e.g.['referent']) →{ tenantId }(short-circuit wins; lock behavior under test)- unknown role,
isPlatformAdmin: false→{ id: '__never_matches__' } - multiple tenant roles (admin + referent) → admin branch wins (order of includes-checks is part of the contract; lock it under test)
7.2 students.service.spec.ts (updates)¶
findAll(ctx)referent with no linked children → empty arrayfindAll(ctx)referent with 2 linked children → both records, nothing elsefindAll(ctx)referent with 1 linked child in a tenant of 100 students → only the linked childfindOne(id, ctx)referent on linked child → record returnedfindOne(id, ctx)referent on unlinked same-tenant student → throwsSTUDENT_NOT_FOUNDfindOne(id, ctx)referent on cross-tenant student → throwsSTUDENT_NOT_FOUND(tenantId boundary holds through the filter)- Admin behavior unchanged (regression)
7.3 students.controller.spec.ts (updates)¶
- Controller forwards
ctxinto service correctly (mock@AccessContext()via NestJS testing utilities) @RequireRolesallowlist metadata includes'referent'ctxis narrow-shaped (no leakage ofisPlatformAdmin,email, or bearer metadata)
7.4 E2E (test/students.e2e-spec.ts)¶
Full stack through the guard chain:
- Seed: tenant with 3 referents, 5 students, 2 linked to referent-A
- referent-A JWT →
GET /students→ 2 records - referent-A JWT →
GET /students/:idon linked → 200 - referent-A JWT →
GET /students/:idon unlinked → 404 - referent-A JWT →
POST /students→ 403 (no create action) - admin JWT →
GET /students→ 5 records (regression)
7.5 Deletions¶
Remove the tests covering findStudentsByUserId / findStudentsByReferentId from referents.service.spec.ts and their counterparts in referents.controller.spec.ts. Keep profile-lookup tests intact.
8. Documentation updates¶
docs/04-rbac.md- Rewrite the paragraph at line 402 ("GET /students and GET /students/:id carry @RequireRoles('admin', 'teacher') because referents access student data through their own controller…"). Replace with the unified behavior and the allowlist-plus-filter synchronization rule.
- Replace the "Record-Level Access" sketch (lines 594–634) with the implemented pattern:
RecordAccessContext+@AccessContext()+<entity>ForAccessContexttriad. Keep the two-phase (service-layer now, RLS later) framing. - Add a subsection "Extending record-level filtering to a new entity" lifted from §6.3's recipe.
docs/05-crud-patterns.md— if there is a "how to add a read endpoint" recipe, append the question "does this endpoint need record-level filtering?" with a forward pointer to chapter 04.docs/11-workflows.md— new entity checklist gains "if record-level visibility differs by role, define<entity>ForAccessContextinqueries.ts".CLAUDE.md— no change; chapter 04 is already indexed.
No new chapter.
9. Acceptance criteria¶
A PR implementing this spec is complete when:
GET /studentsaccepts referent JWTs and returns only their linked children.GET /students/:idwith a referent JWT returns 200 for linked children, 404 for unlinked or cross-tenant students.GET /referents/me/studentsandGET /referents/:id/studentsare removed from the codebase — including handlers, service methods, query helpers, swagger decorators, and their tests.RecordAccessContext,@AccessContext(), andstudentsForAccessContextare in place and used exclusively in the two student read routes.@RequireRoles('admin', 'teacher', 'referent')is applied to both read routes with a comment pointing atstudentsForAccessContext.- All new and updated unit / e2e tests pass.
- Chapter 04 documentation reflects the implemented pattern including the pattern-library content.
- Admin and teacher behavior on
GET /studentsis unchanged (no regression). - Every
whereon a student read still includestenantId, now inside the filter helper — verified by test. - Platform admin bypass behavior is unchanged: they bypass the guard stack and hit the filter, where the
isPlatformAdminshort-circuit returns{ tenantId }— see §1.1 for why the flag is onRecordAccessContext.
10. Risks & mitigations¶
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
@RequireRoles and studentsForAccessContext drift out of sync |
Medium | Low (bug produces [], not data leak) |
Synchronization rule documented; __never_matches__ fallback; cross-pointer comments |
| A developer writes a new student read path and forgets the filter | Low | High (tenant-bounded data leak to wrong user) | Only two read paths exist; service method signatures require ctx. Revisit via BaseTenantedCrudService generalization when a second entity joins |
| Clients not migrated in time | Low | Medium | Coordinated release window; no backward-compat alias |
| Future role with more complex visibility (e.g. principal) doesn't fit the pattern | Medium | Low | RecordAccessContext is extensible; worst case the helper grows extra branches |
| RLS migration diverges from service-layer logic | Low | Medium | Filter helpers written in RLS-compatible form today (userId / tenantId only); translation is mechanical |
References¶
docs/04-rbac.md— RBAC reference, record-level access sketch (target for update)docs/01-architecture.md— service/controller conventionsdocs/09-testing.md— test conventionsdocs/12-migrations.md— migration safety (not triggered by this spec — no schema changes)src/students/students.controller.ts— current controllersrc/students/students.service.ts— current servicesrc/students/students.queries.ts— existing query helperssrc/referents/referents.controller.ts— source of deletionssrc/referents/referents.queries.ts— source of deletions