Student invitations — STUDENT recipient type gated by Department.studentPlatformAccess¶
1. Problem distillation¶
- Students currently have no path onto the platform:
RecipientTypecovers TEACHER / STAFF / REFERENT only, andStudent.userIdcan never be populated. - Schools decide per department whether students get platform access — the flag already exists (
Department.studentPlatformAccess, US-4 S6, defaultfalse) and already gatesStudent.schoolEmailcompleteness 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 (
INVITATIONSentity,send/resetactions).
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 RecipientTypegainsSTUDENT(prisma/schema.prisma:386).- No new tables, columns, or FKs.
Student.schoolEmail,Student.userId,Department.studentPlatformAccessall 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
STUDENTin 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')) |
SendInvitationsDto — recipients[].type now admits STUDENT |
SendBatchResultDto — failed[].reason gains ACCESS_DISABLED |
| GET | /invitations |
unchanged (@RequireScopes(INVITATIONS,'read')) |
ListInvitationsQueryDto — recipientType=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:
- sendBatch — flag OFF →
failed[]reasonACCESS_DISABLED; tx aborted for that recipient only. - resend — flag OFF → throw
INVITATION_ACCESS_DISABLED(409 CONFLICT). - verifyToken — flag OFF → 410
INVITATION_INVALID_OR_USED(generic, no oracle). - 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¶
ProjectedInvitationDtooneOf+ discriminator gains theSTUDENTvariant (same@ApiExtraModelsmechanism).- New error example for
INVITATION_ACCESS_DISABLEDinerror-examples.ts. SendBatchResultDtofailure-reason enum docs updated to listACCESS_DISABLEDwith 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
studentPlatformAccesstoggle 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
/invitationsprojection — 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) —listProjectedloads 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 —ListInvitationsQueryDtodeclares nopage/limitdespite 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 becauseNOT_SENT = absence-of-an-Invitation-rowmakes 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 theNOT_SENTstatus filter is applied in JS today. Own iteration: pick a paging model (per-type cursor + merge, or a materialized recipient view) and decide whetherNOT_SENTstays 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
assertStudentInvitablechokepoint.
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
studentrole with minimal own-record + curricula read grants; framed as profile-coupled base role likereferent, 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(nullschoolEmail), mixed batch isolation; resend 409 on flag OFF; verify/accept 410 on flag OFF; acceptToken creates User withactiveProfile='student', binds thestudentrole, setsStudent.userId; reset on an ACCESSED student detaches the user.src/invitations/invitations.queries.speccoverage via service specs — STUDENT branches offindRoleRow/listRoleRows(cohort predicate: flag-ON ∪ invited) /setRecipientUserId/setRecipientNames.src/students/students.service.spec.ts—getInvitationConfigwiring:schoolEmailchange triggersinvalidateByRecipient('STUDENT', …, 'EMAIL_CHANGED'); hard delete triggersROLE_DELETED.src/students/students.policy.spec.ts— newstudentbranch: ownuserIdmatch, sentinel coverage.src/departments/departments.service.spec.ts— flag-flip cascade:studentPlatformAccesstrue→false (PATCH andbulkSynconUpdate) 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/:idof 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 withentityType: '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
studentrole, cohort = flag-ON ∪ invited, onboarding tab included)
Until this section is filled, no implementation code is written.