From ad9c96927b227a1a3242ea4869bd48728ae2e9e6 Mon Sep 17 00:00:00 2001 From: Harxhit Date: Sun, 17 May 2026 16:15:03 +0530 Subject: [PATCH 1/4] docs: add Discord community invitation link to README and CONTRIBUTING.md --- CONTRIBUTING.md | 8 +++++++- README.md | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00cb1e8..0f95620 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,12 @@ # Contributing to DevCard -Thank you for your interest in contributing to DevCard! This guide will help you get started. +

+ + Discord Server + +

+ +**Join the community** — ask questions, get help, discuss ideas, and meet other contributors on our [Discord server](https://discord.gg/QueQN83wn). ## Development Setup diff --git a/README.md b/README.md index cbe700a..136600f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ GitHub Repo + + Discord Server +

From 7e74cba70f16eec2d7051315bb32bc424a49c8ca Mon Sep 17 00:00:00 2001 From: Harxhit Date: Sun, 17 May 2026 21:37:41 +0530 Subject: [PATCH 2/4] git commit -m "feat(events-api): implement event management REST API with Prisma models" --- apps/backend/prisma/schema.prisma | 32 +- apps/backend/src/__tests__/event.test.ts | 661 ++++++++++++++++++ apps/backend/src/app.ts | 7 +- apps/backend/src/routes/event.ts | 277 ++++++++ .../src/validations/event.validation.ts | 11 + 5 files changed, 984 insertions(+), 4 deletions(-) create mode 100644 apps/backend/src/__tests__/event.test.ts create mode 100644 apps/backend/src/routes/event.ts create mode 100644 apps/backend/src/validations/event.validation.ts diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 13dec57..a6b48db 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1,10 +1,8 @@ generator client { provider = "prisma-client-js" } - datasource db { provider = "postgresql" - url = env("DATABASE_URL") } model User { @@ -29,6 +27,9 @@ model User { ownedViews CardView[] @relation("cardOwner") viewedCards CardView[] @relation("cardViewer") followLogs FollowLog[] + organizer Event[] + attendedEvents EventAttendee[] + @@unique([provider, providerId]) @@map("users") @@ -124,3 +125,30 @@ model FollowLog { @@map("follow_logs") } + +model Event { + id String @id @default(uuid()) + name String + slug String @unique + description String? + organizerId String + startDate DateTime + endDate DateTime + isPublic Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + attendees EventAttendee[] + + organizer User @relation(fields: [organizerId], references: [id]) +} + +model EventAttendee { + id String @id @default(uuid()) + userId String + eventId String + joinedAt DateTime + + event Event @relation(fields: [eventId] , references: [id]) + user User @relation(fields: [userId],references: [id]) + + @@unique([userId, eventId]) +} \ No newline at end of file diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts new file mode 100644 index 0000000..403dd56 --- /dev/null +++ b/apps/backend/src/__tests__/event.test.ts @@ -0,0 +1,661 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { FastifyInstance } from 'fastify'; +import { PrismaClient } from '@prisma/client'; +import { eventRoutes } from '../routes/event'; + + +const MOCK_USER_ID = 'user-uuid-001'; +const MOCK_OTHER_USER_ID = 'user-uuid-002'; + +const MOCK_EVENT = { + id: 'event-uuid-001', + name: 'DevCard Conf 2025', + slug: 'devcard-conf-2025', + description: 'Annual DevCard conference', + organizerId: MOCK_USER_ID, + startDate: new Date('2025-09-01T09:00:00Z'), + endDate: new Date('2025-09-02T18:00:00Z'), + isPublic: true, + createdAt: new Date('2025-01-01T00:00:00Z'), +}; + +const MOCK_USER_PROFILE = { + id: MOCK_USER_ID, + username: 'johndoe', + displayName: 'John Doe', + bio: 'Software engineer', + pronouns: 'he/him', + company: 'Acme Corp', + avatarUrl: 'https://example.com/avatar.png', + accentColor: '#6366f1', +}; + +const MOCK_OTHER_USER_PROFILE = { + id: MOCK_OTHER_USER_ID, + username: 'janedoe', + displayName: 'Jane Doe', + bio: null, + pronouns: null, + company: null, + avatarUrl: null, + accentColor: '#6366f1', +}; + +// ─── Prisma mock ───────────────────────────────────────────────────────────── + +const prismaMock = { + event: { + create: vi.fn(), + findUnique: vi.fn(), + }, + eventAttendee: { + create: vi.fn(), + delete: vi.fn(), + }, +}; + +// ─── App factory ───────────────────────────────────────────────────────────── +// +// Builds a minimal Fastify instance that wires up: +// • 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. + +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(); + }); + + await app.register(eventRoutes); + await app.ready(); + return app; +} + +// ─── 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, + authenticated = true, +) { + return app.inject({ + method: 'POST', + url: '/api/events', + headers: authenticated ? authHeader() : {}, + payload: body, + }); +} + +// ─── 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(); + }); + + afterEach(async () => { + await app.close(); + }); + + // ── POST /api/events ─────────────────────────────────────────────────────── + + describe('POST /api/events — create event', () => { + const validBody = { + name: 'DevCard Conf 2025', + description: 'Annual DevCard conference', + startDate: '2025-09-01T09:00:00Z', + endDate: '2025-09-02T18:00:00Z', + isPublic: true, + }; + + it('201 — creates event and returns it for authenticated organizer', async () => { + prismaMock.event.findUnique.mockResolvedValue(null); // slug is free + prismaMock.event.create.mockResolvedValue(MOCK_EVENT); + + const res = await createEvent(app, validBody); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.slug).toBe('devcard-conf-2025'); + expect(body.organizerId).toBe(MOCK_USER_ID); + + // 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'); + expect(callArg.organizerId).toBe(MOCK_USER_ID); + }); + + it('401 — rejects unauthenticated request', async () => { + mockJwtVerify.mockRejectedValue(new Error('Unauthorized')); + + const res = await createEvent(app, validBody, false); + + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ error: 'Unauthorized' }); + }); + + it('400 — rejects missing required fields', async () => { + const res = await createEvent(app, { name: 'Hi' }); // missing dates + expect(res.statusCode).toBe(400); + }); + + it('400 — rejects event name shorter than 3 characters', async () => { + const res = await createEvent(app, { ...validBody, name: 'Hi' }); + expect(res.statusCode).toBe(400); + }); + + it('400 — rejects event name longer than 100 characters', async () => { + const longName = 'A'.repeat(101); + const res = await createEvent(app, { ...validBody, name: longName }); + expect(res.statusCode).toBe(400); + }); + + it('400 — rejects invalid date format', async () => { + 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 + + prismaMock.event.create.mockResolvedValue({ + ...MOCK_EVENT, + slug: 'devcard-conf-2025-ab12', + }); + + 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]+$/); + }); + + it('201 — isPublic defaults to true when omitted', async () => { + prismaMock.event.findUnique.mockResolvedValue(null); + prismaMock.event.create.mockResolvedValue(MOCK_EVENT); + + const { isPublic: _omit, ...bodyWithoutIsPublic } = validBody; + const res = await createEvent(app, bodyWithoutIsPublic); + + expect(res.statusCode).toBe(201); + const callData = prismaMock.event.create.mock.calls[0][0].data; + expect(callData.isPublic).toBe(true); + }); + + it('500 — returns 500 when database write fails', async () => { + prismaMock.event.findUnique.mockResolvedValue(null); + prismaMock.event.create.mockRejectedValue(new Error('DB error')); + + const res = await createEvent(app, validBody); + + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ error: 'Failed to create event' }); + }); + }); + + // ── GET /api/events/:slug ────────────────────────────────────────────────── + + describe('GET /api/events/:slug — event details', () => { + 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', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.slug).toBe('devcard-conf-2025'); + expect(body.attendeesCount).toBe(42); + // 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', + }); + + 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 + 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', + // No Authorization header + }); + + expect(res.statusCode).toBe(200); + expect(mockJwtVerify).not.toHaveBeenCalled(); + }); + }); + + // ── POST /api/events/:slug/join ──────────────────────────────────────────── + + describe('POST /api/events/:slug/join — join event', () => { + it('201 — authenticated user joins an existing event', async () => { + prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); + prismaMock.eventAttendee.create.mockResolvedValue({ + id: 'attendee-uuid-001', + userId: MOCK_OTHER_USER_ID, + eventId: MOCK_EVENT.id, + joinedAt: new Date(), + }); + + mockJwtVerify.mockResolvedValue({ id: MOCK_OTHER_USER_ID }); + + const res = await app.inject({ + method: 'POST', + url: '/api/events/devcard-conf-2025/join', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(201); + expect(res.json()).toMatchObject({ message: 'User joined successfully' }); + + const callData = prismaMock.eventAttendee.create.mock.calls[0][0].data; + expect(callData.eventId).toBe(MOCK_EVENT.id); + expect(callData.userId).toBe(MOCK_OTHER_USER_ID); + }); + + it('401 — rejects unauthenticated request', async () => { + mockJwtVerify.mockRejectedValue(new Error('Unauthorized')); + + const res = await app.inject({ + method: 'POST', + url: '/api/events/devcard-conf-2025/join', + }); + + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ error: 'Unauthorized' }); + }); + + it('404 — returns 404 when event does not exist', async () => { + prismaMock.event.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'POST', + url: '/api/events/ghost-event/join', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ error: 'Event not found' }); + }); + + it('409 — returns 409 when user already joined the event', async () => { + prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); + // Prisma unique constraint error + const uniqueError = Object.assign(new Error('Unique constraint'), { + code: 'P2002', + }); + prismaMock.eventAttendee.create.mockRejectedValue(uniqueError); + + const res = await app.inject({ + method: 'POST', + url: '/api/events/devcard-conf-2025/join', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ error: 'Already joined' }); + }); + + it('500 — returns 500 on unexpected database error', async () => { + prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); + prismaMock.eventAttendee.create.mockRejectedValue(new Error('DB error')); + + const res = await app.inject({ + method: 'POST', + url: '/api/events/devcard-conf-2025/join', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ error: 'Failed to join' }); + }); + }); + + // ── DELETE /api/events/:slug/leave ──────────────────────────────────────── + + describe('DELETE /api/events/:slug/leave — leave event', () => { + it('204 — authenticated user leaves an event they joined', async () => { + prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); + prismaMock.eventAttendee.delete.mockResolvedValue({}); + + mockJwtVerify.mockResolvedValue({ id: MOCK_OTHER_USER_ID }); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/events/devcard-conf-2025/leave', + headers: authHeader(), + }); + + 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, + }, + }); + }); + + it('401 — rejects unauthenticated request', async () => { + mockJwtVerify.mockRejectedValue(new Error('Unauthorized')); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/events/devcard-conf-2025/leave', + }); + + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ error: 'Unauthorized' }); + }); + + it('404 — returns 404 when event does not exist', async () => { + prismaMock.event.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/events/ghost-event/leave', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ error: 'Event not found' }); + }); + + 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', + }); + prismaMock.eventAttendee.delete.mockRejectedValue(notFoundError); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/events/devcard-conf-2025/leave', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ error: 'User not found' }); + }); + + it('500 — returns 500 on unexpected database error', async () => { + prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); + prismaMock.eventAttendee.delete.mockRejectedValue(new Error('DB error')); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/events/devcard-conf-2025/leave', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ error: 'Failed to leave' }); + }); + }); + + // ── 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 }, + }; + } + + 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, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees', + }); + + 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, + }); + }); + + it('200 — respects custom page and limit query params', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_EVENT, + attendees: [makeAttendeeRow(MOCK_OTHER_USER_PROFILE)], + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees?page=2&limit=5', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + 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.take).toBe(5); + }); + + it('200 — caps limit at 50 even if higher value is requested', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_EVENT, + attendees: [], + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees?limit=200', + }); + + expect(res.statusCode).toBe(200); + const includeArg = prismaMock.event.findUnique.mock.calls[0][0].include; + expect(includeArg.attendees.take).toBe(50); + }); + + it('200 — treats page < 1 as page 1', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_EVENT, + attendees: [], + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees?page=0', + }); + + 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 + }); + + it('200 — returns empty attendees list for event with no attendees', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_EVENT, + attendees: [], + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.attendees).toHaveLength(0); + expect(body.pagination.total).toBe(0); + }); + + it('200 — public profiles do not leak sensitive fields', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_EVENT, + attendees: [makeAttendeeRow(MOCK_USER_PROFILE)], + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/devcard-conf-2025/attendees', + }); + + 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'); + expect(attendee).not.toHaveProperty('role'); + }); + + it('404 — returns 404 for unknown event slug', async () => { + prismaMock.event.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/api/events/ghost-event/attendees', + }); + + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ error: 'Event not found' }); + }); + + it('200 — attendees are ordered by joinedAt desc (latest first)', async () => { + prismaMock.event.findUnique.mockResolvedValue({ + ...MOCK_EVENT, + attendees: [], + }); + + 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' }); + }); + }); + + // ── Slug generation edge cases ──────────────────────────────────────────── + + describe('Slug generation', () => { + const baseBody = { + startDate: '2025-09-01T09:00:00Z', + endDate: '2025-09-02T18:00:00Z', + }; + + it('converts spaces and special characters to hyphens', async () => { + prismaMock.event.findUnique.mockResolvedValue(null); + prismaMock.event.create.mockResolvedValue({ ...MOCK_EVENT, slug: 'my-awesome-event' }); + + await createEvent(app, { ...baseBody, name: 'My Awesome Event!!!' }); + + const slug: string = prismaMock.event.create.mock.calls[0][0].data.slug; + expect(slug).toBe('my-awesome-event'); + }); + + it('strips leading and trailing hyphens from slug', async () => { + prismaMock.event.findUnique.mockResolvedValue(null); + prismaMock.event.create.mockResolvedValue({ ...MOCK_EVENT, slug: 'event-name' }); + + await createEvent(app, { ...baseBody, name: '---Event Name---' }); + + const slug: string = prismaMock.event.create.mock.calls[0][0].data.slug; + expect(slug).not.toMatch(/^-|-$/); + }); + + it('collapses multiple consecutive hyphens into one', async () => { + prismaMock.event.findUnique.mockResolvedValue(null); + prismaMock.event.create.mockResolvedValue({ ...MOCK_EVENT, slug: 'event-name' }); + + await createEvent(app, { ...baseBody, name: 'Event Name' }); + + const slug: string = prismaMock.event.create.mock.calls[0][0].data.slug; + 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 dc023a2..964a977 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -9,7 +9,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { prismaPlugin } from './plugins/prisma.js'; -import { redisPlugin } from './plugins/redis.js'; +// import { redisPlugin } from './plugins/redis.js'; import { authRoutes } from './routes/auth.js'; import { profileRoutes } from './routes/profiles.js'; import { cardRoutes } from './routes/cards.js'; @@ -17,6 +17,8 @@ import { publicRoutes } from './routes/public.js'; import { followRoutes } from './routes/follow.js'; import { connectRoutes } from './routes/connect.js'; import { analyticsRoutes } from './routes/analytics.js'; +import { eventRoutes } from './routes/event.js'; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -55,7 +57,7 @@ export async function buildApp() { // ─── Database & Cache Plugins ─── await app.register(prismaPlugin); - await app.register(redisPlugin); + // await app.register(redisPlugin); // ─── Auth Decorator ─── app.decorate('authenticate', async function (request: any, reply: any) { @@ -74,6 +76,7 @@ export async function buildApp() { await app.register(followRoutes, { prefix: '/api/follow' }); await app.register(connectRoutes, { prefix: '/api/connect' }); await app.register(analyticsRoutes, { prefix: '/api/analytics' }); + await app.register(eventRoutes, {prefix: '/api/events'}) // ─── Health Check ─── app.get('/health', async () => ({ diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts new file mode 100644 index 0000000..6e2a271 --- /dev/null +++ b/apps/backend/src/routes/event.ts @@ -0,0 +1,277 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { createEventSchema, joinEventSchema} from '../validations/event.validation'; + +type EventDetails = { + id: string; + name: string; + slug: string; + description: string | null; + organizerId: string; + startDate: Date; + endDate: Date; + createdAt: Date; + attendeesCount: number +} + +type AttendeePublicProfile = { + id: string; + username: string; + displayName: string; + bio: string | null; + pronouns: string | null; + company: string | null; + avatarUrl: string | null; + accentColor: string; +} + + +type PaginatedAttendeesResponse = { + attendees: AttendeePublicProfile[]; + pagination: { + page: number; + limit: number; + total: number; + }; +} + +export async function eventRoutes(app:FastifyInstance) { + app.post('/api/events' , async(request: FastifyRequest<{ + Body: { + name: string, + description?: string, + startDate: 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} = 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, + 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('/api/events/:slug', async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { + const paramsSlug = request.params.slug; + const details = await app.prisma.event.findUnique({ + where: { + slug : paramsSlug, + }, + 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, + organizerId: details.organizerId, + startDate: details.startDate, + endDate: details.endDate, + createdAt: details.createdAt, + attendeesCount: details._count.attendees + } + + return response; + }) + + app.post('/api/events/: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('/api/events/: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('/api/events/:slug/attendees', async(request: FastifyRequest<{Params: {slug: string}, Querystring: {page?:string; limit?: 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 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: { + 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'} + } + }, + }) + + if(!event){ + return reply.status(404).send({error: 'Event not found'}) + } + + + const attendees = event.attendees.map(attendee => ({ + 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.attendees.length, + } + } + + return response; + }) +} \ No newline at end of file diff --git a/apps/backend/src/validations/event.validation.ts b/apps/backend/src/validations/event.validation.ts new file mode 100644 index 0000000..210ee8e --- /dev/null +++ b/apps/backend/src/validations/event.validation.ts @@ -0,0 +1,11 @@ +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(), + 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 From dae533491be479c692272d9e16d789a25d08bb1a Mon Sep 17 00:00:00 2001 From: Harxhit Date: Sun, 17 May 2026 22:04:30 +0530 Subject: [PATCH 3/4] fix: revert changes to align with repository tech stack --- apps/backend/prisma/schema.prisma | 1 + apps/backend/src/app.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index a6b48db..b645274 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -3,6 +3,7 @@ generator client { } datasource db { provider = "postgresql" + url = env("DATABASE_URL") } model User { diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 964a977..f887c08 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -9,7 +9,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { prismaPlugin } from './plugins/prisma.js'; -// import { redisPlugin } from './plugins/redis.js'; +import { redisPlugin } from './plugins/redis.js'; import { authRoutes } from './routes/auth.js'; import { profileRoutes } from './routes/profiles.js'; import { cardRoutes } from './routes/cards.js'; @@ -57,7 +57,7 @@ export async function buildApp() { // ─── Database & Cache Plugins ─── await app.register(prismaPlugin); - // await app.register(redisPlugin); + await app.register(redisPlugin); // ─── Auth Decorator ─── app.decorate('authenticate', async function (request: any, reply: any) { From 7868d368a0ddddf81021aacfa9ffbf414943a0d2 Mon Sep 17 00:00:00 2001 From: Harxhit Date: Sun, 17 May 2026 23:37:54 +0530 Subject: [PATCH 4/4] fix: Revert changes --- CONTRIBUTING.md | 8 +------- README.md | 3 --- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f95620..00cb1e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,6 @@ # Contributing to DevCard -

- - Discord Server - -

- -**Join the community** — ask questions, get help, discuss ideas, and meet other contributors on our [Discord server](https://discord.gg/QueQN83wn). +Thank you for your interest in contributing to DevCard! This guide will help you get started. ## Development Setup diff --git a/README.md b/README.md index 136600f..cbe700a 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,6 @@ GitHub Repo - - Discord Server -