diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a64c3f..5285ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- Organization invitations: `POST /organizations/:id/invitations` (OWNER/ADMIN) invites an email address with an `ADMIN` or `STAFF` role, and `POST /organizations/invitations/accept` creates the `OrganizationMember`; invites expire after 7 days (`410` once expired) - `GET /tickets/:ticketId` (owner or organization staff), `GET /tickets/:ticketId/qr` (PNG QR code, owner only) and `GET /tickets/:ticketId/qr-token` (short-lived signed QR token, configurable via `QR_TOKEN_TTL_SECONDS` / `QR_TOKEN_CLOCK_SKEW_SECONDS`) which `GET /tickets/verify/:value` also accepts - Controller unit specs for events, tickets, organizations and users (route wiring, guards, DTO validation, error propagation) and an auth-flow e2e suite (register, login, `/users/me`) - Auth (register/login, JWT) diff --git a/docs/API.md b/docs/API.md index e172b24..705e19f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -338,6 +338,64 @@ Clears `deletedAt` (members only). Returns the row. curl -s -X POST "$API/v1/organizations/2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b/restore" -H "Authorization: Bearer $TOKEN" ``` +### `POST /v1/organizations/:id/invitations` + +Invites an email address to join the organization (`OWNER`/`ADMIN` only). +The person doesn't need an account yet. The raw `token` is returned **once**; +only its hash is stored, so deliver it to the invitee yourself (e.g. in an +accept link). Invites expire 7 days after creation. + +| field | type | rules | +|---|---|---| +| `email` | string | required, trimmed and lower-cased | +| `role` | string | optional, `ADMIN` or `STAFF` (default `STAFF`) | + +```bash +curl -s -X POST "$API/v1/organizations/2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b/invitations" \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"email":"grace@example.com","role":"ADMIN"}' +``` + +```json +{ + "invitation": { + "id": "7a1c3e5f-2b4d-4c6e-8f0a-1b2c3d4e5f6a", + "organizationId": "2c7e4a10-5b6d-4f8e-9a1b-3c4d5e6f7a8b", + "email": "grace@example.com", + "role": "ADMIN", + "invitedById": "5e2b9f0c-1a3d-4b7e-8f9a-0c1d2e3f4a5b", + "expiresAt": "2026-10-25T12:00:00.000Z", + "acceptedAt": null, + "acceptedById": null, + "createdAt": "2026-10-18T12:00:00.000Z" + }, + "token": "inv_3f9c…" +} +``` + +`403` when the caller isn't an `OWNER`/`ADMIN`, `404` when the organization is +missing or soft-deleted, `409` when that email already belongs to a member. + +### `POST /v1/organizations/invitations/accept` + +Accepts an invite as the signed-in user, whose account email must match the +invite's. Creates the `OrganizationMember` with the invited role and promotes +an `ATTENDEE` to `ORGANIZER`. Returns the membership row. + +| field | type | rules | +|---|---|---| +| `token` | string | required, the token from the invite | + +```bash +curl -s -X POST "$API/v1/organizations/invitations/accept" \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"token":"inv_3f9c…"}' +``` + +`410` when the invite has expired, `404` for an unknown token or a +soft-deleted organization, `403` when the email doesn't match, `409` when the +invite was already accepted or the caller is already a member. + ## Webhooks All three routes require membership in `:organizationId`. See diff --git a/docs/bruno/organizations-invitations/Accept.bru b/docs/bruno/organizations-invitations/Accept.bru new file mode 100644 index 0000000..f2c1afb --- /dev/null +++ b/docs/bruno/organizations-invitations/Accept.bru @@ -0,0 +1,20 @@ +meta { + name: Accept + type: http +} + +post { + url: {{baseUrl}}/v1/organizations/invitations/accept + body: json + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +body:json { + { + "token": "x" + } +} diff --git a/docs/bruno/organizations-invitations/Create.bru b/docs/bruno/organizations-invitations/Create.bru new file mode 100644 index 0000000..3491a7d --- /dev/null +++ b/docs/bruno/organizations-invitations/Create.bru @@ -0,0 +1,21 @@ +meta { + name: Create + type: http +} + +post { + url: {{baseUrl}}/v1/organizations/{{id}}/invitations + body: json + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +body:json { + { + "email": "you@example.com", + "role": "string" + } +} diff --git a/prisma/migrations/20261021000000_organization_invitation/migration.sql b/prisma/migrations/20261021000000_organization_invitation/migration.sql new file mode 100644 index 0000000..39ca24c --- /dev/null +++ b/prisma/migrations/20261021000000_organization_invitation/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "OrganizationInvitation" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" "OrgMemberRole" NOT NULL DEFAULT 'STAFF', + "tokenHash" TEXT NOT NULL, + "invitedById" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "acceptedAt" TIMESTAMP(3), + "acceptedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OrganizationInvitation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OrganizationInvitation_tokenHash_key" ON "OrganizationInvitation"("tokenHash"); + +-- CreateIndex +CREATE INDEX "OrganizationInvitation_organizationId_email_idx" ON "OrganizationInvitation"("organizationId", "email"); + +-- AddForeignKey +ALTER TABLE "OrganizationInvitation" ADD CONSTRAINT "OrganizationInvitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrganizationInvitation" ADD CONSTRAINT "OrganizationInvitation_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrganizationInvitation" ADD CONSTRAINT "OrganizationInvitation_acceptedById_fkey" FOREIGN KEY ("acceptedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa1ad74..8ff5aa1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -107,6 +107,8 @@ model User { promoRedemptions PromoCodeRedemption[] auditLogs AuditLog[] pendingTxs PendingTx[] + sentInvitations OrganizationInvitation[] @relation("InvitationInviter") + acceptedInvitations OrganizationInvitation[] @relation("InvitationAcceptor") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -127,6 +129,7 @@ model Organization { /// Default `Event.royaltyBps` applied when a new event's DTO omits it. defaultRoyaltyBps Int @default(500) members OrganizationMember[] + invitations OrganizationInvitation[] events Event[] webhookEndpoints WebhookEndpoint[] /// #207 — soft-delete marker. Null = live; non-null = deleted at that time. @@ -149,6 +152,29 @@ model OrganizationMember { @@index([userId]) } +/// An invite for an email address to join an `Organization`, so members can be +/// added before they have an account. Only a SHA-256 hash of the opaque token +/// is stored; the raw token is returned once at creation. Accepting creates +/// the `OrganizationMember`; invites expire 7 days after creation. +model OrganizationInvitation { + id String @id @default(uuid()) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String + /// Stored trimmed and lower-cased, matching `User.email`. + email String + role OrgMemberRole @default(STAFF) + tokenHash String @unique + invitedBy User? @relation("InvitationInviter", fields: [invitedById], references: [id], onDelete: SetNull) + invitedById String? + expiresAt DateTime + acceptedAt DateTime? + acceptedBy User? @relation("InvitationAcceptor", fields: [acceptedById], references: [id], onDelete: SetNull) + acceptedById String? + createdAt DateTime @default(now()) + + @@index([organizationId, email]) +} + model Event { id String @id @default(uuid()) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) diff --git a/src/organizations/dto/accept-invitation.dto.ts b/src/organizations/dto/accept-invitation.dto.ts new file mode 100644 index 0000000..c0fa319 --- /dev/null +++ b/src/organizations/dto/accept-invitation.dto.ts @@ -0,0 +1,8 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class AcceptInvitationDto { + @IsString() + @MinLength(1) + @MaxLength(256) + token!: string; +} diff --git a/src/organizations/dto/create-invitation.dto.ts b/src/organizations/dto/create-invitation.dto.ts new file mode 100644 index 0000000..3041528 --- /dev/null +++ b/src/organizations/dto/create-invitation.dto.ts @@ -0,0 +1,18 @@ +import { Transform } from 'class-transformer'; +import { IsEmail, IsIn, IsOptional } from 'class-validator'; +import { OrgMemberRole } from '@prisma/client'; + +/** Roles an invite can grant. Ownership is not transferable by invite. */ +export const INVITABLE_ROLES = [OrgMemberRole.ADMIN, OrgMemberRole.STAFF]; + +export class CreateInvitationDto { + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim().toLowerCase() : value, + ) + @IsEmail() + email!: string; + + @IsOptional() + @IsIn(INVITABLE_ROLES) + role?: OrgMemberRole; +} diff --git a/src/organizations/invitations.controller.spec.ts b/src/organizations/invitations.controller.spec.ts new file mode 100644 index 0000000..5434517 --- /dev/null +++ b/src/organizations/invitations.controller.spec.ts @@ -0,0 +1,90 @@ +import { GoneException, INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { + AUTH, + createControllerApp, + server, + TEST_USER, +} from '../../test/helpers/controller-app'; +import { InvitationsController } from './invitations.controller'; +import { InvitationsService } from './invitations.service'; + +describe('InvitationsController', () => { + let app: INestApplication; + const service = { create: jest.fn(), accept: jest.fn() }; + + beforeAll(async () => { + app = await createControllerApp({ + controller: InvitationsController, + service: { provide: InvitationsService, useValue: service }, + }); + }); + afterAll(() => app.close()); + beforeEach(() => jest.resetAllMocks()); + + it.each([ + ['/organizations/org-1/invitations'], + ['/organizations/invitations/accept'], + ])('rejects unauthenticated POST %s', async (url) => { + await request(server(app)).post(url).expect(401); + }); + + it('POST /:id/invitations normalizes the email and defaults the role', async () => { + service.create.mockResolvedValue({ + invitation: { id: 'inv-1' }, + token: 't', + }); + const res = await request(server(app)) + .post('/organizations/org-1/invitations') + .set(AUTH) + .send({ email: ' New@Example.com ' }) + .expect(201); + + expect(service.create).toHaveBeenCalledWith(TEST_USER.userId, 'org-1', { + email: 'new@example.com', + }); + expect(res.body).toEqual({ invitation: { id: 'inv-1' }, token: 't' }); + }); + + it.each([ + [{ email: 'not-an-email' }], + [{ email: 'a@example.com', role: 'OWNER' }], + [{ email: 'a@example.com', extra: true }], + ])('POST /:id/invitations rejects invalid body %j', async (body) => { + await request(server(app)) + .post('/organizations/org-1/invitations') + .set(AUTH) + .send(body) + .expect(400); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('POST /invitations/accept passes the token for the current user', async () => { + service.accept.mockResolvedValue({ id: 'm-1' }); + await request(server(app)) + .post('/organizations/invitations/accept') + .set(AUTH) + .send({ token: 'inv_abc' }) + .expect(201); + expect(service.accept).toHaveBeenCalledWith(TEST_USER.userId, 'inv_abc'); + }); + + it('POST /invitations/accept returns 410 for an expired invite', async () => { + service.accept.mockRejectedValue( + new GoneException('Invitation has expired'), + ); + await request(server(app)) + .post('/organizations/invitations/accept') + .set(AUTH) + .send({ token: 'inv_abc' }) + .expect(410); + }); + + it('POST /invitations/accept rejects a missing token', async () => { + await request(server(app)) + .post('/organizations/invitations/accept') + .set(AUTH) + .send({}) + .expect(400); + }); +}); diff --git a/src/organizations/invitations.controller.ts b/src/organizations/invitations.controller.ts new file mode 100644 index 0000000..94ab4d3 --- /dev/null +++ b/src/organizations/invitations.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { CurrentUserPayload } from '../auth/decorators/current-user.decorator'; +import { InvitationsService } from './invitations.service'; +import { CreateInvitationDto } from './dto/create-invitation.dto'; +import { AcceptInvitationDto } from './dto/accept-invitation.dto'; + +@Controller('organizations') +@UseGuards(JwtAuthGuard) +export class InvitationsController { + constructor(private readonly invitationsService: InvitationsService) {} + + @Post('invitations/accept') + accept( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: AcceptInvitationDto, + ) { + return this.invitationsService.accept(user.userId, dto.token); + } + + @Post(':id/invitations') + create( + @CurrentUser() user: CurrentUserPayload, + @Param('id') id: string, + @Body() dto: CreateInvitationDto, + ) { + return this.invitationsService.create(user.userId, id, dto); + } +} diff --git a/src/organizations/invitations.service.spec.ts b/src/organizations/invitations.service.spec.ts new file mode 100644 index 0000000..0db1d9e --- /dev/null +++ b/src/organizations/invitations.service.spec.ts @@ -0,0 +1,222 @@ +import { + ConflictException, + ForbiddenException, + GoneException, + NotFoundException, +} from '@nestjs/common'; +import { createHash } from 'crypto'; +import { InvitationsService, INVITATION_TTL_MS } from './invitations.service'; +import type { OrganizationsService } from './organizations.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import { createOrganization } from '../../test/factories'; + +const hash = (token: string) => + createHash('sha256').update(token).digest('hex'); + +describe('InvitationsService', () => { + let service: InvitationsService; + let organizations: { findOne: jest.Mock; assertRole: jest.Mock }; + let prisma: { + organizationInvitation: { + create: jest.Mock; + findUnique: jest.Mock; + updateMany: jest.Mock; + }; + organizationMember: { + findFirst: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + }; + user: { findUnique: jest.Mock; updateMany: jest.Mock }; + $transaction: jest.Mock; + }; + + beforeEach(() => { + organizations = { + findOne: jest.fn().mockResolvedValue(createOrganization({ id: 'org-1' })), + assertRole: jest.fn().mockResolvedValue(undefined), + }; + prisma = { + organizationInvitation: { + create: jest.fn(), + findUnique: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + organizationMember: { + findFirst: jest.fn().mockResolvedValue(null), + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn(), + }, + user: { + findUnique: jest + .fn() + .mockResolvedValue({ id: 'user-2', email: 'new@example.com' }), + updateMany: jest.fn(), + }, + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(prisma)), + }; + service = new InvitationsService( + prisma as unknown as PrismaService, + organizations as unknown as OrganizationsService, + ); + }); + + describe('create', () => { + it('requires OWNER/ADMIN and stores only the token hash with a 7-day expiry', async () => { + prisma.organizationInvitation.create.mockImplementation( + ({ data }: { data: Record }) => ({ + id: 'inv-1', + ...data, + }), + ); + const before = Date.now(); + + const { invitation, token } = await service.create('user-1', 'org-1', { + email: 'new@example.com', + }); + + expect(organizations.assertRole).toHaveBeenCalledWith('org-1', 'user-1', [ + 'OWNER', + 'ADMIN', + ]); + expect(token).toMatch(/^inv_[0-9a-f]{64}$/); + const [[{ data }]] = prisma.organizationInvitation.create.mock.calls as [ + [{ data: Record }], + ]; + expect(data).toMatchObject({ + organizationId: 'org-1', + email: 'new@example.com', + role: 'STAFF', + tokenHash: hash(token), + invitedById: 'user-1', + }); + const expiresAt = (data.expiresAt as Date).getTime(); + expect(expiresAt).toBeGreaterThanOrEqual(before + INVITATION_TTL_MS); + expect(expiresAt).toBeLessThanOrEqual(Date.now() + INVITATION_TTL_MS); + expect(invitation).toMatchObject({ id: 'inv-1' }); + }); + + it('propagates a role rejection without creating an invite', async () => { + organizations.assertRole.mockRejectedValue(new ForbiddenException()); + await expect( + service.create('user-1', 'org-1', { email: 'new@example.com' }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(prisma.organizationInvitation.create).not.toHaveBeenCalled(); + }); + + it('rejects inviting an existing member', async () => { + prisma.organizationMember.findFirst.mockResolvedValue({ id: 'm-1' }); + await expect( + service.create('user-1', 'org-1', { email: 'new@example.com' }), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.organizationInvitation.create).not.toHaveBeenCalled(); + }); + }); + + describe('accept', () => { + const pending = (overrides: Record = {}) => ({ + id: 'inv-1', + organizationId: 'org-1', + email: 'new@example.com', + role: 'ADMIN', + acceptedAt: null, + expiresAt: new Date(Date.now() + 60_000), + organization: createOrganization({ id: 'org-1' }), + ...overrides, + }); + + it('creates the OrganizationMember with the invited role and promotes an ATTENDEE', async () => { + prisma.organizationInvitation.findUnique.mockResolvedValue(pending()); + prisma.organizationMember.create.mockResolvedValue({ + id: 'm-1', + organizationId: 'org-1', + userId: 'user-2', + role: 'ADMIN', + }); + + const member = await service.accept('user-2', 'inv_abc'); + + expect(prisma.organizationInvitation.findUnique).toHaveBeenCalledWith({ + where: { tokenHash: hash('inv_abc') }, + include: { organization: true }, + }); + expect(prisma.organizationInvitation.updateMany).toHaveBeenCalledWith({ + where: { id: 'inv-1', acceptedAt: null }, + data: { acceptedAt: expect.any(Date) as Date, acceptedById: 'user-2' }, + }); + expect(prisma.organizationMember.create).toHaveBeenCalledWith({ + data: { organizationId: 'org-1', userId: 'user-2', role: 'ADMIN' }, + }); + expect(prisma.user.updateMany).toHaveBeenCalledWith({ + where: { id: 'user-2', role: 'ATTENDEE' }, + data: { role: 'ORGANIZER' }, + }); + expect(member).toMatchObject({ id: 'm-1', role: 'ADMIN' }); + }); + + it('returns 410 Gone for an expired invite', async () => { + prisma.organizationInvitation.findUnique.mockResolvedValue( + pending({ expiresAt: new Date(Date.now() - 1) }), + ); + await expect(service.accept('user-2', 'inv_abc')).rejects.toBeInstanceOf( + GoneException, + ); + expect(prisma.organizationMember.create).not.toHaveBeenCalled(); + }); + + it('404s for an unknown token or a soft-deleted organization', async () => { + prisma.organizationInvitation.findUnique.mockResolvedValueOnce(null); + await expect(service.accept('user-2', 'nope')).rejects.toBeInstanceOf( + NotFoundException, + ); + + prisma.organizationInvitation.findUnique.mockResolvedValueOnce( + pending({ + organization: createOrganization({ deletedAt: new Date() }), + }), + ); + await expect(service.accept('user-2', 'inv_abc')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('409s for an already-accepted invite', async () => { + prisma.organizationInvitation.findUnique.mockResolvedValue( + pending({ acceptedAt: new Date() }), + ); + await expect(service.accept('user-2', 'inv_abc')).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it('409s when a concurrent accept claimed the invite first', async () => { + prisma.organizationInvitation.findUnique.mockResolvedValue(pending()); + prisma.organizationInvitation.updateMany.mockResolvedValue({ count: 0 }); + await expect(service.accept('user-2', 'inv_abc')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(prisma.organizationMember.create).not.toHaveBeenCalled(); + }); + + it('409s when the user is already a member', async () => { + prisma.organizationInvitation.findUnique.mockResolvedValue(pending()); + prisma.organizationMember.findUnique.mockResolvedValue({ id: 'm-1' }); + await expect(service.accept('user-2', 'inv_abc')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(prisma.organizationMember.create).not.toHaveBeenCalled(); + }); + + it('403s when the signed-in user is not the invited email', async () => { + prisma.organizationInvitation.findUnique.mockResolvedValue(pending()); + prisma.user.findUnique.mockResolvedValue({ + id: 'user-3', + email: 'other@example.com', + }); + await expect(service.accept('user-3', 'inv_abc')).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(prisma.organizationMember.create).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/organizations/invitations.service.ts b/src/organizations/invitations.service.ts new file mode 100644 index 0000000..fe79ccf --- /dev/null +++ b/src/organizations/invitations.service.ts @@ -0,0 +1,140 @@ +import { + ConflictException, + ForbiddenException, + GoneException, + Injectable, + NotFoundException, + Optional, +} from '@nestjs/common'; +import { OrgMemberRole, UserRole } from '@prisma/client'; +import { createHash, randomBytes } from 'crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import { OrganizationsService } from './organizations.service'; +import { CreateInvitationDto } from './dto/create-invitation.dto'; + +const TOKEN_PREFIX = 'inv'; +export const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +@Injectable() +export class InvitationsService { + constructor( + private readonly prisma: PrismaService, + private readonly organizations: OrganizationsService, + @Optional() private readonly audit?: AuditService, + ) {} + + /** Invites `dto.email` to the organization (OWNER/ADMIN only) and returns the raw token once -- only its hash is persisted. */ + async create( + userId: string, + organizationId: string, + dto: CreateInvitationDto, + ) { + await this.organizations.findOne(organizationId); + await this.organizations.assertRole(organizationId, userId, [ + OrgMemberRole.OWNER, + OrgMemberRole.ADMIN, + ]); + + const existingMember = await this.prisma.organizationMember.findFirst({ + where: { organizationId, user: { email: dto.email } }, + }); + if (existingMember) { + throw new ConflictException( + 'That user is already a member of this organization', + ); + } + + const token = `${TOKEN_PREFIX}_${randomBytes(32).toString('hex')}`; + const invitation = await this.prisma.organizationInvitation.create({ + data: { + organizationId, + email: dto.email, + role: dto.role ?? OrgMemberRole.STAFF, + tokenHash: this.hash(token), + invitedById: userId, + expiresAt: new Date(Date.now() + INVITATION_TTL_MS), + }, + omit: { tokenHash: true }, + }); + await this.audit?.record( + userId, + 'organization.invite', + 'Organization', + organizationId, + { invitationId: invitation.id, role: invitation.role }, + ); + return { invitation, token }; + } + + /** + * Accepts an invite on behalf of the signed-in user, whose email must match + * the invite's. Creates the `OrganizationMember` and promotes an ATTENDEE to + * ORGANIZER. `410` once the invite has expired. + */ + async accept(userId: string, token: string) { + const invitation = await this.prisma.organizationInvitation.findUnique({ + where: { tokenHash: this.hash(token) }, + include: { organization: true }, + }); + // #207 — invites to a soft-deleted organization read as not-found. + if (!invitation || invitation.organization.deletedAt) { + throw new NotFoundException('Invitation not found'); + } + if (invitation.acceptedAt) { + throw new ConflictException('Invitation has already been accepted'); + } + if (invitation.expiresAt.getTime() <= Date.now()) { + throw new GoneException('Invitation has expired'); + } + + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || user.email !== invitation.email) { + throw new ForbiddenException( + 'This invitation was sent to a different email address', + ); + } + + const { organizationId } = invitation; + const member = await this.prisma.$transaction(async (tx) => { + // Conditional on `acceptedAt: null` so two concurrent accepts can't both win. + const claimed = await tx.organizationInvitation.updateMany({ + where: { id: invitation.id, acceptedAt: null }, + data: { acceptedAt: new Date(), acceptedById: userId }, + }); + if (claimed.count === 0) { + throw new ConflictException('Invitation has already been accepted'); + } + const existing = await tx.organizationMember.findUnique({ + where: { organizationId_userId: { organizationId, userId } }, + }); + if (existing) { + throw new ConflictException( + 'You are already a member of this organization', + ); + } + const created = await tx.organizationMember.create({ + data: { organizationId, userId, role: invitation.role }, + }); + // #390 — members of a live organization are ORGANIZERs. + await tx.user.updateMany({ + where: { id: userId, role: UserRole.ATTENDEE }, + data: { role: UserRole.ORGANIZER }, + }); + return created; + }); + + await this.audit?.record( + userId, + 'organization.invite.accept', + 'Organization', + organizationId, + { invitationId: invitation.id, role: member.role }, + ); + return member; + } + + private hash(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } +} diff --git a/src/organizations/organizations.module.ts b/src/organizations/organizations.module.ts index 8f0849d..84dc5d1 100644 --- a/src/organizations/organizations.module.ts +++ b/src/organizations/organizations.module.ts @@ -2,11 +2,13 @@ import { Module } from '@nestjs/common'; import { AuditModule } from '../audit/audit.module'; import { OrganizationsController } from './organizations.controller'; import { OrganizationsService } from './organizations.service'; +import { InvitationsController } from './invitations.controller'; +import { InvitationsService } from './invitations.service'; @Module({ imports: [AuditModule], - controllers: [OrganizationsController], - providers: [OrganizationsService], + controllers: [OrganizationsController, InvitationsController], + providers: [OrganizationsService, InvitationsService], exports: [OrganizationsService], }) export class OrganizationsModule {}