From ad9c96927b227a1a3242ea4869bd48728ae2e9e6 Mon Sep 17 00:00:00 2001
From: Harxhit
Date: Sun, 17 May 2026 16:15:03 +0530
Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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 @@
-
-
-
From d9a947ae2fcfd97f9e8f09ab94a87ad08963887d Mon Sep 17 00:00:00 2001
From: Harxhit
Date: Tue, 19 May 2026 13:44:04 +0530
Subject: [PATCH 5/7] fix: add location field to schema and update API,
validation, and tests
---
apps/backend/src/__tests__/event.test.ts | 27 ++-
apps/backend/src/prisma/schema.prisma | 155 ++++++++++++++++++
apps/backend/src/routes/event.ts | 14 +-
.../src/validations/event.validation.ts | 1 +
4 files changed, 186 insertions(+), 11 deletions(-)
create mode 100644 apps/backend/src/prisma/schema.prisma
diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts
index 403dd56..b921fb2 100644
--- a/apps/backend/src/__tests__/event.test.ts
+++ b/apps/backend/src/__tests__/event.test.ts
@@ -3,6 +3,7 @@ import Fastify, { FastifyInstance } from 'fastify';
import { PrismaClient } from '@prisma/client';
import { eventRoutes } from '../routes/event';
+// ─── Shared mock data ────────────────────────────────────────────────────────
const MOCK_USER_ID = 'user-uuid-001';
const MOCK_OTHER_USER_ID = 'user-uuid-002';
@@ -12,6 +13,7 @@ const MOCK_EVENT = {
name: 'DevCard Conf 2025',
slug: 'devcard-conf-2025',
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'),
@@ -124,6 +126,7 @@ describe('Events API', () => {
const validBody = {
name: 'DevCard Conf 2025',
description: 'Annual DevCard conference',
+ location: 'San Francisco, CA',
startDate: '2025-09-01T09:00:00Z',
endDate: '2025-09-02T18:00:00Z',
isPublic: true,
@@ -139,12 +142,14 @@ describe('Events API', () => {
const body = res.json();
expect(body.slug).toBe('devcard-conf-2025');
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');
expect(callArg.organizerId).toBe(MOCK_USER_ID);
+ expect(callArg.location).toBe('San Francisco, CA');
});
it('401 — rejects unauthenticated request', async () => {
@@ -156,8 +161,24 @@ describe('Events API', () => {
expect(res.json()).toMatchObject({ error: 'Unauthorized' });
});
- it('400 — rejects missing required fields', async () => {
- const res = await createEvent(app, { name: 'Hi' }); // missing dates
+ it('400 — rejects missing required fields (no dates, no location)', async () => {
+ const res = await createEvent(app, { name: 'Hello World' }); // missing dates + location
+ expect(res.statusCode).toBe(400);
+ });
+
+ it('400 — rejects missing location', async () => {
+ const { location: _omit, ...bodyWithoutLocation } = validBody;
+ const res = await createEvent(app, bodyWithoutLocation);
+ expect(res.statusCode).toBe(400);
+ });
+
+ it('400 — rejects location shorter than 2 characters', async () => {
+ const res = await createEvent(app, { ...validBody, location: 'A' });
+ expect(res.statusCode).toBe(400);
+ });
+
+ it('400 — rejects location longer than 100 characters', async () => {
+ const res = await createEvent(app, { ...validBody, location: 'A'.repeat(101) });
expect(res.statusCode).toBe(400);
});
@@ -240,6 +261,7 @@ describe('Events API', () => {
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);
});
@@ -624,6 +646,7 @@ describe('Events API', () => {
describe('Slug generation', () => {
const baseBody = {
+ location: 'San Francisco, CA',
startDate: '2025-09-01T09:00:00Z',
endDate: '2025-09-02T18:00:00Z',
};
diff --git a/apps/backend/src/prisma/schema.prisma b/apps/backend/src/prisma/schema.prisma
new file mode 100644
index 0000000..e4673d8
--- /dev/null
+++ b/apps/backend/src/prisma/schema.prisma
@@ -0,0 +1,155 @@
+generator client {
+ provider = "prisma-client-js"
+}
+datasource db {
+ provider = "postgresql"
+}
+
+model User {
+ id String @id @default(uuid())
+ email String @unique
+ username String @unique
+ displayName String @map("display_name")
+ bio String?
+ pronouns String?
+ role String?
+ company String?
+ avatarUrl String? @map("avatar_url")
+ accentColor String @default("#6366f1") @map("accent_color")
+ provider String
+ providerId String @map("provider_id")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ platformLinks PlatformLink[]
+ cards Card[]
+ oauthTokens OAuthToken[]
+ ownedViews CardView[] @relation("cardOwner")
+ viewedCards CardView[] @relation("cardViewer")
+ followLogs FollowLog[]
+ organizer Event[]
+ attendedEvents EventAttendee[]
+
+
+ @@unique([provider, providerId])
+ @@map("users")
+}
+
+model PlatformLink {
+ id String @id @default(uuid())
+ userId String @map("user_id")
+ platform String
+ username String
+ url String
+ displayOrder Int @default(0) @map("display_order")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ cardLinks CardLink[]
+
+ @@map("platform_links")
+}
+
+model Card {
+ id String @id @default(uuid())
+ userId String @map("user_id")
+ title String
+ isDefault Boolean @default(false) @map("is_default")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ cardLinks CardLink[]
+ views CardView[]
+
+ @@map("cards")
+}
+
+model CardLink {
+ id String @id @default(uuid())
+ cardId String @map("card_id")
+ platformLinkId String @map("platform_link_id")
+ displayOrder Int @default(0) @map("display_order")
+
+ card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
+ platformLink PlatformLink @relation(fields: [platformLinkId], references: [id], onDelete: Cascade)
+
+ @@unique([cardId, platformLinkId])
+ @@map("card_links")
+}
+
+model OAuthToken {
+ id String @id @default(uuid())
+ userId String @map("user_id")
+ platform String
+ accessToken String @map("access_token")
+ refreshToken String? @map("refresh_token")
+ scopes String
+ expiresAt DateTime? @map("expires_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, platform])
+ @@map("oauth_tokens")
+}
+
+model CardView {
+ id String @id @default(uuid())
+ cardId String? @map("card_id") // null = default profile view
+ ownerId String @map("owner_id") // card/profile owner
+ viewerId String? @map("viewer_id") // null = anonymous web viewer
+ viewerIp String? @map("viewer_ip")
+ viewerAgent String? @map("viewer_agent")
+ source String @default("qr") // "qr" | "link" | "web" | "app"
+ createdAt DateTime @default(now()) @map("created_at")
+
+ card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull)
+ owner User @relation("cardOwner", fields: [ownerId], references: [id], onDelete: Cascade)
+ viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull)
+
+ @@map("card_views")
+}
+
+model FollowLog {
+ id String @id @default(uuid())
+ followerId String @map("follower_id")
+ targetUsername String @map("target_username")
+ platform String
+ status String @default("success") // "success" | "error"
+ layer String // "api" | "webview" | "link"
+ createdAt DateTime @default(now()) @map("created_at")
+
+ follower User @relation(fields: [followerId], references: [id], onDelete: Cascade)
+
+ @@map("follow_logs")
+}
+
+model Event {
+ id String @id @default(uuid())
+ name String
+ slug String @unique
+ location String
+ 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/routes/event.ts b/apps/backend/src/routes/event.ts
index 6e2a271..4e2f7d0 100644
--- a/apps/backend/src/routes/event.ts
+++ b/apps/backend/src/routes/event.ts
@@ -5,6 +5,7 @@ type EventDetails = {
id: string;
name: string;
slug: string;
+ location: string;
description: string | null;
organizerId: string;
startDate: Date;
@@ -40,6 +41,7 @@ export async function eventRoutes(app:FastifyInstance) {
name: string,
description?: string,
startDate: string,
+ location: string,
endDate: string,
isPublic?: boolean
}}>, reply: FastifyReply) => {
@@ -55,7 +57,7 @@ export async function eventRoutes(app:FastifyInstance) {
return reply.status(400).send({error: 'Bad request'})
}
- const {name, description, startDate, endDate, isPublic} = parsed.data
+ 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;
@@ -79,6 +81,7 @@ export async function eventRoutes(app:FastifyInstance) {
name,
description,
slug: finalSlug,
+ location: location,
startDate: startDateObj,
endDate: endDateObj,
isPublic: isPublic ?? true,
@@ -118,6 +121,7 @@ export async function eventRoutes(app:FastifyInstance) {
name: details.name,
slug: details.slug,
description: details.description,
+ location: details.location,
organizerId: details.organizerId,
startDate: details.startDate,
endDate: details.endDate,
@@ -208,14 +212,6 @@ export async function eventRoutes(app:FastifyInstance) {
})
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);
diff --git a/apps/backend/src/validations/event.validation.ts b/apps/backend/src/validations/event.validation.ts
index 210ee8e..0fc4044 100644
--- a/apps/backend/src/validations/event.validation.ts
+++ b/apps/backend/src/validations/event.validation.ts
@@ -3,6 +3,7 @@ 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)
From b4ee55174c1e5eba1783d77d1b148c0877fe9f87 Mon Sep 17 00:00:00 2001
From: Harxhit
Date: Tue, 19 May 2026 13:52:06 +0530
Subject: [PATCH 6/7] fix: remove accidental schema.prisma file
---
apps/backend/src/prisma/schema.prisma | 155 --------------------------
1 file changed, 155 deletions(-)
delete mode 100644 apps/backend/src/prisma/schema.prisma
diff --git a/apps/backend/src/prisma/schema.prisma b/apps/backend/src/prisma/schema.prisma
deleted file mode 100644
index e4673d8..0000000
--- a/apps/backend/src/prisma/schema.prisma
+++ /dev/null
@@ -1,155 +0,0 @@
-generator client {
- provider = "prisma-client-js"
-}
-datasource db {
- provider = "postgresql"
-}
-
-model User {
- id String @id @default(uuid())
- email String @unique
- username String @unique
- displayName String @map("display_name")
- bio String?
- pronouns String?
- role String?
- company String?
- avatarUrl String? @map("avatar_url")
- accentColor String @default("#6366f1") @map("accent_color")
- provider String
- providerId String @map("provider_id")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
-
- platformLinks PlatformLink[]
- cards Card[]
- oauthTokens OAuthToken[]
- ownedViews CardView[] @relation("cardOwner")
- viewedCards CardView[] @relation("cardViewer")
- followLogs FollowLog[]
- organizer Event[]
- attendedEvents EventAttendee[]
-
-
- @@unique([provider, providerId])
- @@map("users")
-}
-
-model PlatformLink {
- id String @id @default(uuid())
- userId String @map("user_id")
- platform String
- username String
- url String
- displayOrder Int @default(0) @map("display_order")
- createdAt DateTime @default(now()) @map("created_at")
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- cardLinks CardLink[]
-
- @@map("platform_links")
-}
-
-model Card {
- id String @id @default(uuid())
- userId String @map("user_id")
- title String
- isDefault Boolean @default(false) @map("is_default")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- cardLinks CardLink[]
- views CardView[]
-
- @@map("cards")
-}
-
-model CardLink {
- id String @id @default(uuid())
- cardId String @map("card_id")
- platformLinkId String @map("platform_link_id")
- displayOrder Int @default(0) @map("display_order")
-
- card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
- platformLink PlatformLink @relation(fields: [platformLinkId], references: [id], onDelete: Cascade)
-
- @@unique([cardId, platformLinkId])
- @@map("card_links")
-}
-
-model OAuthToken {
- id String @id @default(uuid())
- userId String @map("user_id")
- platform String
- accessToken String @map("access_token")
- refreshToken String? @map("refresh_token")
- scopes String
- expiresAt DateTime? @map("expires_at")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
-
- @@unique([userId, platform])
- @@map("oauth_tokens")
-}
-
-model CardView {
- id String @id @default(uuid())
- cardId String? @map("card_id") // null = default profile view
- ownerId String @map("owner_id") // card/profile owner
- viewerId String? @map("viewer_id") // null = anonymous web viewer
- viewerIp String? @map("viewer_ip")
- viewerAgent String? @map("viewer_agent")
- source String @default("qr") // "qr" | "link" | "web" | "app"
- createdAt DateTime @default(now()) @map("created_at")
-
- card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull)
- owner User @relation("cardOwner", fields: [ownerId], references: [id], onDelete: Cascade)
- viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull)
-
- @@map("card_views")
-}
-
-model FollowLog {
- id String @id @default(uuid())
- followerId String @map("follower_id")
- targetUsername String @map("target_username")
- platform String
- status String @default("success") // "success" | "error"
- layer String // "api" | "webview" | "link"
- createdAt DateTime @default(now()) @map("created_at")
-
- follower User @relation(fields: [followerId], references: [id], onDelete: Cascade)
-
- @@map("follow_logs")
-}
-
-model Event {
- id String @id @default(uuid())
- name String
- slug String @unique
- location String
- 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
From e5f5be7d92f2da2e95f2616e2682e0a6de9e5983 Mon Sep 17 00:00:00 2001
From: Harxhit
Date: Tue, 19 May 2026 13:55:01 +0530
Subject: [PATCH 7/7] fix: Updated schema with location in event
---
apps/backend/prisma/schema.prisma | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma
index b645274..f2d3afe 100644
--- a/apps/backend/prisma/schema.prisma
+++ b/apps/backend/prisma/schema.prisma
@@ -3,7 +3,8 @@ generator client {
}
datasource db {
provider = "postgresql"
- url = env("DATABASE_URL")
+ url = env("DATABASE_URL")
+
}
model User {
@@ -131,6 +132,7 @@ model Event {
id String @id @default(uuid())
name String
slug String @unique
+ location String
description String?
organizerId String
startDate DateTime