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

+ + 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/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 -

- - 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 -

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