Skip to content

Student invitations — STUDENT recipient type gated by Department.studentPlatformAccess


1. Problem distillation

  • Students currently have no path onto the platform: RecipientType covers TEACHER / STAFF / REFERENT only, and Student.userId can never be populated.
  • Schools decide per department whether students get platform access — the flag already exists (Department.studentPlatformAccess, US-4 S6, default false) and already gates Student.schoolEmail completeness curation. This iteration makes it gate credential issuance.
  • This is the first conditionally invitable recipient type: teachers/staff/referents are unconditionally invitable; students are invitable iff their department's flag is ON at the moment of send/resend/accept.
  • Everything else rides the existing invitations subsystem unchanged: token mechanics, debounce, afterCommit dispatch, delivery reports, reset, auto-invalidation hooks, admin RBAC (INVITATIONS entity, send/reset actions).

Success criteria (observable behavior that proves this works): - Admin sends a batch containing a student in a flag-ON department → invitation row created, mail dispatched, student appears as SENT in GET /invitations and the onboarding tab. - Same batch containing a student in a flag-OFF department → that entry lands in failed[] with reason ACCESS_DISABLED; the rest of the batch is unaffected. - Student clicks the link, sets a password → User created with activeProfile='student', JWT roles narrow to ['student'], the seeded student role's grants resolve, and the student can read their own Student record (identity/contacts/assignment) and curricula surfaces. - Admin flips the department flag OFF after a send → pending (non-accepted) STUDENT invitation rows for that department's students are deleted in the same transaction as the flag write (cascade decided in the 2026-06-03 department-fields session, superseding this spec's original gate-only call); already-accepted students in that department keep access until individually reset. - PATCH /students/:id changing schoolEmail, and student hard-delete, auto-invalidate the invitation exactly like teachers/staff.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No invalidation cascade on student department reassignment (the flag flip DOES cascade — see gate semantics; a single student moving into a flag-OFF department only hits the live gate). - No student profile-completion gate at first login (SUS-1 S4–S6 — separate iteration per existing project decision). - No student-facing feature surface beyond login + own-record read + the curricula read scopes listed in §6 (grades, register, homework etc. arrive with their own US). - No setup-wizard "send credentials to students" step (same uncertainty as US-14/US-20 wizard integration). - No change to token mechanics, mailer, webhook, or reset semantics.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/invitations/invitations.queries.ts + docs/superpowers/specs/2026-04-20-invitations-design.md The complete recipient-type extension seam: RECIPIENT_EMAIL_FIELD, RECIPIENT_PRESET_ROLE_KEY, findRoleRow / listRoleRows / findRoleRowWithUser / setRecipientUserId / setRecipientNames switches, resolveRecipientProfile, RECIPIENT_TO_PROFILE No precedent for a send-path gate — all three existing types are unconditionally invitable. The flag check is the one genuinely new pattern (called out per chapter 16 §5: new write-side gate, off-axis).
prisma/seed/roles.ts referent preset + src/common/utils/narrow-roles.ts Profile-coupled role mechanics: seeded per-tenant Role row whose key equals the profile key; narrowRolesForActiveProfile replaces roles with [activeProfile] for non-RBAC profiles, so the Role row is what permission compilation resolves; accept binds the UserRole (RECIPIENT_PRESET_ROLE_KEY) Fits cleanly — student mirrors referent exactly. Not a "preset management role"; it is the profile-coupled base role.
src/teachers/teachers.service.ts:379 + src/staff/staff.service.ts:252 (getInvitationConfig override on BaseTenantedCrudService) Auto-invalidation wiring for email change + hard delete, inherited from the base class Fits cleanly — StudentsService already extends the base class; one override with emailField: 'schoolEmail', recipientType: 'STUDENT'.
src/command-center/onboarding.queries.ts + onboarding.service.ts dispatch table Per-entity-type rows + invitation join + isOverdue overlay; the dispatch-table shape isolates per-type variance to the email accessor and recipientType prefix Student rows need the gate-aware cohort predicate (flag-ON ∪ already-invited) that the other three types don't have.
src/students/students.policy.ts + src/curriculum/curricula.policy.ts / study-plans.policy.ts / selection-windows.policy.ts definePolicy branch shape; curricula-side policies already carry dormant student pass-through branches StudentsPolicy has no student branch yet — add an own-userId narrowing branch (mirrors the referent branch shape).

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes All queries already tenant-filter via the invitation row / role-row lookups; the new student queries filter tenantId identically Standard
Academic-year scope no (unchanged) GET /invitations projects role rows tenant-wide without AY filtering today (teachers/staff are AY-scoped rows too); students follow the same convention. Onboarding tab already filters by active year — student rows do too Consistency with the parent subsystem; AY filtering of the projection is a pre-existing question, not reopened here (§9)
RBAC entity key existing INVITATIONS + existing STUDENTS/CURRICULA No new entity keys The send/reset actions and admin grants cover students as recipients
Scopes existing only students.identity / students.contacts / students.assignment READ + curricula.configuration / curricula.selection_window READ granted to the new student role No new scope keys → no scope-fields.ts or ScopeFieldMapping changes; rbac-catalogue drift spec unaffected
Actions none No new action keys send/reset already exist on INVITATIONS
Service base existing InvitationsService + BaseTenantedCrudService StudentsService overrides getInvitationConfig(); invitations service gains the flag check inside the per-recipient tx n/a — no new service
queries.ts shape extend invitations.queries.ts switches; add studentsWithInvitation to onboarding.queries.ts STUDENT branches in findRoleRow, listRoleRows, findRoleRowWithUser, setRecipientUserId, setRecipientNames; new cohort predicate for the projection Named functions, no repository classes
Error codes one new INVITATION_ACCESS_DISABLED (409) thrown by resend when the student's department flag is OFF. Send path uses a new failed[].reason union member ACCESS_DISABLED (DTO-level, not an ErrorCode). Verify/accept reuse INVITATION_INVALID_OR_USED (410) Resend is a single-target route → throw; batch send never throws per-recipient; accept stays a generic 410 to avoid an oracle (see §7)
DTO conventions extend existing ProjectedInvitationDto gains the STUDENT discriminant variant; SendBatchFailureDto.reason union gains ACCESS_DISABLED; InvitationRecipientDto.type picks up STUDENT automatically from the Prisma enum; onboarding entityType enum gains 'student' No new DTO files
File-backed sub-resources n/a No file surface
Custom fields n/a No custom-field surface
Profile completeness no delta studentPlatformAccess already gates Student.schoolEmail in completeness curation Already shipped with US-4 S6

4. Data model plan

Schema deltas

  • enum RecipientType gains STUDENT (prisma/schema.prisma:386).
  • No new tables, columns, or FKs. Student.schoolEmail, Student.userId, Department.studentPlatformAccess all exist.

Migration shape

  • Additive: single ALTER TYPE recipient_type ADD VALUE 'STUDENT'.
  • Data backfill: none.
  • Hazards from chapter 12 checklist: enum-value addition is the lowest-hazard migration class; audit the generated SQL regardless. Note Postgres restricts using a freshly-added enum value inside the same transaction that adds it — irrelevant here since no seed/data statement references STUDENT in the same migration.

Indexes and uniqueness

  • None new. Existing @@unique([tenantId, recipientType, recipientId]) and status indexes cover the new value automatically.

5. API surface

No new routes. Existing routes change behavior as follows:

Verb Path Decorators Request DTO Response DTO
POST /invitations/send unchanged (@RequireAction(INVITATIONS,'send')) SendInvitationsDtorecipients[].type now admits STUDENT SendBatchResultDtofailed[].reason gains ACCESS_DISABLED
GET /invitations unchanged (@RequireScopes(INVITATIONS,'read')) ListInvitationsQueryDtorecipientType=STUDENT now valid ProjectedInvitationDto union gains the STUDENT variant
POST /invitations/:id/resend unchanged unchanged unchanged; may now throw INVITATION_ACCESS_DISABLED (409)
POST /invitations/:id/reset unchanged unchanged unchanged — reset works regardless of the flag (admin cleanup must always be possible)
GET /invitations/verify unchanged (public) unchanged unchanged — flag-OFF student token → 410 INVITATION_INVALID_OR_USED
POST /invitations/accept unchanged (public) unchanged unchanged — accept of a flag-OFF student token → 410
GET /command-center/onboarding unchanged (admin) entityType enum gains 'student' OnboardingItemDto.entityType gains 'student'

Gate semantics (the one new rule)

studentPlatformAccess is checked live (current department of the student row) at four points, all inside the existing transactions:

  1. sendBatch — flag OFF → failed[] reason ACCESS_DISABLED; tx aborted for that recipient only.
  2. resend — flag OFF → throw INVITATION_ACCESS_DISABLED (409 CONFLICT).
  3. verifyToken — flag OFF → 410 INVITATION_INVALID_OR_USED (generic, no oracle).
  4. acceptToken — flag OFF → 410, checked before the claim-update so no state mutates.

Non-student recipient types bypass the check entirely. Flipping the flag ON immediately re-enables all four paths.

Flag-flip cascade (supersedes this spec's original gate-only call — decided in the 2026-06-03 department-fields session, which is canonical for studentPlatformAccess toggle semantics): when a department's flag flips ON→OFF (PATCH /departments/:id or wizard bulkSync), pending (non-accepted) STUDENT invitation rows belonging to that department's students are deleted in the same transaction as the flag write. Accepted rows survive (acceptance history + the User account stay; revocation remains per-student via reset). The live gate above is still required — it covers department reassignment and the race window between send and flip.

Projection cohort for STUDENT

listRoleRows-equivalent for students returns: students whose current department has the flag ON, plus any student that already has an Invitation row (so ACCESSED history stays visible and resettable after a flag flip, and SENT/FAILED history survives a department move; pending rows do NOT survive a flag flip — the cascade deletes them). Implemented as two reads in invitations.queries.ts — invited STUDENT recipientIds first, then student.findMany({ where: { tenantId, OR: [{ department: { studentPlatformAccess: true } }, { id: { in: invitedIds } }] } }). Same cohort predicate for the onboarding tab (additionally AY-filtered, matching its existing teacher/staff behavior).

Swagger considerations

  • ProjectedInvitationDto oneOf + discriminator gains the STUDENT variant (same @ApiExtraModels mechanism).
  • New error example for INVITATION_ACCESS_DISABLED in error-examples.ts.
  • SendBatchResultDto failure-reason enum docs updated to list ACCESS_DISABLED with the one-line FE-facing meaning ("student's department does not have student platform access enabled").

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none
Role grants (roles.ts) New per-tenant Role row key: 'student' (profile-coupled base role, mirrors the referent row mechanics — not an admin-curated management preset). Grants, all READ: students.identity, students.contacts, students.assignment, curricula.configuration, curricula.selection_window. No actions. No write scopes.
*_SCOPES runtime constant none — no new scope keys

Accept-time binding: RECIPIENT_PRESET_ROLE_KEY.STUDENT = 'student' — accept creates the UserRole row exactly like the referent path (and like referent, the narrowing to [activeProfile] makes the binding semi-redundant at session time but keeps GET /permissions and audit coherent).

Policy deltas (drift rule: prefer admit over drop): - StudentsPolicy gains a student branch: (ctx, base) => ({ ...base, userId: ctx.userId }) — a student sees exactly their own row(s). - CurriculaPolicy, StudyPlansPolicy, SelectionWindowsPolicy already admit student (dormant pass-through branches) — no change; this iteration activates them. - Per-branch policy spec coverage added to students.policy.spec.ts.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Recipient types are unconditionally invitable STUDENT is gated by a live department flag at send/resend/verify/accept Product semantics of US-4 S6 — schools opt departments into student access First conditional send gate in the subsystem; four check sites must stay in sync (single assertStudentInvitable helper, called from all four)
Distinct error codes per rejection cause Verify/accept return the generic 410 INVITATION_INVALID_OR_USED for flag-OFF, not a dedicated code Anti-oracle stance already established for verifyToken (PII-leak fix): a leaked-token holder learns nothing about why the token stopped working FE cannot distinguish "flag off" from "token consumed" on the public routes; admin-side surfaces (list, resend 409) carry the precise state instead
Projection lists every role row of a type STUDENT cohort = flag-ON departments ∪ already-invited Listing thousands of un-invitable students as NOT_SENT is noise; hiding rows with live invitations after a flag flip would orphan state invisibly A student in a flag-OFF department with no invitation history is invisible in /invitations until the flag turns ON — intended
Accept name-overrides No divergence — firstName/lastName overrides apply to students exactly as they do to teachers (overwrite the role row) Uniform accept contract; one fewer branch Students can rename themselves at accept; admin can correct via PATCH /students/:id

8. Pushback log

US says Conflicts with Proposed instead Status
No written "Send Credentials to Students" US exists (US-14 = referents, US-20 = teachers, staff was already inferred) n/a — feature requested directly by user in chat 2026-06-03 Treat as structurally identical to US-20 + the US-4 S6 gate; confirm with product if a SUS-side US surfaces later with different semantics (e.g. age gates, consent) Resolved (user-directed)

9. Deferrals

  • ~~No invalidation cascade on flag flip~~ SUPERSEDED same day: the 2026-06-03 department-fields session (canonical for studentPlatformAccess toggle semantics) decided flag flip ON→OFF cascade-deletes pending STUDENT invitation rows in the same transaction (see §5 gate semantics). Department reassignment remains gate-only. Accepted students keep access until individually reset — bulk revocation still deferred.
  • Student profile-completion gate (SUS-1 S4–S6) — existing project decision: ships after accept+password, not bundled — follow-up: project_profile_completion_gate_separate_iteration.md.
  • Student role grant growth (health, documents, grades, register, homework views) — each arrives with its own SUS user story; v1 grants are deliberately minimal.
  • Setup-wizard "send credentials to students" step — same open product question as US-14/US-20 wizard integration — follow-up: project_setup_wizard_invitations_uncertain.md.
  • AY-scoping of the /invitations projection — pre-existing behavior (role rows listed tenant-wide across years); not reopened. Becomes relevant with the first AY rollover; revisit then.
  • Pagination of GET /invitations (flagged in the 2026-06-03 end-of-plan review; deferred to its own iteration after that review)listProjected loads every role row of every type tenant-wide and assembles the projection in memory; adding STUDENT multiplies the scan by the student cohort (the largest population). The response is an unbounded array — ListInvitationsQueryDto declares no page/limit despite the parent subsystem sketching them. Not fixed in the cleanup pass: in-memory slicing would add the {data, meta} contract but leave the tenant-wide DB scan untouched (the real cost), so it would be a misleading half-fix. True DB-side pagination needs a design decision because NOT_SENT = absence-of-an-Invitation-row makes the canonical list "all role rows LEFT JOIN invitations" — it can't be expressed as a single paginable Prisma query across the four polymorphic recipient types, and the NOT_SENT status filter is applied in JS today. Own iteration: pick a paging model (per-type cursor + merge, or a materialized recipient view) and decide whether NOT_SENT stays a first-class filterable status. Admin-only + tenant-scoped, so not urgent; revisit when a tenant's combined recipient population makes the payload a problem.
  • Age/consent gates for student accounts — no requirement on the table; if a SUS story introduces minimum-age or parental-consent rules they layer onto the same assertStudentInvitable chokepoint.

10. Open questions

All resolved in chat 2026-06-03:

  • Flag-flip-off behavior → ~~gate-only, no cascade~~ cascade: pending STUDENT invitation rows deleted in the flag-write transaction (Fabio — superseding decision in the 2026-06-03 department-fields session, which is canonical for toggle semantics; original gate-only call from this session overridden).
  • Accept-time role binding → seeded student role with minimal own-record + curricula read grants; framed as profile-coupled base role like referent, not a management preset (Fabio, chat).
  • Projection cohort → flag-ON departments ∪ already-invited (Fabio, chat).
  • Onboarding tab → include students in this iteration (Fabio, chat).

11. Verification plan

  • Unit specs:
  • src/invitations/invitations.service.spec.ts — sendBatch with STUDENT: happy path (flag ON), ACCESS_DISABLED (flag OFF), MISSING_EMAIL (null schoolEmail), mixed batch isolation; resend 409 on flag OFF; verify/accept 410 on flag OFF; acceptToken creates User with activeProfile='student', binds the student role, sets Student.userId; reset on an ACCESSED student detaches the user.
  • src/invitations/invitations.queries.spec coverage via service specs — STUDENT branches of findRoleRow / listRoleRows (cohort predicate: flag-ON ∪ invited) / setRecipientUserId / setRecipientNames.
  • src/students/students.service.spec.tsgetInvitationConfig wiring: schoolEmail change triggers invalidateByRecipient('STUDENT', …, 'EMAIL_CHANGED'); hard delete triggers ROLE_DELETED.
  • src/students/students.policy.spec.ts — new student branch: own userId match, sentinel coverage.
  • src/departments/departments.service.spec.ts — flag-flip cascade: studentPlatformAccess true→false (PATCH and bulkSync onUpdate) deletes pending STUDENT invitation rows of that department's students inside the flag-write tx; ACCESSED rows untouched; false→true and same-value writes delete nothing.
  • src/command-center/onboarding.service.spec.ts — student rows appear with status + isOverdue; flag-OFF-never-invited students excluded; flag-OFF-with-invitation students included.
  • E2E specs:
  • test/invitations.e2e-spec.ts — admin sends to student in flag-ON dept → 200 + row; flag-OFF dept → failed[] ACCESS_DISABLED; flip flag OFF after send → verify returns 410; accept in flag-ON dept → cookie session with student profile, GET /students/:id of own record succeeds, other records 404; resend on flag-OFF → 409; teacher/staff/referent/student callers get 403 on admin routes; cross-tenant opacity (404).
  • test/command-center.e2e-spec.ts — onboarding tab includes student rows with entityType: 'student'.
  • Manual verification: none beyond E2E — the flow is fully exercisable via API.

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-06-03
  • Chat reference: approved in chat 2026-06-03 after design walkthrough (live four-site gate + flag-flip cascade on pending rows per the same-day department-fields session, profile-coupled student role, cohort = flag-ON ∪ invited, onboarding tab included)

Until this section is filled, no implementation code is written.