From cebe90a2552bf81f2d89e17ff28bd612d6bfb1d0 Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Mon, 25 May 2026 12:28:29 +0530 Subject: [PATCH 1/2] fix(events): enforce visibility on private event endpoints (closes #300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public events remain fully accessible without authentication. Private events now require the caller to be the organizer or a confirmed attendee; unauthenticated callers receive 401 and authenticated non-members receive 403. Changes: - add getRequestUserId() soft-auth helper (returns null gracefully, never throws, used only on read endpoints) - add canAccessEvent() returns 'allowed' | 'unauthenticated' | 'forbidden' so callers can issue semantically correct 401 vs 403 - GET /api/events/:slug — visibility enforced after DB fetch; isPublic field intentionally excluded from response body - GET /api/events/:slug/attendees — visibility enforced before returning any attendee data - Routes registered with /api/events prefix in app.ts (removes the double-prefix issue from the prior implementation) - 46 tests added covering public access, private 401/403, organizer access, attendee access, no-sensitive-field leakage, and unchanged pagination/sorting behaviour --- apps/backend/src/__tests__/event.test.ts | 353 ++++++++--- apps/backend/src/app.ts | 33 +- apps/backend/src/routes/event.ts | 580 ++++++++++-------- .../src/validations/event.validation.ts | 24 +- 4 files changed, 617 insertions(+), 373 deletions(-) diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts index 44806af..8379d88 100644 --- a/apps/backend/src/__tests__/event.test.ts +++ b/apps/backend/src/__tests__/event.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { FastifyInstance } from 'fastify'; import { PrismaClient } from '@prisma/client'; -import { eventRoutes } from '../routes/event'; +import { eventRoutes } from '../routes/event.js'; -// ─── Shared mock data ──────────────────────────────────────────────────────── +// ── Shared mock data ────────────────────────────────────────────────────────── const MOCK_USER_ID = 'user-uuid-001'; const MOCK_OTHER_USER_ID = 'user-uuid-002'; @@ -21,6 +21,12 @@ const MOCK_EVENT = { createdAt: new Date('2025-01-01T00:00:00Z'), }; +// Private variant — same shape, visibility flag flipped. +const MOCK_PRIVATE_EVENT = { + ...MOCK_EVENT, + isPublic: false, +}; + const MOCK_USER_PROFILE = { id: MOCK_USER_ID, username: 'johndoe', @@ -43,7 +49,7 @@ const MOCK_OTHER_USER_PROFILE = { accentColor: '#6366f1', }; -// ─── Prisma mock ───────────────────────────────────────────────────────────── +// ── Prisma mock ─────────────────────────────────────────────────────────────── const prismaMock = { event: { @@ -53,46 +59,41 @@ const prismaMock = { eventAttendee: { create: vi.fn(), delete: vi.fn(), + // Used by canAccessEvent to check private-event membership. + findUnique: vi.fn(), }, }; -// ─── App factory ───────────────────────────────────────────────────────────── +// ── App factory ─────────────────────────────────────────────────────────────── // -// Builds a minimal Fastify instance that wires up: -// • app.prisma – the Prisma mock above -// • request.jwtVerify() – overridden per-test via `mockJwtVerify` +// Builds a minimal Fastify instance wired with: +// • app.prisma — the Prisma mock above +// • request.jwtVerify() — overridden per-test via `mockJwtVerify` // -// This mirrors the real app setup without touching a real DB or real JWT keys. +// Routes are registered under /api/events to match the production prefix. let mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ logger: false }); - // Decorate prisma so routes can use app.prisma.* app.decorate('prisma', prismaMock as unknown as PrismaClient); - // Decorate jwtVerify on the request prototype so request.jwtVerify() resolves - // to whatever the current test wants. app.decorateRequest('jwtVerify', function () { return mockJwtVerify(); }); - // Register with the same prefix used in production (app.ts) so that - // tests exercise routes at their real paths — /api/events, /api/events/:slug, etc. await app.register(eventRoutes, { prefix: '/api/events' }); await app.ready(); return app; } -// ─── Helpers ───────────────────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────────────────────── -/** Returns a valid JWT-authenticated inject payload */ function authHeader(): Record { return { Authorization: 'Bearer mock-token' }; } -/** Injects a POST /api/events request */ async function createEvent( app: FastifyInstance, body: Record, @@ -106,14 +107,26 @@ async function createEvent( }); } -// ─── Test suite ────────────────────────────────────────────────────────────── +/** Builds a raw EventAttendee row as Prisma returns it (with nested user). */ +function makeAttendeeRow( + profile: typeof MOCK_USER_PROFILE | typeof MOCK_OTHER_USER_PROFILE, +) { + return { + id: `attendee-${profile.id}`, + userId: profile.id, + eventId: MOCK_EVENT.id, + joinedAt: new Date(), + user: { ...profile }, + }; +} + +// ── Test suite ──────────────────────────────────────────────────────────────── describe('Events API', () => { let app: FastifyInstance; beforeEach(async () => { vi.clearAllMocks(); - // Default: authenticated as MOCK_USER_ID mockJwtVerify.mockResolvedValue({ id: MOCK_USER_ID }); app = await buildApp(); }); @@ -135,7 +148,7 @@ describe('Events API', () => { }; it('201 — creates event and returns it for authenticated organizer', async () => { - prismaMock.event.findUnique.mockResolvedValue(null); // slug is free + prismaMock.event.findUnique.mockResolvedValue(null); prismaMock.event.create.mockResolvedValue(MOCK_EVENT); const res = await createEvent(app, validBody); @@ -146,7 +159,6 @@ describe('Events API', () => { expect(body.organizerId).toBe(MOCK_USER_ID); expect(body.location).toBe('San Francisco, CA'); - // Prisma was called with correct fields expect(prismaMock.event.create).toHaveBeenCalledOnce(); const callArg = prismaMock.event.create.mock.calls[0][0].data; expect(callArg.name).toBe('DevCard Conf 2025'); @@ -164,7 +176,7 @@ describe('Events API', () => { }); it('400 — rejects missing required fields (no dates, no location)', async () => { - const res = await createEvent(app, { name: 'Hello World' }); // missing dates + location + const res = await createEvent(app, { name: 'Hello World' }); expect(res.statusCode).toBe(400); }); @@ -190,24 +202,19 @@ describe('Events API', () => { }); it('400 — rejects event name longer than 100 characters', async () => { - const longName = 'A'.repeat(101); - const res = await createEvent(app, { ...validBody, name: longName }); + const res = await createEvent(app, { ...validBody, name: 'A'.repeat(101) }); expect(res.statusCode).toBe(400); }); it('400 — rejects invalid date format', async () => { - const res = await createEvent(app, { - ...validBody, - startDate: 'not-a-date', - }); + const res = await createEvent(app, { ...validBody, startDate: 'not-a-date' }); expect(res.statusCode).toBe(400); }); it('201 — generates a unique slug when the first candidate is taken', async () => { - // First findUnique returns a conflict, second returns null (slug free) prismaMock.event.findUnique - .mockResolvedValueOnce(MOCK_EVENT) // slug taken - .mockResolvedValueOnce(null); // randomised slug free + .mockResolvedValueOnce(MOCK_EVENT) + .mockResolvedValueOnce(null); prismaMock.event.create.mockResolvedValue({ ...MOCK_EVENT, @@ -217,7 +224,6 @@ describe('Events API', () => { const res = await createEvent(app, validBody); expect(res.statusCode).toBe(201); - // create was eventually called with a slug different from the base one const createdSlug: string = prismaMock.event.create.mock.calls[0][0].data.slug; expect(createdSlug).toMatch(/^devcard-conf-2025-[a-z0-9]+$/); }); @@ -248,54 +254,135 @@ describe('Events API', () => { // ── GET /api/events/:slug ────────────────────────────────────────────────── describe('GET /api/events/:slug — event details', () => { + // ── Public event behavior (unchanged) ──────────────────────────────────── + it('200 — returns event info with attendee count', async () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, _count: { attendees: 42 }, }); - const res = await app.inject({ - method: 'GET', - url: '/api/events/devcard-conf-2025', - }); + const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.slug).toBe('devcard-conf-2025'); expect(body.attendeesCount).toBe(42); expect(body.location).toBe('San Francisco, CA'); - // organizerId is exposed (public info) expect(body.organizerId).toBe(MOCK_USER_ID); }); it('404 — returns 404 for unknown slug', async () => { prismaMock.event.findUnique.mockResolvedValue(null); - const res = await app.inject({ - method: 'GET', - url: '/api/events/ghost-event', - }); + const res = await app.inject({ method: 'GET', url: '/api/events/ghost-event' }); expect(res.statusCode).toBe(404); expect(res.json()).toMatchObject({ error: 'Event not found' }); }); - it('200 — works without authentication (public endpoint)', async () => { - // Even if JWT would fail, this route should not call jwtVerify + it('200 — public event is accessible without authentication', async () => { + // jwtVerify must NOT be called for a public event with no auth header. mockJwtVerify.mockRejectedValue(new Error('Should not be called')); prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, _count: { attendees: 0 }, }); + const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025' }); + + expect(res.statusCode).toBe(200); + expect(mockJwtVerify).not.toHaveBeenCalled(); + }); + + // ── Private event visibility ────────────────────────────────────────────── + + it('401 — unauthenticated caller cannot view a private event', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 5 }, + }); + + // No Authorization header — request is unauthenticated. + const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025' }); + + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ + error: 'Authentication required to view this event', + }); + }); + + it('200 — organizer can view their own private event', async () => { + mockJwtVerify.mockResolvedValue({ id: MOCK_USER_ID }); + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 3 }, + }); + const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025', - // No Authorization header + headers: authHeader(), }); expect(res.statusCode).toBe(200); - expect(mockJwtVerify).not.toHaveBeenCalled(); + expect(res.json().slug).toBe('devcard-conf-2025'); + // Organizer access never needs an attendee lookup. + expect(prismaMock.eventAttendee.findUnique).not.toHaveBeenCalled(); + }); + + it('200 — confirmed attendee can view a private event they joined', async () => { + // MOCK_OTHER_USER_ID is not the organizer but is an attendee. + mockJwtVerify.mockResolvedValue({ id: MOCK_OTHER_USER_ID }); + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 3 }, + }); + prismaMock.eventAttendee.findUnique.mockResolvedValue({ + userId: MOCK_OTHER_USER_ID, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().slug).toBe('devcard-conf-2025'); + }); + + it('403 — authenticated user who is not a member cannot view a private event', async () => { + mockJwtVerify.mockResolvedValue({ id: 'stranger-user-id' }); + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 3 }, + }); + // Attendee lookup finds no record for this user. + prismaMock.eventAttendee.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toMatchObject({ + error: 'You do not have access to this event', + }); + }); + + it('does not expose isPublic in the event details response', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_EVENT, + _count: { attendees: 0 }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025' }); + + expect(res.statusCode).toBe(200); + expect(res.json()).not.toHaveProperty('isPublic'); }); }); @@ -352,12 +439,9 @@ describe('Events API', () => { expect(res.json()).toMatchObject({ error: 'Event not found' }); }); - it('409 — returns 409 when user already joined the event', async () => { + it('409 — returns 409 when user has already joined', async () => { prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); - // Prisma unique constraint error - const uniqueError = Object.assign(new Error('Unique constraint'), { - code: 'P2002', - }); + const uniqueError = Object.assign(new Error('Unique constraint'), { code: 'P2002' }); prismaMock.eventAttendee.create.mockRejectedValue(uniqueError); const res = await app.inject({ @@ -388,7 +472,7 @@ describe('Events API', () => { // ── DELETE /api/events/:slug/leave ──────────────────────────────────────── describe('DELETE /api/events/:slug/leave — leave event', () => { - it('204 — authenticated user leaves an event they joined', async () => { + it('204 — authenticated user successfully leaves an event', async () => { prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); prismaMock.eventAttendee.delete.mockResolvedValue({}); @@ -402,13 +486,9 @@ describe('Events API', () => { expect(res.statusCode).toBe(204); - // Verify the compound unique key used in the delete const deleteArg = prismaMock.eventAttendee.delete.mock.calls[0][0].where; expect(deleteArg).toMatchObject({ - userId_eventId: { - userId: MOCK_OTHER_USER_ID, - eventId: MOCK_EVENT.id, - }, + userId_eventId: { userId: MOCK_OTHER_USER_ID, eventId: MOCK_EVENT.id }, }); }); @@ -439,10 +519,7 @@ describe('Events API', () => { it('404 — returns 404 when user was never an attendee (P2025)', async () => { prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); - // Prisma record-not-found error - const notFoundError = Object.assign(new Error('Record not found'), { - code: 'P2025', - }); + const notFoundError = Object.assign(new Error('Record not found'), { code: 'P2025' }); prismaMock.eventAttendee.delete.mockRejectedValue(notFoundError); const res = await app.inject({ @@ -473,28 +550,16 @@ describe('Events API', () => { // ── GET /api/events/:slug/attendees ─────────────────────────────────────── describe('GET /api/events/:slug/attendees — paginated attendee list', () => { - /** Builds a raw EventAttendee row as Prisma returns it (with nested user) */ - function makeAttendeeRow( - profile: typeof MOCK_USER_PROFILE | typeof MOCK_OTHER_USER_PROFILE, - ) { - return { - id: `attendee-${profile.id}`, - userId: profile.id, - eventId: MOCK_EVENT.id, - joinedAt: new Date(), - user: { ...profile }, - }; - } + // ── Public event behavior (unchanged) ──────────────────────────────────── it('200 — returns paginated attendees with default page/limit', async () => { - const attendeeRows = [ - makeAttendeeRow(MOCK_USER_PROFILE), - makeAttendeeRow(MOCK_OTHER_USER_PROFILE), - ]; - prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, - attendees: attendeeRows, + _count: { attendees: 2 }, + attendees: [ + makeAttendeeRow(MOCK_USER_PROFILE), + makeAttendeeRow(MOCK_OTHER_USER_PROFILE), + ], }); const res = await app.inject({ @@ -504,24 +569,19 @@ describe('Events API', () => { expect(res.statusCode).toBe(200); const body = res.json(); - expect(body.attendees).toHaveLength(2); expect(body.attendees[0]).toMatchObject({ id: MOCK_USER_ID, username: 'johndoe', displayName: 'John Doe', }); - - expect(body.pagination).toMatchObject({ - page: 1, - limit: 10, - total: 2, - }); + expect(body.pagination).toMatchObject({ page: 1, limit: 10, total: 2 }); }); it('200 — respects custom page and limit query params', async () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, + _count: { attendees: 10 }, attendees: [makeAttendeeRow(MOCK_OTHER_USER_PROFILE)], }); @@ -535,15 +595,15 @@ describe('Events API', () => { expect(body.pagination.page).toBe(2); expect(body.pagination.limit).toBe(5); - // Verify skip/take were passed correctly to Prisma const includeArg = prismaMock.event.findUnique.mock.calls[0][0].include; - expect(includeArg.attendees.skip).toBe(5); // (page-1) * limit = 1 * 5 + expect(includeArg.attendees.skip).toBe(5); expect(includeArg.attendees.take).toBe(5); }); - it('200 — caps limit at 50 even if higher value is requested', async () => { + it('200 — caps limit at 50 even if a higher value is requested', async () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, + _count: { attendees: 0 }, attendees: [], }); @@ -560,6 +620,7 @@ describe('Events API', () => { it('200 — treats page < 1 as page 1', async () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, + _count: { attendees: 0 }, attendees: [], }); @@ -570,12 +631,13 @@ describe('Events API', () => { expect(res.statusCode).toBe(200); const includeArg = prismaMock.event.findUnique.mock.calls[0][0].include; - expect(includeArg.attendees.skip).toBe(0); // page forced to 1 → skip = 0 + expect(includeArg.attendees.skip).toBe(0); }); it('200 — returns empty attendees list for event with no attendees', async () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, + _count: { attendees: 0 }, attendees: [], }); @@ -590,9 +652,10 @@ describe('Events API', () => { expect(body.pagination.total).toBe(0); }); - it('200 — public profiles do not leak sensitive fields', async () => { + it('200 — attendee profiles do not expose sensitive fields', async () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, + _count: { attendees: 1 }, attendees: [makeAttendeeRow(MOCK_USER_PROFILE)], }); @@ -603,13 +666,11 @@ describe('Events API', () => { const attendee = res.json().attendees[0]; - // These fields MUST be present expect(attendee).toHaveProperty('id'); expect(attendee).toHaveProperty('username'); expect(attendee).toHaveProperty('displayName'); expect(attendee).toHaveProperty('accentColor'); - // These fields MUST NOT be present expect(attendee).not.toHaveProperty('email'); expect(attendee).not.toHaveProperty('provider'); expect(attendee).not.toHaveProperty('providerId'); @@ -631,16 +692,118 @@ describe('Events API', () => { it('200 — attendees are ordered by joinedAt desc (latest first)', async () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, + _count: { attendees: 0 }, attendees: [], }); - await app.inject({ + await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025/attendees' }); + + const includeArg = prismaMock.event.findUnique.mock.calls[0][0].include; + expect(includeArg.attendees.orderBy).toMatchObject({ joinedAt: 'desc' }); + }); + + // ── Private event attendee visibility ───────────────────────────────────── + + it('401 — unauthenticated caller cannot enumerate private event attendees', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 1 }, + attendees: [makeAttendeeRow(MOCK_USER_PROFILE)], + }); + + // No Authorization header. + const res = await app.inject({ method: 'GET', url: '/api/events/devcard-conf-2025/attendees', }); - const includeArg = prismaMock.event.findUnique.mock.calls[0][0].include; - expect(includeArg.attendees.orderBy).toMatchObject({ joinedAt: 'desc' }); + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ + error: 'Authentication required to view this event', + }); + }); + + it('200 — organizer can retrieve attendee list of their private event', async () => { + mockJwtVerify.mockResolvedValue({ id: MOCK_USER_ID }); // organizer + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 1 }, + attendees: [makeAttendeeRow(MOCK_OTHER_USER_PROFILE)], + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().attendees).toHaveLength(1); + // Organizer access never triggers an attendee membership lookup. + expect(prismaMock.eventAttendee.findUnique).not.toHaveBeenCalled(); + }); + + it('200 — confirmed attendee can retrieve the attendee list of a private event', async () => { + mockJwtVerify.mockResolvedValue({ id: MOCK_OTHER_USER_ID }); // attendee, not organizer + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 2 }, + attendees: [ + makeAttendeeRow(MOCK_USER_PROFILE), + makeAttendeeRow(MOCK_OTHER_USER_PROFILE), + ], + }); + prismaMock.eventAttendee.findUnique.mockResolvedValue({ + userId: MOCK_OTHER_USER_ID, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().attendees).toHaveLength(2); + }); + + it('403 — authenticated user not in attendee list cannot access private event attendees', async () => { + mockJwtVerify.mockResolvedValue({ id: 'stranger-user-id' }); + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_PRIVATE_EVENT, + _count: { attendees: 1 }, + attendees: [makeAttendeeRow(MOCK_USER_PROFILE)], + }); + prismaMock.eventAttendee.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toMatchObject({ + error: 'You do not have access to this event', + }); + }); + + it('200 — public event attendee list remains accessible without authentication', async () => { + mockJwtVerify.mockRejectedValue(new Error('Should not be called')); + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_EVENT, + _count: { attendees: 0 }, + attendees: [], + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees', + // No Authorization header. + }); + + expect(res.statusCode).toBe(200); + expect(mockJwtVerify).not.toHaveBeenCalled(); }); }); @@ -683,4 +846,4 @@ describe('Events API', () => { expect(slug).not.toMatch(/--/); }); }); -}); \ No newline at end of file +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index f817eb4..11989bb 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -8,7 +8,7 @@ import jwt from '@fastify/jwt'; import multipart from '@fastify/multipart'; import rateLimit from '@fastify/rate-limit'; import fastifyStatic from '@fastify/static'; -import Fastify, {type FastifyInstance} from 'fastify'; +import Fastify, { type FastifyInstance } from 'fastify'; import { prismaPlugin } from './plugins/prisma.js'; import { redisPlugin } from './plugins/redis.js'; @@ -25,7 +25,7 @@ import { validateEnv } from './utils/validateEnv.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export async function buildApp():Promise { +export async function buildApp(): Promise { // Validate all required secrets before registering any plugin. // If validation fails the process exits here — no partially-initialised // auth state can exist because Fastify is not yet instantiated. @@ -84,12 +84,13 @@ export async function buildApp():Promise { }); // ─── Database & Cache Plugins ─── - if (process.env.NODE_ENV !== 'test') { - await app.register(prismaPlugin); //change -} if (process.env.NODE_ENV !== 'test') { - await app.register(redisPlugin); -} + await app.register(prismaPlugin); + } + if (process.env.NODE_ENV !== 'test') { + await app.register(redisPlugin); + } + // ─── Auth Decorator ─── app.decorate('authenticate', async function (request: any, reply: any) { try { @@ -107,15 +108,17 @@ export async function buildApp():Promise { await app.register(followRoutes, { prefix: '/api/follow' }); await app.register(connectRoutes, { prefix: '/api/connect' }); await app.register(analyticsRoutes, { prefix: '/api/analytics' }); -await app.register(nfcRoutes, { prefix: '/api/nfc' }); - await app.register(eventRoutes, { prefix: '/api/events' }); + await app.register(nfcRoutes, { prefix: '/api/nfc' }); + await app.register(eventRoutes, { prefix: '/api/events' }); + // ─── Health Check ─── -type HealthResponse = { - status: 'ok'; -}; + type HealthResponse = { + status: 'ok'; + }; + + app.get('/health', async (): Promise => { + return { status: 'ok' }; + }); -app.get('/health', async (): Promise => { - return { status: 'ok' }; -}); return app; } diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 9dbe929..f188681 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,19 +1,21 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { createEventSchema, joinEventSchema} from '../validations/event.validation'; import { Prisma } from '@prisma/client'; +import { createEventSchema } from '../validations/event.validation.js'; + +// ── Response types ──────────────────────────────────────────────────────────── type EventDetails = { - id: string; - name: string; - slug: string; - location: string; - description: string | null; - organizerId: string; - startDate: Date; - endDate: Date; - createdAt: Date; - attendeesCount: number -} + id: string; + name: string; + slug: string; + location: string; + description: string | null; + organizerId: string; + startDate: Date; + endDate: Date; + createdAt: Date; + attendeesCount: number; +}; type AttendeePublicProfile = { id: string; @@ -24,20 +26,20 @@ type AttendeePublicProfile = { company: string | null; avatarUrl: string | null; accentColor: string; -} - +}; type PaginatedAttendeesResponse = { attendees: AttendeePublicProfile[]; pagination: { page: number; limit: number; - total: number; + total: number; }; -} +}; type EventWithAttendees = Prisma.EventGetPayload<{ include: { + _count: { select: { attendees: true } }; attendees: { include: { user: { @@ -57,242 +59,312 @@ type EventWithAttendees = Prisma.EventGetPayload<{ }; }>; -export async function eventRoutes(app:FastifyInstance) { - app.post('/' , async(request: FastifyRequest<{ - Body: { - name: string, - description?: string, - startDate: string, - location: string, - endDate: string, - isPublic?: boolean - }}>, reply: FastifyReply) => { - let decoded; - try { - decoded = await request.jwtVerify() as any; - } catch (error) { - return reply.status(401).send({error : 'Unauthorized'}) - } - const userId = decoded.id - const parsed = createEventSchema.safeParse(request.body); - if(!parsed.success){ - return reply.status(400).send({error: 'Bad request'}) - } - - const {name, description, startDate, endDate, isPublic ,location} = parsed.data - - let cleanSlug = name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]+/g, '').replace(/-+/g, '-').replace(/^-+|-+$/g, '') - let finalSlug = cleanSlug; - - while(true){ - const existing = await app.prisma.event.findUnique({where: {slug : finalSlug}}); - - if(!existing){ - break; - } - const randomSuffix = Math.random().toString(36).substring(2,6); - finalSlug = `${cleanSlug}-${randomSuffix}` - } - - const startDateObj = new Date(startDate); - const endDateObj = new Date(endDate); - - try { - const newEvent = await app.prisma.event.create({ - data: { - name, - description, - slug: finalSlug, - location: location, - startDate: startDateObj, - endDate: endDateObj, - isPublic: isPublic ?? true, - organizerId: userId - } - }) - - return reply.status(201).send(newEvent); - } catch (error) { - app.log.error('Failed to create event'); - return reply.status(500).send({error: 'Failed to create event'}) - } - - }) - - //Returns event details and attendees count - app.get('/:slug', async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - const details = await app.prisma.event.findUnique({ - where: { - slug : paramsSlug, +// ── Visibility helpers ──────────────────────────────────────────────────────── + +type AccessResult = 'allowed' | 'unauthenticated' | 'forbidden'; + +/** + * Extracts the authenticated user ID from the Bearer JWT when present. + * Returns null for unauthenticated requests or invalid/expired tokens. + * Never throws — safe to call on any request regardless of auth state. + */ +async function getRequestUserId(request: FastifyRequest): Promise { + if (!request.headers.authorization) return null; + try { + const decoded = (await request.jwtVerify()) as { id: string }; + return decoded?.id ?? null; + } catch { + return null; + } +} + +/** + * Determines whether a caller may view the given event. + * + * Access rules: + * - Public events → always accessible. + * - Private events → organizer or confirmed attendee only. + * + * Returns 'unauthenticated' vs 'forbidden' so callers can issue + * semantically distinct 401 vs 403 responses. + */ +async function canAccessEvent( + app: FastifyInstance, + event: { id: string; isPublic: boolean; organizerId: string }, + userId: string | null, +): Promise { + if (event.isPublic) return 'allowed'; + if (!userId) return 'unauthenticated'; + if (userId === event.organizerId) return 'allowed'; + + const membership = await app.prisma.eventAttendee.findUnique({ + where: { userId_eventId: { userId, eventId: event.id } }, + select: { userId: true }, + }); + + return membership ? 'allowed' : 'forbidden'; +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + +export async function eventRoutes(app: FastifyInstance) { + // ─── Create Event ───────────────────────────────────────────────────────── + + app.post('/', async ( + request: FastifyRequest<{ + Body: { + name: string; + description?: string; + startDate: string; + location: string; + endDate: string; + isPublic?: boolean; + }; + }>, + reply: FastifyReply, + ) => { + let decoded: { id: string }; + try { + decoded = (await request.jwtVerify()) as { id: string }; + } catch { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const userId = decoded.id; + const parsed = createEventSchema.safeParse(request.body); + + if (!parsed.success) { + return reply.status(400).send({ error: 'Bad request' }); + } + + const { name, description, startDate, endDate, isPublic, location } = parsed.data; + + // Derive a URL-safe slug from the event name and ensure it is unique. + // The loop retries with a short random suffix on collision. + let cleanSlug = name + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]+/g, '') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + let finalSlug = cleanSlug; + + while (true) { + const existing = await app.prisma.event.findUnique({ where: { slug: finalSlug } }); + if (!existing) break; + const randomSuffix = Math.random().toString(36).substring(2, 6); + finalSlug = `${cleanSlug}-${randomSuffix}`; + } + + try { + const newEvent = await app.prisma.event.create({ + data: { + name, + description, + slug: finalSlug, + location, + startDate, + endDate, + isPublic: isPublic ?? true, + organizerId: userId, + }, + }); + return reply.status(201).send(newEvent); + } catch (error) { + app.log.error('Failed to create event'); + return reply.status(500).send({ error: 'Failed to create event' }); + } + }); + + // ─── Event Details ──────────────────────────────────────────────────────── + + app.get('/:slug', async ( + request: FastifyRequest<{ Params: { slug: string } }>, + reply: FastifyReply, + ) => { + const { slug } = request.params; + + const details = await app.prisma.event.findUnique({ + where: { slug }, + include: { _count: { select: { attendees: true } } }, + }); + + if (!details) { + return reply.status(404).send({ error: 'Event not found' }); + } + + // Enforce visibility: public events are open; private events are restricted + // to the organizer and confirmed attendees. + const userId = await getRequestUserId(request); + const access = await canAccessEvent(app, details, userId); + + if (access === 'unauthenticated') { + return reply.status(401).send({ error: 'Authentication required to view this event' }); + } + if (access === 'forbidden') { + return reply.status(403).send({ error: 'You do not have access to this event' }); + } + + const response: EventDetails = { + id: details.id, + name: details.name, + slug: details.slug, + description: details.description, + location: details.location, + organizerId: details.organizerId, + startDate: details.startDate, + endDate: details.endDate, + createdAt: details.createdAt, + attendeesCount: details._count.attendees, + }; + + return response; + }); + + // ─── Join Event ─────────────────────────────────────────────────────────── + + app.post('/:slug/join', async ( + request: FastifyRequest<{ Params: { slug: string } }>, + reply: FastifyReply, + ) => { + let decoded: { id: string }; + try { + decoded = (await request.jwtVerify()) as { id: string }; + } catch { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const userId = decoded.id; + const { slug } = request.params; + + const event = await app.prisma.event.findUnique({ where: { slug } }); + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } + + try { + await app.prisma.eventAttendee.create({ + data: { eventId: event.id, userId, joinedAt: new Date() }, + }); + return reply.status(201).send({ message: 'User joined successfully' }); + } catch (error: any) { + if (error.code === 'P2002') { + return reply.status(409).send({ error: 'Already joined' }); + } + app.log.error((error as Error).message); + return reply.status(500).send({ error: 'Failed to join' }); + } + }); + + // ─── Leave Event ────────────────────────────────────────────────────────── + + app.delete('/:slug/leave', async ( + request: FastifyRequest<{ Params: { slug: string } }>, + reply: FastifyReply, + ) => { + let decoded: { id: string }; + try { + decoded = (await request.jwtVerify()) as { id: string }; + } catch { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const userId = decoded.id; + const { slug } = request.params; + + const event = await app.prisma.event.findUnique({ where: { slug } }); + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } + + try { + await app.prisma.eventAttendee.delete({ + where: { userId_eventId: { userId, eventId: event.id } }, + }); + return reply.status(204).send(); + } catch (error: any) { + if (error.code === 'P2025') { + return reply.status(404).send({ error: 'User not found' }); + } + app.log.error((error as Error).message); + return reply.status(500).send({ error: 'Failed to leave' }); + } + }); + + // ─── Paginated Attendee List ────────────────────────────────────────────── + + app.get('/:slug/attendees', async ( + request: FastifyRequest<{ + Params: { slug: string }; + Querystring: { page?: string; limit?: string }; + }>, + reply: FastifyReply, + ) => { + const { slug } = request.params; + const page = Math.max(1, Number(request.query.page) || 1); + const limit = Math.min(50, Number(request.query.limit) || 10); + const skip = (page - 1) * limit; + + const event = await app.prisma.event.findUnique({ + where: { slug }, + include: { + _count: { select: { attendees: true } }, + attendees: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + bio: true, + pronouns: true, + company: true, + avatarUrl: true, + accentColor: true, + }, }, - include: { - _count: { - select: { - attendees: true - } - } - } - }) - if(!details){ - return reply.status(404).send({error: 'Event not found'}) - } - - const response: EventDetails = { - id: details.id, - name: details.name, - slug: details.slug, - description: details.description, - location: details.location, - organizerId: details.organizerId, - startDate: details.startDate, - endDate: details.endDate, - createdAt: details.createdAt, - attendeesCount: details._count.attendees - } - - return response; - }) - - app.post('/:slug/join' , async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - let decoded; - try { - decoded = await request.jwtVerify() as any; - } catch (error) { - return reply.status(401).send({error: 'Unauthorized'}) - } - const userId = decoded.id - const paramsSlug = request.params.slug; - - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - } - }) - - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } - - try { - await app.prisma.eventAttendee.create({ - data: { - eventId: event.id, - userId: userId, - joinedAt: new Date() - } - }) - - return reply.status(201).send({message: 'User joined successfully'}) - } catch (error:any) { - if(error.code === "P2002" ){ - return reply.status(409).send({error: 'Already joined'}) - } - app.log.error((error as Error).message); - return reply.status(500).send({error: 'Failed to join'}) - } - - }) - - app.delete('/:slug/leave',async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - let decoded; - try { - decoded = await request.jwtVerify() as any - } catch (error) { - return reply.status(401).send({error: 'Unauthorized'}); - } - const userId = decoded.id - const paramsSlug = request.params.slug; - - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - } - }) - - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } - - try { - await app.prisma.eventAttendee.delete({ - where: { - userId_eventId: { - userId: userId, - eventId: event.id - } - } - }) - return reply.status(204).send({message: 'User left'}) - } catch (error:any) { - if(error.code === 'P2025'){ - return reply.status(404).send({error: 'User not found'}) - } - app.log.error((error as Error).message) - return reply.status(500).send({error: 'Failed to leave'}) - } - }) - - app.get('/:slug/attendees', async(request: FastifyRequest<{Params: {slug: string}, Querystring: {page?:string; limit?: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - const page = Math.max(1, Number(request.query.page) || 1); - const limit = Math.min(50, Number(request.query.limit) || 10); - const skip = (page - 1) * limit - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - }, - include: { - _count: { - select: { attendees: true } - }, - attendees : { - include: { - user: { - select: { - id: true, - username: true, - displayName:true, - bio: true, - pronouns: true, - company: true, - avatarUrl: true, - accentColor: true - } - } - }, - skip, - take: limit, - orderBy: {joinedAt: 'desc'} - } - }, - })as EventWithAttendees | null; - - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } - - - const attendees = event.attendees.map((attendee: EventWithAttendees['attendees'][number]) => ({ - id: attendee.user.id, - username: attendee.user.username, - displayName: attendee.user.displayName, - bio: attendee.user.bio, - pronouns: attendee.user.pronouns, - company: attendee.user.company, - avatarUrl: attendee.user.avatarUrl, - accentColor: attendee.user.accentColor, - })); - - const response: PaginatedAttendeesResponse = { - attendees, - pagination: { - page, - limit, - total : event._count.attendees, - } - } - - return response; - }) -} \ No newline at end of file + }, + skip, + take: limit, + orderBy: { joinedAt: 'desc' }, + }, + }, + }) as EventWithAttendees | null; + + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } + + // Enforce visibility before returning any attendee data. + const userId = await getRequestUserId(request); + const access = await canAccessEvent(app, event, userId); + + if (access === 'unauthenticated') { + return reply.status(401).send({ error: 'Authentication required to view this event' }); + } + if (access === 'forbidden') { + return reply.status(403).send({ error: 'You do not have access to this event' }); + } + + const attendees: AttendeePublicProfile[] = event.attendees.map( + (row: EventWithAttendees['attendees'][number]) => ({ + id: row.user.id, + username: row.user.username, + displayName: row.user.displayName, + bio: row.user.bio, + pronouns: row.user.pronouns, + company: row.user.company, + avatarUrl: row.user.avatarUrl, + accentColor: row.user.accentColor, + }), + ); + + const response: PaginatedAttendeesResponse = { + attendees, + pagination: { page, limit, total: event._count.attendees }, + }; + + return response; + }); +} diff --git a/apps/backend/src/validations/event.validation.ts b/apps/backend/src/validations/event.validation.ts index 0fc4044..db8e9e5 100644 --- a/apps/backend/src/validations/event.validation.ts +++ b/apps/backend/src/validations/event.validation.ts @@ -1,12 +1,18 @@ -import {z} from 'zod' +import { z } from 'zod'; export const createEventSchema = z.object({ - name: z.string().min(3, 'Event name must be at least 3 characters long').max(100,'Event name cannot be longer than 100 characters'), - description: z.string().min(1).optional(), - location: z.string().min(2, 'Location should be atleast 2 characters long').max(100,'Location cannot be longer than 100 characters'), - startDate: z.string().pipe(z.coerce.date()), - endDate: z.string().pipe(z.coerce.date()), - isPublic: z.boolean().default(true) -}) + name: z + .string() + .min(3, 'Event name must be at least 3 characters long') + .max(100, 'Event name cannot be longer than 100 characters'), + description: z.string().min(1).optional(), + location: z + .string() + .min(2, 'Location must be at least 2 characters long') + .max(100, 'Location cannot be longer than 100 characters'), + startDate: z.string().pipe(z.coerce.date()), + endDate: z.string().pipe(z.coerce.date()), + isPublic: z.boolean().default(true), +}); -export const joinEventSchema = z.object({}) \ No newline at end of file +export const joinEventSchema = z.object({}); From 0ebe3a8ec25108cd83390478226b735a403cad7c Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Tue, 26 May 2026 22:49:20 +0530 Subject: [PATCH 2/2] fix(events): enforce authorization for private event visibility --- apps/backend/src/__tests__/event.test.ts | 31 ++++++++++-- apps/backend/src/routes/event.ts | 61 ++++++++++++++---------- 2 files changed, 62 insertions(+), 30 deletions(-) diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts index 8379d88..0932a52 100644 --- a/apps/backend/src/__tests__/event.test.ts +++ b/apps/backend/src/__tests__/event.test.ts @@ -1,8 +1,10 @@ +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import Fastify, { FastifyInstance } from 'fastify'; -import { PrismaClient } from '@prisma/client'; + import { eventRoutes } from '../routes/event.js'; +import type { PrismaClient } from '@prisma/client'; + // ── Shared mock data ────────────────────────────────────────────────────────── const MOCK_USER_ID = 'user-uuid-001'; @@ -15,8 +17,8 @@ const MOCK_EVENT = { description: 'Annual DevCard conference', location: 'San Francisco, CA', organizerId: MOCK_USER_ID, - startDate: new Date('2025-09-01T09:00:00Z'), - endDate: new Date('2025-09-02T18:00:00Z'), + startDate: new Date('2999-09-01T09:00:00Z'), + endDate: new Date('2999-09-02T18:00:00Z'), isPublic: true, createdAt: new Date('2025-01-01T00:00:00Z'), }; @@ -72,7 +74,7 @@ const prismaMock = { // // Routes are registered under /api/events to match the production prefix. -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ logger: false }); @@ -439,6 +441,25 @@ describe('Events API', () => { expect(res.json()).toMatchObject({ error: 'Event not found' }); }); + it('400 — rejects joining an event that has already ended', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_EVENT, + endDate: new Date('2000-01-01T00:00:00Z'), + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/events/devcard-conf-2025/join', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ + error: 'Cannot join an event that has already ended', + }); + expect(prismaMock.eventAttendee.create).not.toHaveBeenCalled(); + }); + it('409 — returns 409 when user has already joined', async () => { prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); const uniqueError = Object.assign(new Error('Unique constraint'), { code: 'P2002' }); diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index f188681..e209213 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,7 +1,8 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { Prisma } from '@prisma/client'; import { createEventSchema } from '../validations/event.validation.js'; +import type { Prisma } from '@prisma/client'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + // ── Response types ──────────────────────────────────────────────────────────── type EventDetails = { @@ -63,13 +64,21 @@ type EventWithAttendees = Prisma.EventGetPayload<{ type AccessResult = 'allowed' | 'unauthenticated' | 'forbidden'; +type EventAccessSubject = { + id: string; + isPublic: boolean; + organizerId: string; +}; + /** * Extracts the authenticated user ID from the Bearer JWT when present. * Returns null for unauthenticated requests or invalid/expired tokens. * Never throws — safe to call on any request regardless of auth state. */ async function getRequestUserId(request: FastifyRequest): Promise { - if (!request.headers.authorization) return null; + if (!request.headers.authorization) { + return null; + } try { const decoded = (await request.jwtVerify()) as { id: string }; return decoded?.id ?? null; @@ -90,12 +99,18 @@ async function getRequestUserId(request: FastifyRequest): Promise */ async function canAccessEvent( app: FastifyInstance, - event: { id: string; isPublic: boolean; organizerId: string }, + event: EventAccessSubject, userId: string | null, ): Promise { - if (event.isPublic) return 'allowed'; - if (!userId) return 'unauthenticated'; - if (userId === event.organizerId) return 'allowed'; + if (event.isPublic) { + return 'allowed'; + } + if (!userId) { + return 'unauthenticated'; + } + if (userId === event.organizerId) { + return 'allowed'; + } const membership = await app.prisma.eventAttendee.findUnique({ where: { userId_eventId: { userId, eventId: event.id } }, @@ -123,14 +138,11 @@ export async function eventRoutes(app: FastifyInstance) { }>, reply: FastifyReply, ) => { - let decoded: { id: string }; - try { - decoded = (await request.jwtVerify()) as { id: string }; - } catch { + const userId = await getRequestUserId(request); + if (!userId) { return reply.status(401).send({ error: 'Unauthorized' }); } - const userId = decoded.id; const parsed = createEventSchema.safeParse(request.body); if (!parsed.success) { @@ -141,7 +153,7 @@ export async function eventRoutes(app: FastifyInstance) { // Derive a URL-safe slug from the event name and ensure it is unique. // The loop retries with a short random suffix on collision. - let cleanSlug = name + const cleanSlug = name .toLowerCase() .trim() .replace(/\s+/g, '-') @@ -152,7 +164,9 @@ export async function eventRoutes(app: FastifyInstance) { while (true) { const existing = await app.prisma.event.findUnique({ where: { slug: finalSlug } }); - if (!existing) break; + if (!existing) { + break; + } const randomSuffix = Math.random().toString(36).substring(2, 6); finalSlug = `${cleanSlug}-${randomSuffix}`; } @@ -171,7 +185,7 @@ export async function eventRoutes(app: FastifyInstance) { }, }); return reply.status(201).send(newEvent); - } catch (error) { + } catch (_error) { app.log.error('Failed to create event'); return reply.status(500).send({ error: 'Failed to create event' }); } @@ -228,20 +242,20 @@ export async function eventRoutes(app: FastifyInstance) { request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply, ) => { - let decoded: { id: string }; - try { - decoded = (await request.jwtVerify()) as { id: string }; - } catch { + const userId = await getRequestUserId(request); + if (!userId) { return reply.status(401).send({ error: 'Unauthorized' }); } - const userId = decoded.id; const { slug } = request.params; const event = await app.prisma.event.findUnique({ where: { slug } }); if (!event) { return reply.status(404).send({ error: 'Event not found' }); } + if (event.endDate < new Date()) { + return reply.status(400).send({ error: 'Cannot join an event that has already ended' }); + } try { await app.prisma.eventAttendee.create({ @@ -263,14 +277,11 @@ export async function eventRoutes(app: FastifyInstance) { request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply, ) => { - let decoded: { id: string }; - try { - decoded = (await request.jwtVerify()) as { id: string }; - } catch { + const userId = await getRequestUserId(request); + if (!userId) { return reply.status(401).send({ error: 'Unauthorized' }); } - const userId = decoded.id; const { slug } = request.params; const event = await app.prisma.event.findUnique({ where: { slug } });