Skip to content

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, so ReferentInvitationDto.pairs[], ReferentPairStatusDto, and the projectPairStatus function have been deleted. Linked-student display (if ever needed) is a read concern for the referents module, not the invitations projection. A FAILED account retains status=FAILED regardless 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 Invitation Prisma model (single table, account-scoped, polymorphic recipient).
  • New mailer/ module — generic: MailerPort interface, MailTransport inner seam, LogMailer transport, MailerService orchestrator with onDeliveryReport(handler) subscription + ingestDeliveryReport(report) internal seam. No webhook controller in this module.
  • New invitations/ module — business logic: InvitationsService, InvitationsController at /invitations, queries in invitations.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 from Teachers/Staff/Referents update and delete services when email changes or the role row is deleted.
  • RBAC catalogue addition: new INVITATIONS entity with single default scope and send / reset actions. Seed updates for tenant-admin-tier role presets.
  • Migration generating the Invitation table + 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.acceptToken method 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 with resend.transport.ts, resend-webhook.controller.ts, svix-signature.guard.ts, and a resend-report.mapper.ts. No changes to invitations/ are expected.
  • Delivery-report HTTP endpoint — provider-specific (Resend uses Svix, others vary). Lives in the Resend spec. In v1 the FAILED status is production-unreachable because LogMailer cannot fail; MailerService.ingestDeliveryReport is wired and tested but has no HTTP caller yet.
  • Queue / BullMQ / retries — future spec. LogMailer and 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 getCompletionStatus public 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/send flow. 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 tokenExpiresAt column. 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-emitter is ever introduced, the one-way invalidation hook and the onDeliveryReport callback 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.

  1. 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.
  2. Email delivery: MailerPort interface + LogMailer now; Resend in the next spec. Real provider adapter drops in without touching invitation code. See §2.2.
  3. Data model: single Invitation table, 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.
  4. User row creation: at acceptance time. Role-entity userId stays NULL until 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.
  5. 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.
  6. Setup Wizard integration: deferred. No public completion status exposed. See scope.
  7. Controller surface: single /invitations (not /admin/invitations). RBAC governs access; URL is role-neutral. List DTO is a discriminated union keyed on recipientType to support an "all invitations" view. See §2.4.
  8. Module partitioning: split invitations/ + mailer/. mailer/ lives at src/mailer/ (has a future controller → not common/). invitations/ depends on MailerPort via DI and registers a delivery-report subscriber at onModuleInit. See §2.
  9. Polymorphic FK: single recipientId string + compound unique (tenantId, recipientType, recipientId). No DB FK, no CHECK constraint. 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.
  10. NOT_SENT is not a persisted status. Absence of an Invitation row = not sent. The Prisma enum has only SENT, FAILED, ACCESSED. The DTO enum includes NOT_SENT and is produced by the projection layer via a LEFT JOIN-style query starting from the role table. Reset, email-change, and role-delete all DELETE the invitation row. See §2.1 and §3.
  11. 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.
  12. RBAC actions: send and reset only. read is implicit via scope access + FieldFilterInterceptor field stripping, per the project convention. See §2.6.
  13. ~~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 no projectPairStatus function. A FAILED account stays FAILED across new pair additions — the resend flow is what refreshes reachability.
  14. afterCommit mailer dispatch. Every outbound email is dispatched after its DB transaction commits, so rolled-back writes cannot leak emails. See §3.1.
  15. Service-layer debounce, ~10s window. Double-click protection lives server-side. See §3.1.
  16. Batch cap 500 recipients. POST /invitations/send validates @ArrayMaxSize(500). See §2.4.
  17. Permissive reset. POST /invitations/:id/reset deletes the invitation row regardless of current status; the user-soft-delete branch only runs when status === ACCESSED. Rejecting non-ACCESSED resets with a 409 would be defensible per US-20 S8 / US-14 S9 wording, but deleting a SENT/FAILED row is a reasonable admin action ("kill the outstanding invite") and not worth a rejection path. See §3.3.
  18. sendCount tracked on Invitation. Defaults to 1 on create; incremented on every resend and on repeat sendBatch update-branch. Surfaced in list / failure DTOs. Lets the frontend distinguish first-send from resend without baking per-recipient nuance into SendBatchResultDto. See §2.1 and §2.4.
  19. acceptToken does not tenant-scope the role-row lookup. Role UUIDs are globally unique; the invitation row's tenantId is 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 a MailerPort reference, calls sendEmail, and registers a onDeliveryReport handler at onModuleInit.
  • 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 in mailer/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_SENT is produced by the projection layer.
  • Polymorphic via discriminator, no DB FK. recipientId is 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 by upsert, invalidateByRecipient, and the list LEFT JOIN.
  • tokenHash unique + nullable. SHA-256(32-byte random). Cleared to NULL on acceptance (the token is single-use). Unique index enables O(1) lookup from the acceptance endpoint without a tenant context.
  • lastSendAt non-nullable. Every row exists because a send was attempted; there is no state where lastSendAt is unset.
  • sendCount tracks first-send vs resend. Defaults to 1 on create. Incremented on every resend (explicit or via repeat sendBatch). 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):

export interface MailTransport {
  send(payload: MailPayload): Promise<MailSendResult>;
}

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 projectPairStatusSUPERSEDED 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:

  1. Open tx.
  2. Fetch the role row by (tenantId, id).
  3. Pre-flight validation:
  4. role row missing → append { recipientId, reason: 'NOT_FOUND' } to failed[], abort tx.
  5. role email missing → append { recipientId, reason: 'MISSING_EMAIL' } to failed[], abort tx.
  6. existing invitation with status = ACCESSED → append { recipientId, reason: 'ALREADY_ACCESSED' } to failed[], abort tx. (Use reset first.)
  7. Debounce: if an existing invitation has lastSendAt > now() - 10s, append recipientId to debounced[], abort tx.
  8. Generate rawToken = crypto.randomBytes(32) (base64url-encoded), tokenHash = sha256(rawToken).
  9. upsert on compound key (tenantId, recipientType, recipientId):
  10. create: status=SENT, tokenHash, lastSendAt=now(), sendCount=1, lastFailureReason=null, acceptedAt=null.
  11. update: status=SENT, tokenHash, lastSendAt=now(), sendCount={ increment: 1 }, lastFailureReason=null, acceptedAt=null.
  12. Commit.
  13. afterCommit: call mailer.sendEmail({ to: role.email, subject, bodyHtml, correlationId: invitation.id, link: buildInviteLink(rawToken) }).
  14. Append recipientId to sent[].

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

  1. Load Invitation by id within the caller's tenant.
  2. Reject with INVITATION_NOT_FOUND (404) if missing or wrong tenant.
  3. Reject with INVITATION_ALREADY_ACCESSED (409) if status === ACCESSED.
  4. Open tx:
  5. Generate new rawToken + tokenHash (old hash is overwritten — the prior link dies).
  6. Update: status=SENT, tokenHash, lastSendAt=now(), sendCount={ increment: 1 }, lastFailureReason=null.
  7. Commit.
  8. afterCommit: mailer.sendEmail(...) with the new link.
  9. 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

  1. Load Invitation by id within the caller's tenant.
  2. Reject with INVITATION_NOT_FOUND (404) if missing or wrong tenant.
  3. Open tx:
  4. If status === ACCESSED: a. Fetch the role row by (recipientType, recipientId). b. If role.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.
  5. DELETE Invitation WHERE id = :id.
  6. Commit.
  7. 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):

  1. Find invitation by (tenantId, recipientType, recipientId). If none, return.
  2. If status === ACCESSED:
  3. Soft-delete the linked user (same as reset: isActive=false, revoke refresh tokens, null out role.userId). Performed on tx.
  4. 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: sendBatch does 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 is mailer.sendEmail called. A rolled-back tx never triggers an email.
  • Mailer failure in v1: LogMailer.send cannot throw in practice. If a bug causes it to throw, the DB already committed status=SENT. This tiny drift is accepted for v1 because the state is production-unreachable. The Resend spec will introduce proper mailer-failure handling (a FAILED terminal state written via the delivery-report path, not via sendEmail throws).
  • Delivery-report idempotency: handleDeliveryReport is safe to call multiple times for the same correlationId — it early-returns on ACCESSED, and repeated FAILED writes 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 return DEBOUNCED.
  • 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 tokenHash constraint + the status !== 'ACCESSED' guard inside the acceptance tx ensure at most one wins. If reset commits first, acceptance throws INVITATION_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.send event from LogMailer carries { to, subject, correlationId } — useful for dev inspection before Resend lands.
  • Orphan delivery reports (correlationId matches no invitation) log at warn.

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 with sent: [id], verifies Invitation row exists, verifies Pino captured mailer.send.
  • POST /invitations/send with wrong tenant teacher ID → failed[] with NOT_FOUND.
  • GET /invitations — no filter returns all three types with discriminator; teacher-only filter returns only teachers; status=NOT_SENT returns role rows without an invitation.
  • POST /invitations/:id/resend — new tokenHash generated, old link invalidated (simulated by submitting the stale raw token to acceptTokenINVITATION_INVALID_OR_USED).
  • POST /invitations/:id/resend on accepted invitation → 409.
  • POST /invitations/:id/reset on accepted invitation → user isActive=false, refresh tokens deleted, role userId=null, invitation row gone.
  • GET /invitations/delivery-failures — after injecting a failed delivery report (via direct mailerService.ingestDeliveryReport), the failed invitation appears; DELIVERED reports do not.
  • Auto-invalidation — PATCH /teachers/:id with new email deletes the invitation; subsequent POST /invitations/send creates 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 — LogMailer is 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_idtenants.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:

  • afterCommit helper location. If src/common/ already has a post-commit callback utility, reuse it. If not, add one as a small helper alongside PrismaService. 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 expose firstName + lastName; referents' names are nullable (see project_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 MailPayload be 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.
  • loadRoleRow tenant scoping in acceptToken. Look up the role row by id only — not (id, tenantId). Role entity IDs are globally unique UUIDs, and the invitation row already carries the authoritative tenantId used to create the User. 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:

  1. Resend adaptermailer/resend/ submodule: resend.transport.ts, resend-webhook.controller.ts, svix-signature.guard.ts, resend-report.mapper.ts. Wires ResendTransport as the active mailer via config. Migrates FAILED from "unreachable" to "production-reachable". Adds the webhook HMAC guard.
  2. Referent acceptance flow (RUS-1)POST /auth/invitations/accept endpoint calling InvitationsService.acceptToken. Issues JWT + refresh token.
  3. 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.
  4. Setup Wizard integration — adds "Send credentials to teachers/staff" steps. Brainstormed alongside US-12 / US-15.
  5. Referent→Referent pending-request workflow (RUS-2) — self-service referent addition with admin approval gate. Calls the existing admin POST /invitations/send on approval.
  6. Shared identity across tenants — future, scope-TBD project. Today User is 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 separate User rows with separate credentials. The acceptToken flow (per decision #19) is built to avoid foreclosing a future model where a single identity spans tenants, but the actual data-model refactor (dedup User / Teacher / Referent identities, cross-tenant login, tenant-picker UX) is a standalone project and not within the invitations subsystem.