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.
+
+
+
+
+
+
+**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 @@
+
+
+
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
-
-
-
-
-
-
-**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 @@
-
-
-