Invitations Subsystem — Admin-Side Credential Issuance¶
Status: Design approved, awaiting implementation plan
Date: 2026-04-20
Author: brainstormed with Fabio Barbieri
Source user stories: US-14 (Send Credentials to Referents, ClickUp 8cnxf2d-4495), US-20 (Send Credentials to Teachers, 8cnxf2d-4735), plus supporting stories US-17, US-17.1, US-18, RUS-1/2/3 in doc 8cnxf2d-7815. "Send Credentials to Staff" has no written US — inferred as structurally identical to US-20.
Amendments¶
- 2026-04-22 — Referent pair-status projection removed. Decision #13 and §2.5 (
projectPairStatus) are superseded: the frontend only surfaces the account-level invitation status, soReferentInvitationDto.pairs[],ReferentPairStatusDto, and theprojectPairStatusfunction have been deleted. Linked-student display (if ever needed) is a read concern for the referents module, not the invitations projection. A FAILED account retainsstatus=FAILEDregardless of when pairs were created; a resend is what refreshes the token and restores access for all pairs.
Problem¶
Teachers, Staff, and Referents need platform access but have no way to get credentials today. The backend has no Invitation concept, no email library, no queue infrastructure, and no state machine for credential lifecycle. US-14 and US-20 describe an identical admin-side flow — send, re-send, reset, auto-invalidate on email change, bounce reporting — that must be built before any recipient-side login flow (RUS-1, TUS-1) can exist.
Decision: build a single, unified /invitations subsystem covering all three recipient types. Commit to Resend as the eventual email provider but ship behind a MailerPort abstraction + a no-op LogMailer transport in this spec. Resend integration ships as the immediately-following spec.
Scope¶
In scope¶
- New
InvitationPrisma model (single table, account-scoped, polymorphic recipient). - New
mailer/module — generic:MailerPortinterface,MailTransportinner seam,LogMailertransport,MailerServiceorchestrator withonDeliveryReport(handler)subscription +ingestDeliveryReport(report)internal seam. No webhook controller in this module. - New
invitations/module — business logic:InvitationsService,InvitationsControllerat/invitations, queries ininvitations.queries.ts. - Admin-side endpoints:
POST /invitations/send,GET /invitations,POST /invitations/:id/resend,POST /invitations/:id/reset,GET /invitations/delivery-failures. InvitationsService.acceptToken(rawToken, password)method (backend logic only — HTTP endpoint for acceptance lives in the recipient-side spec).- Auto-invalidation hook
invalidateByRecipient(tx, type, id, reason)called fromTeachers/Staff/Referentsupdate and delete services when email changes or the role row is deleted. - RBAC catalogue addition: new
INVITATIONSentity with singledefaultscope andsend/resetactions. Seed updates for tenant-admin-tier role presets. - Migration generating the
Invitationtable + enum types + unique/index constraints; seed data migration for the new RBAC catalogue rows.
Out of scope (explicitly deferred)¶
- Recipient-side acceptance HTTP endpoint — the
InvitationsService.acceptTokenmethod is implemented here, but the route that exposes it lives in per-role follow-up specs (RUS-1 for referents, TUS-1 for teachers, none-yet for staff). - Real email provider (Resend) adapter — follow-up spec. Will add a
mailer/resend/submodule withresend.transport.ts,resend-webhook.controller.ts,svix-signature.guard.ts, and aresend-report.mapper.ts. No changes toinvitations/are expected. - Delivery-report HTTP endpoint — provider-specific (Resend uses Svix, others vary). Lives in the Resend spec. In v1 the
FAILEDstatus is production-unreachable becauseLogMailercannot fail;MailerService.ingestDeliveryReportis wired and tested but has no HTTP caller yet. - Queue / BullMQ / retries — future spec.
LogMailerand synchronous-afterCommit dispatch are fine for v1. - Setup Wizard integration — "Send credentials to teachers/staff" as setup steps will be brainstormed alongside US-12/US-15 in a separate spec. No
getCompletionStatuspublic method, no completion predicate, no events exposed by this module. - Referent→Referent self-service invitations — RUS-2 Scenario 6 + US-17 Scenario 8 specify that a referent "adding another referent" files a pending request for an admin to approve; only then does the admin initiate credential sending via the normal
POST /invitations/sendflow. The pending-request workflow belongs to the RUS-2 spec. This subsystem remains admin-only sender. - Guardians — US-18 is explicit: Guardians do not log in, do not receive credentials, do not appear in the referent list. They are data-only records. No Guardian row will ever be an invitation recipient.
- Token TTL — no
tokenExpiresAtcolumn. Tokens are valid until re-sent, reset, accepted, or auto-invalidated. Adding a TTL later is a non-breaking additive migration. - Application-wide event bus — not adopted by this spec. If
@nestjs/event-emitteris ever introduced, the one-way invalidation hook and theonDeliveryReportcallback become event subscribers;mailer/is the one place that changes.
Decisions log¶
Ordered answers to the clarifying questions during brainstorming. Each links to the section that enforces the decision.
- Spec scope: admin side only. Token generation, send, status tracking, bounce handling, reset, profile-email-change auto-invalidation. Recipient side (link → register → set password → session) is a follow-up per role. See §2.4.
- Email delivery:
MailerPortinterface +LogMailernow; Resend in the next spec. Real provider adapter drops in without touching invitation code. See §2.2. - Data model: single
Invitationtable, account-scoped. One row per invitable user, not per pair. Referent account status is the only status surfaced — per-pair projection was considered and later removed (see Amendments). See §2.1. - User row creation: at acceptance time. Role-entity
userIdstaysNULLuntil the recipient clicks the link and sets a password. Reset soft-deletes the User (isActive=false); re-invite + re-accept creates a new User row so audit history survives. See §3.3 and §3.6. - Token: opaque 256-bit random, base64url-encoded, stored as SHA-256 hash. No TTL — the token is valid until re-sent, reset, accepted, or auto-invalidated. See §2.1 and §3.6.
- Setup Wizard integration: deferred. No public completion status exposed. See scope.
- Controller surface: single
/invitations(not/admin/invitations). RBAC governs access; URL is role-neutral. List DTO is a discriminated union keyed onrecipientTypeto support an "all invitations" view. See §2.4. - Module partitioning: split
invitations/+mailer/.mailer/lives atsrc/mailer/(has a future controller → notcommon/).invitations/depends onMailerPortvia DI and registers a delivery-report subscriber atonModuleInit. See §2. - Polymorphic FK: single
recipientIdstring + compound unique(tenantId, recipientType, recipientId). No DB FK, noCHECKconstraint. Integrity is enforced by the service layer + the explicit invalidation hook on role deletion. Simpler and scales to future recipient types without a migration. See §2.1. NOT_SENTis not a persisted status. Absence of anInvitationrow = not sent. The Prisma enum has onlySENT,FAILED,ACCESSED. The DTO enum includesNOT_SENTand is produced by the projection layer via aLEFT JOIN-style query starting from the role table. Reset, email-change, and role-delete all DELETE the invitation row. See §2.1 and §3.- Auto-invalidation: explicit one-way hook, no event bus. Role modules call
invitationsService.invalidateByRecipient(tx, type, id, reason)inside their update/delete transaction. See §3.4. - RBAC actions:
sendandresetonly.readis implicit via scope access +FieldFilterInterceptorfield stripping, per the project convention. See §2.6. - ~~Pair projection — ACCESSED and SENT inherit, FAILED distinguishes by timing.~~ Superseded 2026-04-22 (see Amendments). Referent invitations expose account-level status only; there is no
pairs[]field on the projected DTO and noprojectPairStatusfunction. A FAILED account stays FAILED across new pair additions — the resend flow is what refreshes reachability. - afterCommit mailer dispatch. Every outbound email is dispatched after its DB transaction commits, so rolled-back writes cannot leak emails. See §3.1.
- Service-layer debounce, ~10s window. Double-click protection lives server-side. See §3.1.
- Batch cap 500 recipients.
POST /invitations/sendvalidates@ArrayMaxSize(500). See §2.4. - Permissive reset.
POST /invitations/:id/resetdeletes the invitation row regardless of current status; the user-soft-delete branch only runs whenstatus === ACCESSED. Rejecting non-ACCESSED resets with a 409 would be defensible per US-20 S8 / US-14 S9 wording, but deleting aSENT/FAILEDrow is a reasonable admin action ("kill the outstanding invite") and not worth a rejection path. See §3.3. sendCounttracked onInvitation. Defaults to 1 on create; incremented on every resend and on repeatsendBatchupdate-branch. Surfaced in list / failure DTOs. Lets the frontend distinguish first-send from resend without baking per-recipient nuance intoSendBatchResultDto. See §2.1 and §2.4.acceptTokendoes not tenant-scope the role-row lookup. Role UUIDs are globally unique; the invitation row'stenantIdis authoritative for User creation. Skipping the redundant tenant filter keeps the code path forward-compatible with a future "shared identity across tenants" model. See §7 and §8.
1. Architecture summary¶
Two cooperating modules, clean separation of business logic from transport.
┌──────────────────────────────────────────────┐
│ invitations/ │
│ - InvitationsController (/invitations/*) │
│ - InvitationsService │
│ - invitations.queries.ts │
│ - Invitation Prisma model │
└───────────────┬──────────────────────────────┘
│ depends on
│ (DI: MailerPort)
▼ │
┌──────────────────────────────────────────────┐ │
│ mailer/ │ │
│ - MailerService (MailerPort facade + │ │
│ transport orchestration + │ │
│ onDeliveryReport subscribers) │ │
│ - MailTransport (internal seam) │ │
│ - LogMailer (MailTransport impl, v1) │ │
│ - mailer interfaces │ │
└───────────┬──────────────────────────────────┘ │
│ │
│ in the future │
▼ │
┌──────────────────────────────────────────────┐ │
│ mailer/resend/ (NEXT SPEC — not in this) │ │
│ - resend.transport.ts │ │
│ - resend-webhook.controller.ts │ │
│ - svix-signature.guard.ts │ │
│ - resend-report.mapper.ts │ │
└──────────────────────────────────────────────┘ │
│
Teachers/Staff/Referents services ─── call ───────┤
invalidateByRecipient(tx, type, id, reason) │
on email change or role row delete │
│
Future recipient-side endpoint (RUS-1, TUS-1) ────┘
calls InvitationsService.acceptToken(raw, pwd)
Key invariants:
invitations/knows nothing about email transports. It holds aMailerPortreference, callssendEmail, and registers aonDeliveryReporthandler atonModuleInit.mailer/knows nothing about invitations. It routes payloads to configured transports and fans delivery reports out to registered subscribers.- The "webhook" seam is an internal method call (
MailerService.ingestDeliveryReport(report)) in v1. The HTTP surface for webhooks is provider-specific and lives inmailer/resend/when that spec lands.
2. Components¶
2.1 Prisma model¶
model Invitation {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
recipientType RecipientType @map("recipient_type")
recipientId String @map("recipient_id") @db.Uuid
status InvitationStatus
tokenHash String? @map("token_hash")
lastSendAt DateTime @map("last_send_at")
sendCount Int @default(1) @map("send_count")
lastFailureReason String? @map("last_failure_reason")
acceptedAt DateTime? @map("accepted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, recipientType, recipientId])
@@unique([tokenHash])
@@index([tenantId, status])
@@index([tenantId, recipientType, status])
@@map("invitations")
}
enum RecipientType {
TEACHER
STAFF
REFERENT
}
enum InvitationStatus {
SENT
FAILED
ACCESSED
}
Rationale:
- Absence = NOT_SENT. No row means the recipient has not been invited. Reset, email-change, and role-delete all DELETE the invitation row. The DTO-level
NOT_SENTis produced by the projection layer. - Polymorphic via discriminator, no DB FK.
recipientIdis a plain UUID string. Referential integrity is enforced by the service layer and by explicit invalidation hooks on role deletion. Accepted trade-off: scales to future recipient types without schema change; the alternative (three nullable FKs + CHECK) forces a migration every time a new role becomes invitable. (tenantId, recipientType, recipientId)is the primary lookup key. Compound unique index. Used byupsert,invalidateByRecipient, and the listLEFT JOIN.tokenHashunique + nullable. SHA-256(32-byte random). Cleared toNULLon acceptance (the token is single-use). Unique index enablesO(1)lookup from the acceptance endpoint without a tenant context.lastSendAtnon-nullable. Every row exists because a send was attempted; there is no state wherelastSendAtis unset.sendCounttracks first-send vs resend. Defaults to 1 on create. Incremented on every resend (explicit or via repeatsendBatch). Exposed in list and failure DTOs so the frontend can label "sent once" vs "sent N times" without the service needing to distinguish per-recipient in the batch result.- Tenant cascade delete. Standard SIS pattern — tenant teardown removes invitations.
2.2 mailer/ module¶
Generic, reusable. Ships minimal surface in this spec; grows transports in follow-ups.
src/mailer/
├── mailer.module.ts
├── mailer.service.ts // public facade + transport orchestrator
├── mailer-port.interface.ts // public DI token
├── mail-transport.interface.ts // internal: per-provider contract
├── log-mailer.transport.ts // v1 only transport
├── interfaces/
│ ├── mail-payload.interface.ts
│ ├── mail-send-result.interface.ts
│ └── delivery-report.interface.ts
└── index.ts
MailerPort (public interface, consumed by invitations/):
export const MAILER_PORT = Symbol('MAILER_PORT');
export interface MailerPort {
sendEmail(payload: MailPayload): Promise<MailSendResult>;
onDeliveryReport(handler: DeliveryReportHandler): void;
}
Supporting types:
export interface MailPayload {
to: string;
subject: string;
bodyHtml: string;
bodyText?: string;
correlationId: string; // Invitation.id; echoed back in delivery reports
}
export interface MailSendResult {
correlationId: string;
acceptedAt: Date;
}
export type DeliveryReportHandler = (report: DeliveryReport) => Promise<void>;
export interface DeliveryReport {
correlationId: string;
outcome: 'DELIVERED' | 'FAILED';
reason?: string;
reportedAt: Date;
}
MailTransport (internal, implemented by LogMailer and later ResendTransport):
MailerService: owns a single active MailTransport (selected at module init based on config — LogMailer in dev/test, later ResendTransport when enabled). Maintains a list of registered DeliveryReportHandlers and a public internal method ingestDeliveryReport(report) that fans out to all subscribers. sendEmail delegates to the active transport. onDeliveryReport appends to the subscriber list.
LogMailer: writes the payload to Pino as a structured mailer.send event (subject, to, correlationId), returns { correlationId, acceptedAt: new Date() }. Never calls ingestDeliveryReport — it cannot fail. In v1, this is the only transport.
Why no webhook controller in this module: provider webhook auth schemes are not uniform (Resend uses Svix signatures, SendGrid uses ECDSA, Postmark uses Basic Auth). Baking a generic HMAC controller into mailer/ would be premature. The webhook controller that translates a provider payload into a DeliveryReport belongs to the provider adapter (mailer/resend/). The internal seam (MailerService.ingestDeliveryReport) is what remains stable across providers.
Trade-off accepted for v1: FAILED status is production-unreachable until the Resend spec lands, because LogMailer cannot fail. The state is still exercised by unit tests that call mailerService.ingestDeliveryReport(...) directly.
2.3 InvitationsService public API¶
class InvitationsService implements OnModuleInit {
constructor(
private readonly prisma: PrismaService,
@Inject(MAILER_PORT) private readonly mailer: MailerPort,
) {}
onModuleInit() {
this.mailer.onDeliveryReport((report) => this.handleDeliveryReport(report));
}
// Admin actions
sendBatch(input: SendBatchInput): Promise<SendBatchResult>;
resend(invitationId: string): Promise<Invitation>;
reset(invitationId: string): Promise<void>;
// Invalidation hook — called from role-module update/delete txs
invalidateByRecipient(
tx: Prisma.TransactionClient,
recipientType: RecipientType,
recipientId: string,
reason: 'EMAIL_CHANGED' | 'ROLE_DELETED',
): Promise<void>;
// Reads
listProjected(query: ListInvitationsQuery): Promise<ProjectedInvitationDto[]>;
getFailures(recipientType?: RecipientType): Promise<FailedInvitationDto[]>;
// Recipient side (called from future RUS-1/TUS-1 endpoint)
acceptToken(rawToken: string, password: string): Promise<AcceptResult>;
// Internal — invoked via mailer subscription
private handleDeliveryReport(report: DeliveryReport): Promise<void>;
}
Semantics are documented in detail in §3 (data flow) and §4 (error handling).
2.4 InvitationsController endpoints¶
Route base /invitations. Guarded by JwtAuthGuard, ScopeGuard, ActionGuard globally for this controller.
@Controller('invitations')
@ProtectedResource()
export class InvitationsController {
@Post('send')
@RequireAction(EntityKey.INVITATIONS, 'send')
sendBatch(@Body() dto: SendInvitationsDto): Promise<SendBatchResultDto>;
@Get()
@RequireScopes(EntityKey.INVITATIONS, 'read')
list(@Query() query: ListInvitationsQueryDto): Promise<ProjectedInvitationDto[]>;
@Post(':id/resend')
@RequireAction(EntityKey.INVITATIONS, 'send')
resend(@Param('id', ParseUUIDPipe) id: string): Promise<ProjectedInvitationDto>;
@Post(':id/reset')
@RequireAction(EntityKey.INVITATIONS, 'reset')
@HttpCode(204)
reset(@Param('id', ParseUUIDPipe) id: string): Promise<void>;
@Get('delivery-failures')
@RequireScopes(EntityKey.INVITATIONS, 'read')
getFailures(
@Query('recipientType') recipientType?: RecipientType,
): Promise<FailedInvitationDto[]>;
}
Per the project convention (see docs/04-rbac.md and §2.6): mutating routes use only @RequireAction — actions embed their required scopes at permission-compilation time, so a redundant @RequireScopes is unnecessary. Read routes use @RequireScopes(EntityKey.X, 'read') since they perform no action. EntityKey.INVITATIONS is added to the existing EntityKey enum as part of this spec.
DTOs:
class SendInvitationsDto {
@IsEnum(RecipientType) recipientType: RecipientType;
@IsArray() @ArrayMinSize(1) @ArrayMaxSize(500) @IsUUID('4', { each: true })
recipientIds: string[];
}
class SendBatchResultDto {
sent: string[];
debounced: string[];
failed: { recipientId: string; reason: string }[];
}
class ListInvitationsQueryDto {
@IsOptional() @IsEnum(RecipientType) recipientType?: RecipientType;
@IsOptional() @IsEnum(UiInvitationStatus) status?: UiInvitationStatus;
@IsOptional() @IsInt() @Min(1) page?: number;
@IsOptional() @IsInt() @Min(1) @Max(100) limit?: number;
}
enum UiInvitationStatus {
NOT_SENT = 'NOT_SENT',
SENT = 'SENT',
FAILED = 'FAILED',
ACCESSED = 'ACCESSED',
}
// Discriminated union via @ApiExtraModels + oneOf + discriminator on recipientType.
// All three variants share the base shape — `recipientType` is the only discriminator
// the FE needs for type narrowing. (Referent pair-status projection was removed 2026-04-22.)
type ProjectedInvitationDto =
| (BaseProjectedInvitationDto & { recipientType: 'TEACHER' })
| (BaseProjectedInvitationDto & { recipientType: 'STAFF' })
| (BaseProjectedInvitationDto & { recipientType: 'REFERENT' });
class BaseProjectedInvitationDto {
invitationId: string | null;
recipientId: string;
recipientEmail: string;
recipientDisplayName: string;
status: UiInvitationStatus;
lastSendAt: string | null;
sendCount: number; // 0 when status is NOT_SENT; ≥1 once an invitation row exists
lastFailureReason: string | null;
}
class FailedInvitationDto {
invitationId: string;
recipientType: RecipientType;
recipientId: string;
recipientEmail: string;
recipientDisplayName: string;
lastSendAt: string;
sendCount: number;
lastFailureReason: string;
}
2.5 projectPairStatus — SUPERSEDED 2026-04-22¶
Removed. See the Amendments section at the top of this document. ReferentInvitationDto now carries only the account-level status. Linked-student display, if needed by the UI, is a concern of the referents module, not the invitations projection.
2.6 RBAC catalogue additions¶
New entity INVITATIONS in prisma/seed/rbac-catalogue.ts (exact file to be located at implementation time; pattern lives in the seed files):
{
key: 'INVITATIONS',
label: 'Invitations',
scopes: [
{ key: 'default', label: 'Invitation records', fields: ['*'] },
],
actions: [
{ key: 'send', label: 'Send / resend credentials', requiredScopes: ['default'] },
{ key: 'reset', label: 'Reset access', requiredScopes: ['default'] },
],
}
No read action — per the project convention, read access is governed by scope presence + FieldFilterInterceptor. Single default scope covers all columns; there is no sensitive sub-grouping within invitation data.
Preset role assignments (seed defaults):
| Preset | Scopes on INVITATIONS | Actions |
|---|---|---|
| Tenant Admin | default |
send, reset |
| School Admin | default |
send, reset |
| Teacher | — | — |
| Staff | — | — |
| Referent | — | — |
Production tenants receive the catalogue rows + admin-preset assignments via a data migration generated alongside the schema migration.
3. Data flow¶
3.1 Send batch — POST /invitations/send¶
One transaction per recipient, not one big tx. A single invalid email in a batch of 50 does not roll back the other 49.
Per recipient:
- Open tx.
- Fetch the role row by
(tenantId, id). - Pre-flight validation:
- role row missing → append
{ recipientId, reason: 'NOT_FOUND' }tofailed[], abort tx. - role email missing → append
{ recipientId, reason: 'MISSING_EMAIL' }tofailed[], abort tx. - existing invitation with
status = ACCESSED→ append{ recipientId, reason: 'ALREADY_ACCESSED' }tofailed[], abort tx. (Use reset first.) - Debounce: if an existing invitation has
lastSendAt > now() - 10s, appendrecipientIdtodebounced[], abort tx. - Generate
rawToken = crypto.randomBytes(32)(base64url-encoded),tokenHash = sha256(rawToken). upserton compound key(tenantId, recipientType, recipientId):- create:
status=SENT, tokenHash, lastSendAt=now(), sendCount=1, lastFailureReason=null, acceptedAt=null. - update:
status=SENT, tokenHash, lastSendAt=now(), sendCount={ increment: 1 }, lastFailureReason=null, acceptedAt=null. - Commit.
afterCommit: callmailer.sendEmail({ to: role.email, subject, bodyHtml, correlationId: invitation.id, link: buildInviteLink(rawToken) }).- Append
recipientIdtosent[].
The raw token is held only in a local variable during the request. It appears in the email link (once) and is never persisted.
3.2 Resend — POST /invitations/:id/resend¶
- Load
Invitationbyidwithin the caller's tenant. - Reject with
INVITATION_NOT_FOUND(404) if missing or wrong tenant. - Reject with
INVITATION_ALREADY_ACCESSED(409) ifstatus === ACCESSED. - Open tx:
- Generate new
rawToken+tokenHash(old hash is overwritten — the prior link dies). - Update:
status=SENT, tokenHash, lastSendAt=now(), sendCount={ increment: 1 }, lastFailureReason=null. - Commit.
afterCommit:mailer.sendEmail(...)with the new link.- Return the updated invitation projected through the same shape as list.
No debounce on explicit resend — admin clicked the button deliberately. NestJS throttler still applies.
3.3 Reset — POST /invitations/:id/reset¶
- Load
Invitationbyidwithin the caller's tenant. - Reject with
INVITATION_NOT_FOUND(404) if missing or wrong tenant. - Open tx:
- If
status === ACCESSED: a. Fetch the role row by(recipientType, recipientId). b. Ifrole.userId !== null: -UPDATE User SET isActive=false WHERE id = role.userId-DELETE RefreshToken WHERE userId = role.userId(revoke all sessions) - Update role row:userId = null. DELETE Invitation WHERE id = :id.- Commit.
- Return 204.
No mailer call. A subsequent POST /invitations/send is a separate action.
3.4 Email-change auto-invalidation¶
Inside TeachersService.update / StaffService.update / ReferentsService.update:
await prisma.$transaction(async (tx) => {
const current = await tx.teacher.findUniqueOrThrow({ where: { id } });
if (dto.email !== undefined && dto.email !== current.email) {
await invitationsService.invalidateByRecipient(tx, 'TEACHER', id, 'EMAIL_CHANGED');
}
await tx.teacher.update({ where: { id }, data: dto });
});
invalidateByRecipient(tx, type, id, reason):
- Find invitation by
(tenantId, recipientType, recipientId). If none, return. - If
status === ACCESSED: - Soft-delete the linked user (same as reset:
isActive=false, revoke refresh tokens, null outrole.userId). Performed ontx. DELETE Invitation.
reason = 'ROLE_DELETED' variant is called by the role-deletion service inside its delete tx. Same logic. There is no FK cascade from Invitation to the role tables (by design — polymorphic recipientId has no FK), so this explicit hook is required.
3.5 Delivery report (internal seam, no HTTP in v1)¶
(future) Resend webhook controller receives POST /mailer/resend/webhook
verifies Svix signature
maps provider payload → DeliveryReport
calls mailerService.ingestDeliveryReport(report)
│
▼
MailerService.ingestDeliveryReport(report)
for each registered subscriber:
await handler(report)
│
▼
InvitationsService.handleDeliveryReport(report)
open tx:
invitation = tx.invitation.findUnique({ where: { id: report.correlationId } })
if !invitation: log.warn('orphan delivery report'); return.
if invitation.status === 'ACCESSED': return. // report arrived too late
if report.outcome === 'FAILED':
update: status='FAILED', lastFailureReason=report.reason ?? 'Unknown'
// 'DELIVERED' is a no-op in v1 — SENT already means "handed to provider".
commit
In v1, LogMailer never calls ingestDeliveryReport. The subscription wiring + handleDeliveryReport are exercised by unit tests that invoke ingestDeliveryReport directly.
3.6 Acceptance (backend logic, HTTP endpoint in future spec)¶
async acceptToken(rawToken: string, password: string): Promise<AcceptResult> {
const tokenHash = sha256(rawToken);
// Global lookup — no tenant context because recipient is unauthenticated.
// Safe: tokenHash is globally unique (@@unique) and carries 256 bits of entropy.
const invitation = await prisma.invitation.findUnique({ where: { tokenHash } });
if (!invitation || invitation.status === 'ACCESSED') {
throw new GoneException('INVITATION_INVALID_OR_USED');
}
return await prisma.$transaction(async (tx) => {
const role = await loadRoleRow(tx, invitation.recipientType, invitation.recipientId);
const passwordHash = await argon2.hash(password);
const user = await tx.user.create({
data: {
tenantId: invitation.tenantId,
email: role.email, // canonical source; email-change would have invalidated
passwordHash,
isActive: true,
},
});
await updateRoleRowUserId(tx, invitation.recipientType, invitation.recipientId, user.id);
await tx.invitation.update({
where: { id: invitation.id },
data: { status: 'ACCESSED', acceptedAt: new Date(), tokenHash: null },
});
return { userId: user.id, tenantId: invitation.tenantId };
});
}
The caller (auth module in the RUS-1/TUS-1 spec) takes { userId, tenantId } and issues the JWT/refresh tokens.
4. Error handling¶
4.1 Error codes¶
Follows the standard SIS error envelope (docs/06-error-handling.md). Codes specific to this subsystem:
| Code | HTTP | Where |
|---|---|---|
INVITATION_NOT_FOUND |
404 | resend, reset, acceptance when tokenHash miss |
INVITATION_ALREADY_ACCESSED |
409 | resend targeting an accepted invitation |
INVITATION_INVALID_OR_USED |
410 Gone | acceptToken — bad/reused/cleared token |
INVITATION_BATCH_TOO_LARGE |
400 | sendBatch when validation rejects >500 IDs (class-validator) |
INSUFFICIENT_SCOPE |
403 | ScopeGuard rejection — standard envelope |
ACTION_NOT_PERMITTED |
403 | ActionGuard rejection — standard envelope |
Per-recipient validation failures inside sendBatch are not thrown — they are surfaced via SendBatchResultDto.failed[]:
failed[].reason |
Meaning |
|---|---|
NOT_FOUND |
Role row with that ID does not exist in the tenant. |
MISSING_EMAIL |
Role row has no email on file. |
ALREADY_ACCESSED |
Recipient already accepted a previous invitation; use reset first. |
4.2 Transactional guarantees¶
- Per-recipient isolation:
sendBatchdoes one tx per recipient. Failures do not cross-contaminate. - afterCommit mailer dispatch: implemented via a post-commit callback queue (helper in
src/common/if one does not already exist — to be located at implementation time). The transaction commits, then and only then ismailer.sendEmailcalled. A rolled-back tx never triggers an email. - Mailer failure in v1:
LogMailer.sendcannot throw in practice. If a bug causes it to throw, the DB already committedstatus=SENT. This tiny drift is accepted for v1 because the state is production-unreachable. The Resend spec will introduce proper mailer-failure handling (aFAILEDterminal state written via the delivery-report path, not viasendEmailthrows). - Delivery-report idempotency:
handleDeliveryReportis safe to call multiple times for the samecorrelationId— it early-returns onACCESSED, and repeatedFAILEDwrites set the same fields. - Acceptance tx: user create + role-row update + invitation status flip all run in a single tx. Failure rolls back the user row; the token remains valid for retry.
4.3 Concurrency¶
- Double-click on send: debounce via
lastSendAt. Re-send requests within 10s returnDEBOUNCED. - Double-click on resend: NestJS throttler at the controller level (global default rate limit already configured; no custom throttle for this route). Two legitimate in-flight resends would each generate a new token — the second wins in DB order, the first's link dies silently. Harmless.
- Simultaneous accept + admin reset: unique
tokenHashconstraint + thestatus !== 'ACCESSED'guard inside the acceptance tx ensure at most one wins. If reset commits first, acceptance throwsINVITATION_INVALID_OR_USED. If acceptance commits first, reset operates on an accepted invitation and proceeds normally (soft-deletes the user, deletes the row) — the user who just accepted is immediately logged out. - Race on email-change + send: both operate on the same
(tenantId, recipientType, recipientId)unique key via upsert/delete. Prisma serializes these at the DB level. Either order produces a sensible terminal state.
4.4 Observability¶
- Pino structured logs on every state transition:
invitation.sent,invitation.resent,invitation.reset,invitation.accepted,invitation.invalidated,invitation.delivery_failed,invitation.debounced, each carrying{ tenantId, invitationId, recipientType, recipientId }. mailer.sendevent fromLogMailercarries{ to, subject, correlationId }— useful for dev inspection before Resend lands.- Orphan delivery reports (
correlationIdmatches no invitation) log atwarn.
5. Testing strategy¶
Follows patterns in docs/09-testing.md.
5.1 Unit tests (Jest, *.spec.ts in src/)¶
InvitationsService:
- sendBatch — happy path (all succeed), mixed outcomes (one NOT_FOUND + one MISSING_EMAIL + one ACCESSED + rest SENT), debounce case, empty array rejected by DTO validation.
- resend — happy, 404 missing, 409 accessed.
- reset — happy for SENT (invitation deleted, no user to soft-delete), happy for ACCESSED (user soft-deleted, refresh tokens revoked, role userId nulled, invitation deleted), 404 missing.
- invalidateByRecipient — EMAIL_CHANGED for SENT (just deletes), EMAIL_CHANGED for ACCESSED (soft-deletes user + deletes invitation), ROLE_DELETED equivalents, no-op when no invitation exists.
- listProjected — teacher-only filter, referent-only filter (account-level status only), cross-type "all" view, status=NOT_SENT returns role rows with no invitation (LEFT JOIN IS NULL).
- getFailures — returns only FAILED rows, optional type filter.
- acceptToken — happy (creates user, flips status, clears token), rejects on invalid token, rejects on already-accepted.
- handleDeliveryReport — FAILED outcome updates status + reason, DELIVERED is a no-op, orphan correlationId logs warn.
MailerService:
- sendEmail routes to the active transport.
- onDeliveryReport registers handler; ingestDeliveryReport calls all handlers in order.
LogMailer: emits the expected Pino event shape; returns a MailSendResult with a current timestamp.
5.2 E2E tests (test/**/*.e2e-spec.ts, real DB)¶
Per the tenant-isolation pattern used elsewhere. Seed one tenant with admin, teacher, staff, referent, one student, one StudentReferentLink.
POST /invitations/send— admin sends to a teacher, verifies 200 withsent: [id], verifiesInvitationrow exists, verifies Pino capturedmailer.send.POST /invitations/sendwith wrong tenant teacher ID →failed[]withNOT_FOUND.GET /invitations— no filter returns all three types with discriminator; teacher-only filter returns only teachers;status=NOT_SENTreturns role rows without an invitation.POST /invitations/:id/resend— newtokenHashgenerated, old link invalidated (simulated by submitting the stale raw token toacceptToken→INVITATION_INVALID_OR_USED).POST /invitations/:id/resendon accepted invitation → 409.POST /invitations/:id/reseton accepted invitation → userisActive=false, refresh tokens deleted, roleuserId=null, invitation row gone.GET /invitations/delivery-failures— after injecting a failed delivery report (via directmailerService.ingestDeliveryReport), the failed invitation appears; DELIVERED reports do not.- Auto-invalidation —
PATCH /teachers/:idwith new email deletes the invitation; subsequentPOST /invitations/sendcreates a new one. - RBAC — teacher/staff/referent callers get 403 on all five endpoints.
- Cross-tenant — admin of tenant A cannot see/modify invitations of tenant B (404, not 403 — tenant boundary opacity).
5.3 Tests explicitly not included¶
- No tests for HMAC webhook signature verification — the webhook controller doesn't exist in this spec.
- No tests for actual email sending —
LogMaileris a no-op; provider-level tests belong to the Resend spec.
6. Migration plan¶
6.1 Schema migration¶
npx prisma migrate dev generates a migration that:
- Creates invitations table with columns per §2.1.
- Creates RecipientType and InvitationStatus enum types.
- Creates compound unique index (tenant_id, recipient_type, recipient_id).
- Creates unique index on token_hash.
- Creates non-unique indexes (tenant_id, status) and (tenant_id, recipient_type, status).
- Foreign key tenant_id → tenants.id with ON DELETE CASCADE.
Per docs/12-migrations.md: audit the generated migration.sql against the hazard checklist before committing. This migration is additive (new table, no column changes to existing tables) — low hazard — but still subject to the safety audit.
6.2 RBAC data migration¶
A separate migration (or in-migration SQL block, depending on seed convention) inserts:
- One row into the RBAC entity catalogue for INVITATIONS.
- One scope row (default).
- Two action rows (send, reset).
- For every existing tenant, grant default scope + both actions to admin-tier role presets.
Exact table names and seed helpers to be located at implementation time.
6.3 No code-path toggles¶
No feature flag. The endpoints are live the moment the migration runs. Frontend rolls out against the existing API the same day.
7. Open questions (to resolve during implementation)¶
Items that did not block the design but need concrete answers as code is written:
afterCommithelper location. Ifsrc/common/already has a post-commit callback utility, reuse it. If not, add one as a small helper alongsidePrismaService. Short investigation task at implementation kickoff.- RBAC seed file path. The brainstorm references
prisma/seed/rbac-catalogue.ts; actual location to be confirmed by reading the current seed structure. - Display-name resolution for
recipientDisplayName. Teachers and staff exposefirstName + lastName; referents' names are nullable (seeproject_referent_name_nullable.md) and are populated post-login. For list views: use email fallback when both name parts are null. Small detail; implementation-time decision. - Email template content. Subject line, HTML/text body, branding. Tracked in a follow-up copy task; the spec requires only that
MailPayloadbe populated from whatever templates exist at implementation time. - Invite link URL shape. The absolute URL pointing at the frontend acceptance page. Requires a config value; reuse the existing frontend-base-URL config if present, otherwise add one.
loadRoleRowtenant scoping inacceptToken. Look up the role row byidonly — not(id, tenantId). Role entity IDs are globally unique UUIDs, and the invitation row already carries the authoritativetenantIdused to create theUser. Avoiding a tenant filter here keeps the option open for a future "shared identity across tenants" model (e.g. a parent with children at two sibling schools) without having to revisit this code path.
8. Follow-up specs¶
In order:
- Resend adapter —
mailer/resend/submodule:resend.transport.ts,resend-webhook.controller.ts,svix-signature.guard.ts,resend-report.mapper.ts. WiresResendTransportas the active mailer via config. MigratesFAILEDfrom "unreachable" to "production-reachable". Adds the webhook HMAC guard. - Referent acceptance flow (RUS-1) —
POST /auth/invitations/acceptendpoint callingInvitationsService.acceptToken. Issues JWT + refresh token. - Teacher acceptance flow (TUS-1) — structurally identical to RUS-1 for teachers. Likely folded into the same spec as RUS-1 since the logic is the same.
- Setup Wizard integration — adds "Send credentials to teachers/staff" steps. Brainstormed alongside US-12 / US-15.
- Referent→Referent pending-request workflow (RUS-2) — self-service referent addition with admin approval gate. Calls the existing admin
POST /invitations/sendon approval. - Shared identity across tenants — future, scope-TBD project. Today
Useris tenant-scoped (User.tenantId), so a parent with children in two sibling schools, or a teacher working at two schools of the same group, needs two separateUserrows with separate credentials. TheacceptTokenflow (per decision #19) is built to avoid foreclosing a future model where a single identity spans tenants, but the actual data-model refactor (dedupUser/Teacher/Referentidentities, cross-tenant login, tenant-picker UX) is a standalone project and not within the invitations subsystem.