Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/bruno/organizations-invitations/Accept.bru
Original file line number Diff line number Diff line change
@@ -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"
}
}
21 changes: 21 additions & 0 deletions docs/bruno/organizations-invitations/Create.bru
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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;
26 changes: 26 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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.
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/organizations/dto/accept-invitation.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { IsString, MaxLength, MinLength } from 'class-validator';

export class AcceptInvitationDto {
@IsString()
@MinLength(1)
@MaxLength(256)
token!: string;
}
18 changes: 18 additions & 0 deletions src/organizations/dto/create-invitation.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
90 changes: 90 additions & 0 deletions src/organizations/invitations.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions src/organizations/invitations.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading