diff --git a/README.md b/README.md index 4af8cdfc..a9b00bd5 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,39 @@ The following error cases are implemented: | **Set Default Card** | 404 | `{ error: 'Card not found' }` — when card ID doesn't exist or doesn't belong to authenticated user | | **Successful Deletion** | 204 | No content | +## Events & Attendance + +Users can mark themselves as attending an event (hackathon) and choose a role. Reading an event and its attendee list is public; marking/leaving attendance requires authentication. + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| `GET` | `/api/events/:slug` | No | Event details + attendee count | +| `GET` | `/api/events/:slug/attendees` | No | Paginated attendee list, each with a public `role` | +| `POST` | `/api/events/:slug/join` | Yes | Mark attendance. Body: `{ "role": "PARTICIPANT" \| "ORGANIZER" \| "MENTOR" }` (optional, defaults to `PARTICIPANT`). Returns `{ message, role, flagged }` | +| `DELETE` | `/api/events/:slug/leave` | Yes | Remove your attendance | + +```jsonc +// POST /api/events/devcard-hack-2026/join +{ "role": "MENTOR" } +// → 201 { "message": "User joined successfully", "role": "MENTOR", "flagged": false } +``` + +| Scenario | Status | Response | +|----------|--------|----------| +| **Join** | 400 | `{ error: 'Bad request' }` — invalid `role` | +| **Join / Leave** | 404 | `{ error: 'Event not found' }` | +| **Join** | 409 | `{ error: 'Already joined' }` — attendance already marked | +| **Join** | 429 | Rate limited (see below) | +| **Leave** | 404 | `{ error: 'User not found' }` — not currently attending | +| **Leave** | 204 | No content | + +### Attendance spam rules + +To keep normal sign-ups frictionless while catching mass-marking ("marking every hackathon without attending"), two independent guardrails run on the join route: + +- **Rate limit (hard block):** the join route is capped at **10 requests/minute**; excess requests are rejected with **HTTP 429**. This stops scripted retries. +- **Heuristic (soft flag):** if a user marks attendance for **8 or more events within a 5-minute window**, the attendee record is stored with `flagged = true` and an audit line is logged for moderator review. **The join still succeeds** — legitimate users are never blocked, and `flagged` is never exposed on the public attendee list. Both thresholds are tunable constants (`SPAM_WINDOW_MINUTES`, `SPAM_MAX_JOINS`) in `apps/backend/src/routes/event.ts`. + ## Good First Issues New to open source? We've got you covered! Check out our [Good First Issues](https://github.com/Dev-Card/DevCard/issues?q=is%3Aopen+label%3A%22good-first-issue%22), these are specially curated issues that are: diff --git a/apps/backend/prisma/migrations/20260722181416_attendee_role_and_spam_flag/migration.sql b/apps/backend/prisma/migrations/20260722181416_attendee_role_and_spam_flag/migration.sql new file mode 100644 index 00000000..5552f809 --- /dev/null +++ b/apps/backend/prisma/migrations/20260722181416_attendee_role_and_spam_flag/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "AttendeeRole" AS ENUM ('PARTICIPANT', 'ORGANIZER', 'MENTOR'); + +-- AlterTable +ALTER TABLE "event_attendees" ADD COLUMN "role" "AttendeeRole" NOT NULL DEFAULT 'PARTICIPANT', +ADD COLUMN "flagged" BOOLEAN NOT NULL DEFAULT false, +ALTER COLUMN "joinedAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- CreateIndex +CREATE INDEX "event_attendees_userId_joinedAt_idx" ON "event_attendees"("userId", "joinedAt"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 68f3e2a7..e0557c42 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -236,16 +236,25 @@ model Event { @@map("events") } +enum AttendeeRole { + PARTICIPANT + ORGANIZER + MENTOR +} + model EventAttendee { - id String @id @default(uuid()) + id String @id @default(uuid()) userId String eventId String - joinedAt DateTime + role AttendeeRole @default(PARTICIPANT) + flagged Boolean @default(false) + joinedAt DateTime @default(now()) event Event @relation(fields: [eventId], references: [id]) user User @relation(fields: [userId], references: [id]) @@unique([userId, eventId]) + @@index([userId, joinedAt]) @@map("event_attendees") } diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts index 1931223b..a2d52290 100644 --- a/apps/backend/src/__tests__/event.test.ts +++ b/apps/backend/src/__tests__/event.test.ts @@ -56,6 +56,7 @@ const prismaMock = { eventAttendee: { create: vi.fn(), delete: vi.fn(), + count: vi.fn(), }, }; @@ -125,6 +126,8 @@ describe('Events API', () => { vi.clearAllMocks(); // Default: authenticated as MOCK_USER_ID mockJwtVerify.mockResolvedValue({ id: MOCK_USER_ID }); + // Default: user has no recent joins (spam heuristic inactive). + prismaMock.eventAttendee.count.mockResolvedValue(0); app = await buildApp(); }); @@ -314,12 +317,14 @@ describe('Events API', () => { // ── POST /api/events/:slug/join ──────────────────────────────────────────── describe('POST /api/events/:slug/join — join event', () => { - it('201 — authenticated user joins an existing event', async () => { + it('201 — authenticated user joins an existing event (defaults to PARTICIPANT)', async () => { prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); prismaMock.eventAttendee.create.mockResolvedValue({ id: 'attendee-uuid-001', userId: MOCK_OTHER_USER_ID, eventId: MOCK_EVENT.id, + role: 'PARTICIPANT', + flagged: false, joinedAt: new Date(), }); @@ -332,11 +337,104 @@ describe('Events API', () => { }); expect(res.statusCode).toBe(201); - expect(res.json()).toMatchObject({ message: 'User joined successfully' }); + expect(res.json()).toMatchObject({ + message: 'User joined successfully', + role: 'PARTICIPANT', + flagged: false, + }); 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); + expect(callData.role).toBe('PARTICIPANT'); + expect(callData.flagged).toBe(false); + }); + + it('201 — persists the chosen attendee role (ORGANIZER/MENTOR)', async () => { + prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); + prismaMock.eventAttendee.create.mockResolvedValue({ + id: 'attendee-uuid-002', + userId: MOCK_USER_ID, + eventId: MOCK_EVENT.id, + role: 'MENTOR', + flagged: false, + joinedAt: new Date(), + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/events/devcard-conf-2025/join', + headers: authHeader(), + payload: { role: 'MENTOR' }, + }); + + expect(res.statusCode).toBe(201); + const callData = prismaMock.eventAttendee.create.mock.calls[0][0].data; + expect(callData.role).toBe('MENTOR'); + }); + + it('400 — rejects an invalid attendee role', async () => { + prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); + + const res = await app.inject({ + method: 'POST', + url: '/api/events/devcard-conf-2025/join', + headers: authHeader(), + payload: { role: 'SUPERHERO' }, + }); + + expect(res.statusCode).toBe(400); + expect(prismaMock.eventAttendee.create).not.toHaveBeenCalled(); + }); + + it('flags the join when the user has joined too many events recently', async () => { + prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); + // Simulate a burst: user already joined the max in the window. + prismaMock.eventAttendee.count.mockResolvedValue(8); + prismaMock.eventAttendee.create.mockResolvedValue({ + id: 'attendee-uuid-003', + userId: MOCK_USER_ID, + eventId: MOCK_EVENT.id, + role: 'PARTICIPANT', + flagged: true, + joinedAt: new Date(), + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/events/devcard-conf-2025/join', + headers: authHeader(), + }); + + // Join still succeeds (soft flag, no friction)... + expect(res.statusCode).toBe(201); + expect(res.json()).toMatchObject({ flagged: true }); + // ...but the record is persisted as flagged for review. + const callData = prismaMock.eventAttendee.create.mock.calls[0][0].data; + expect(callData.flagged).toBe(true); + }); + + it('does NOT flag a normal join below the spam threshold', async () => { + prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT); + prismaMock.eventAttendee.count.mockResolvedValue(2); + prismaMock.eventAttendee.create.mockResolvedValue({ + id: 'attendee-uuid-004', + userId: MOCK_USER_ID, + eventId: MOCK_EVENT.id, + role: 'PARTICIPANT', + flagged: false, + joinedAt: new Date(), + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/events/devcard-conf-2025/join', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(201); + const callData = prismaMock.eventAttendee.create.mock.calls[0][0].data; + expect(callData.flagged).toBe(false); }); it('401 — rejects unauthenticated request', async () => { @@ -488,10 +586,12 @@ describe('Events API', () => { /** Builds a raw EventAttendee row as Prisma returns it (with nested user) */ function makeAttendeeRow( profile: typeof MOCK_USER_PROFILE | typeof MOCK_OTHER_USER_PROFILE, + role: 'PARTICIPANT' | 'ORGANIZER' | 'MENTOR' = 'PARTICIPANT', ) : { id: string; userId: string; eventId: string; + role: string; joinedAt: Date; user: typeof MOCK_USER_PROFILE | typeof MOCK_OTHER_USER_PROFILE; } { @@ -499,6 +599,7 @@ describe('Events API', () => { id: `attendee-${profile.id}`, userId: profile.id, eventId: MOCK_EVENT.id, + role, joinedAt: new Date(), user: { ...profile }, }; @@ -613,10 +714,10 @@ describe('Events API', () => { expect(body.pagination.total).toBe(0); }); - it('200 — public profiles do not leak sensitive fields', async () => { + it('200 — exposes the attendee role but not sensitive fields', async () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, - attendees: [makeAttendeeRow(MOCK_USER_PROFILE)], + attendees: [makeAttendeeRow(MOCK_USER_PROFILE, 'ORGANIZER')], _count: { attendees: 1 }, }); @@ -632,12 +733,14 @@ describe('Events API', () => { expect(attendee).toHaveProperty('username'); expect(attendee).toHaveProperty('displayName'); expect(attendee).toHaveProperty('accentColor'); + // The attendance role is public and drives the UI badge. + expect(attendee.role).toBe('ORGANIZER'); // 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'); + expect(attendee).not.toHaveProperty('flagged'); }); it('404 — returns 404 for unknown event slug', async () => { diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 5c24b4ff..fc23e654 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,8 +1,16 @@ import {generateUniqueSlug} from '../utils/slug.js' -import { createEventSchema} from '../validations/event.validation.js'; +import { createEventSchema, joinEventSchema} from '../validations/event.validation.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +// ── Attendance spam heuristic ──────────────────────────────────────────────── +// A user who marks attendance for many events in a very short window is likely +// spamming (marking every hackathon without attending). We record such joins +// with `flagged = true` for moderator review, but still allow the join so that +// normal, fast sign-ups stay frictionless. See README "Attendance spam rules". +const SPAM_WINDOW_MINUTES = 5; +const SPAM_MAX_JOINS = 7; + type EventDetails = { id: string; @@ -28,6 +36,7 @@ type AttendeePublicProfile = { company: string | null; avatarUrl: string | null; accentColor: string; + role: string; } @@ -45,6 +54,7 @@ type EventWithAttendees = { attendees: number; }; attendees: { + role: string; user: { id: string; username: string; @@ -142,9 +152,19 @@ export async function eventRoutes(app:FastifyInstance): Promise { return response; }) - app.post<{ Params: { slug: string } }>('/:slug/join', {preHandler: [(req, reply) => app.authenticate(req, reply)]}, async(request, reply) => { + app.post<{ Params: { slug: string }; Body: { role?: string } }>('/:slug/join', { + preHandler: [(req, reply) => app.authenticate(req, reply)], + // Rate limit (block): fast-reject scripted mass-marking on this route. + config: { rateLimit: { max: 10, timeWindow: '1 minute' } }, + }, async(request, reply) => { const userId = request.user.id; - const paramsSlug = request.params.slug; + const paramsSlug = request.params.slug; + + const parsed = joinEventSchema.safeParse(request.body ?? {}); + if(!parsed.success){ + return reply.status(400).send({error: 'Bad request'}) + } + const { role } = parsed.data; const event = await app.prisma.event.findUnique({ where: { @@ -156,21 +176,36 @@ export async function eventRoutes(app:FastifyInstance): Promise { return reply.status(404).send({error: 'Event not found'}) } + // Heuristic (flag): count how many events this user has joined recently. + // Exceeding the threshold marks the record as flagged for review without + // blocking the join, so legitimate users are never stopped. + const recentJoins = await app.prisma.eventAttendee.count({ + where: { + userId, + joinedAt: { gte: new Date(Date.now() - SPAM_WINDOW_MINUTES * 60_000) }, + }, + }) + const flagged = recentJoins >= SPAM_MAX_JOINS; + if(flagged){ + app.log.warn(`Attendance spam heuristic tripped: userId=${userId} eventId=${event.id} recentJoins=${recentJoins}`) + } + try { - await app.prisma.eventAttendee.create({ + const attendee = await app.prisma.eventAttendee.create({ data: { - eventId: event.id, - userId, - joinedAt: new Date() + eventId: event.id, + userId, + role, + flagged, } }) - return reply.status(201).send({message: 'User joined successfully'}) + return reply.status(201).send({message: 'User joined successfully', role: attendee.role, flagged: attendee.flagged}) } catch (error:any) { if(error.code === "P2002" ){ return reply.status(409).send({error: 'Already joined'}) } - app.log.error((error as Error).message); + app.log.error((error as Error).message); return reply.status(500).send({error: 'Failed to join'}) } @@ -223,20 +258,21 @@ export async function eventRoutes(app:FastifyInstance): Promise { select: { attendees: true } }, attendees : { - include: { + select: { + role: true, user: { select: { id: true, - username: true, + username: true, displayName:true, bio: true, pronouns: true, - company: true, + company: true, avatarUrl: true, - accentColor: true + accentColor: true } } - }, + }, skip, take: limit, orderBy: {joinedAt: 'desc'} @@ -258,6 +294,7 @@ export async function eventRoutes(app:FastifyInstance): Promise { company: attendee.user.company, avatarUrl: attendee.user.avatarUrl, accentColor: attendee.user.accentColor, + role: attendee.role, })); const response: PaginatedAttendeesResponse = { diff --git a/apps/backend/src/validations/event.validation.ts b/apps/backend/src/validations/event.validation.ts index 0fc4044f..c182fe05 100644 --- a/apps/backend/src/validations/event.validation.ts +++ b/apps/backend/src/validations/event.validation.ts @@ -9,4 +9,6 @@ export const createEventSchema = z.object({ isPublic: z.boolean().default(true) }) -export const joinEventSchema = z.object({}) \ No newline at end of file +export const joinEventSchema = z.object({ + role: z.enum(['PARTICIPANT', 'ORGANIZER', 'MENTOR']).default('PARTICIPANT'), +}) \ No newline at end of file diff --git a/apps/web/README.md b/apps/web/README.md index ed8c987f..488e06c8 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -21,6 +21,31 @@ To run the tests: npm run test ``` +## Pages + +| Route | Page | Notes | +|-------|------|-------| +| `/` | Landing | Public | +| `/u/:username` | Public profile | Public | +| `/devcard/:id` | Shared card | Public | +| `/events/:slug` | Event / attendance | Lets a signed-in user **mark attendance** at a hackathon (as Participant / Organizer / Mentor) and leave again. Reading the event and attendee list is public. | +| `/leaderboard` | Contributor leaderboard | Public | + +### Authentication for attendance + +The web app has no login flow yet. Marking or leaving attendance calls the authenticated +backend endpoints (`POST /api/events/:slug/join`, `DELETE /api/events/:slug/leave`), so the +API client (`src/lib/api.ts`) attaches a JWT read from `localStorage` under the key +`devcard-token` when present. To exercise the flow locally, obtain a token from the backend +auth route and set it: + +```js +localStorage.setItem('devcard-token', ''); +``` + +Without a token, the "Mark attendance" button prompts the user to sign in. Anti-spam behavior +(rate limiting + heuristic flagging) is documented in the root [`README.md`](../../README.md#events--attendance). + ## Contributing The web app is a simple, clean, and informative landing page. If you'd like to add sections or improve the UI, make sure you maintain the minimal and professional design aesthetic. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8dd9dc07..3fa0a335 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom'; import LandingPage from './pages/LandingPage'; import ProfilePage from './pages/ProfilePage'; import CardPage from './pages/CardPage'; +import EventPage from './pages/EventPage'; import LeaderboardPage from './pages/LeaderboardPage'; import NotFound from './pages/NotFound'; @@ -11,6 +12,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index be6afdd8..15151aeb 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,16 +1,66 @@ const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; -export async function apiFetch(endpoint: string): Promise { +/** localStorage key holding the user's JWT. Set it manually for now — the web + * app has no login flow yet, so authenticated actions (e.g. marking event + * attendance) read the token from here when present. */ +export const AUTH_TOKEN_KEY = 'devcard-token'; + +export function getAuthToken(): string | null { + try { + return localStorage.getItem(AUTH_TOKEN_KEY); + } catch { + return null; + } +} + +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +interface ApiFetchOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + body?: unknown; + /** Attach the stored JWT as a Bearer token. Defaults to true for + * non-GET requests, false for GET (public endpoints). */ + auth?: boolean; +} + +export async function apiFetch( + endpoint: string, + options: ApiFetchOptions = {} +): Promise { + const method = options.method ?? 'GET'; + const useAuth = options.auth ?? method !== 'GET'; + + const headers: Record = { 'Content-Type': 'application/json' }; + if (useAuth) { + const token = getAuthToken(); + if (token) headers.Authorization = `Bearer ${token}`; + } + const response = await fetch(`${API_BASE_URL}${endpoint}`, { - headers: { 'Content-Type': 'application/json' }, + method, + headers, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, }); if (!response.ok) { const error = await response.json().catch(() => ({})); - throw new Error( - (error as Record)?.message ?? `Request failed: ${response.status}` + throw new ApiError( + (error as Record)?.error ?? + (error as Record)?.message ?? + `Request failed: ${response.status}`, + response.status ); } + // 204 No Content (e.g. leaving an event) has no JSON body. + if (response.status === 204) return undefined as T; + return response.json() as Promise; } diff --git a/apps/web/src/pages/EventPage.css b/apps/web/src/pages/EventPage.css new file mode 100644 index 00000000..3da716ff --- /dev/null +++ b/apps/web/src/pages/EventPage.css @@ -0,0 +1,210 @@ +.event-container { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + padding: clamp(2rem, 6vw, 5rem) 1.25rem 3rem; + opacity: 0; + transform: translateY(22px); + transition: opacity 0.65s ease, transform 0.65s ease; +} + +.event-container.loaded { + opacity: 1; + transform: translateY(0); +} + +.event-card { + width: 100%; + max-width: 620px; + border-radius: var(--radius-xl, 20px); + padding: 2.5rem 2rem; + box-shadow: 0 26px 60px -20px rgba(0, 0, 0, 0.55); + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 23, 42, 0.96); +} + +.event-header { + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding-bottom: 1.5rem; + margin-bottom: 1.5rem; +} + +.event-name { + font-size: clamp(1.6rem, 4vw, 2.2rem); + margin: 0 0 0.75rem; + line-height: 1.2; +} + +.event-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.25rem; + color: rgba(255, 255, 255, 0.72); + font-size: 0.95rem; +} + +.event-organizer { + margin: 0.85rem 0 0; + color: rgba(255, 255, 255, 0.6); + font-size: 0.9rem; +} + +.event-organizer a { + color: #818cf8; + text-decoration: none; +} + +.event-organizer a:hover { + text-decoration: underline; +} + +.event-description { + margin: 1rem 0 0; + color: rgba(255, 255, 255, 0.8); + line-height: 1.6; +} + +/* Attendance panel */ +.attendance-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.attendee-count { + font-size: 1rem; + color: rgba(255, 255, 255, 0.82); +} + +.attendee-count strong { + font-size: 1.35rem; + color: #fff; +} + +.attendance-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.role-label { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.7); +} + +.role-select { + padding: 0.6rem 0.75rem; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(2, 6, 23, 0.7); + color: #fff; + font-size: 0.95rem; +} + +.attendance-button { + padding: 0.65rem 1.4rem; + border-radius: 10px; + border: none; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: #fff; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: transform 0.15s ease, opacity 0.15s ease; +} + +.attendance-button:hover:not(:disabled) { + transform: translateY(-1px); +} + +.attendance-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.attendance-button.leave { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.4); + color: #fca5a5; +} + +.attendance-message { + font-size: 0.9rem; + margin: 0; +} + +.attendance-message.success { + color: #6ee7b7; +} + +.attendance-message.error { + color: #fca5a5; +} + +/* Attendees list */ +.attendees-list { + margin-top: 2rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 1.25rem; +} + +.attendees-list h2 { + font-size: 1.1rem; + margin: 0 0 0.85rem; +} + +.attendees-list ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.attendee-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.attendee-name { + color: #e2e8f0; + text-decoration: none; +} + +.attendee-name:hover { + text-decoration: underline; +} + +.role-badge { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.2rem 0.55rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.85); +} + +.role-badge.role-organizer { + background: rgba(251, 191, 36, 0.15); + border-color: rgba(251, 191, 36, 0.4); + color: #fcd34d; +} + +.role-badge.role-mentor { + background: rgba(139, 92, 246, 0.15); + border-color: rgba(139, 92, 246, 0.4); + color: #c4b5fd; +} + +.role-badge.role-participant { + background: rgba(99, 102, 241, 0.12); + border-color: rgba(99, 102, 241, 0.35); + color: #a5b4fc; +} diff --git a/apps/web/src/pages/EventPage.tsx b/apps/web/src/pages/EventPage.tsx new file mode 100644 index 00000000..e51be684 --- /dev/null +++ b/apps/web/src/pages/EventPage.tsx @@ -0,0 +1,274 @@ +import { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import type { AttendeeRole, EventAttendee, EventDetail } from '../shared'; +import { apiFetch, ApiError, getAuthToken } from '../lib/api'; +import './EventPage.css'; + +const ROLES: { value: AttendeeRole; label: string }[] = [ + { value: 'PARTICIPANT', label: 'Participant' }, + { value: 'ORGANIZER', label: 'Organizer' }, + { value: 'MENTOR', label: 'Mentor' }, +]; + +interface JoinResponse { + message: string; + role: AttendeeRole; + flagged: boolean; +} + +interface AttendeesResponse { + attendees: EventAttendee[]; + pagination: { page: number; limit: number; total: number }; +} + +function formatDateRange(start: string, end: string): string { + const opts: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + }; + const s = new Date(start).toLocaleDateString(undefined, opts); + const e = new Date(end).toLocaleDateString(undefined, opts); + return s === e ? s : `${s} – ${e}`; +} + +export default function EventPage() { + const { slug } = useParams<{ slug: string }>(); + const [event, setEvent] = useState(null); + const [attendees, setAttendees] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const [role, setRole] = useState('PARTICIPANT'); + const [attending, setAttending] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [message, setMessage] = useState(''); + const [messageStatus, setMessageStatus] = useState<'success' | 'error'>('success'); + + function loadAttendees(eventSlug: string) { + apiFetch(`/api/events/${eventSlug}/attendees`) + .then((data) => setAttendees(data.attendees)) + .catch(() => setAttendees([])); + } + + useEffect(() => { + if (!slug) return; + apiFetch(`/api/events/${slug}`) + .then((data) => { + setEvent(data); + setError(null); + loadAttendees(slug); + }) + .catch(() => { + setEvent(null); + setError('Event not found'); + }) + .finally(() => setLoading(false)); + }, [slug]); + + useEffect(() => { + if (event) { + document.title = `${event.name} | DevCard`; + } else if (error) { + document.title = 'Event Not Found | DevCard'; + } + }, [event, error]); + + function showMessage(text: string, status: 'success' | 'error') { + setMessage(text); + setMessageStatus(status); + } + + async function markAttendance() { + if (!slug || submitting) return; + if (!getAuthToken()) { + showMessage('You need to be signed in to mark attendance.', 'error'); + return; + } + setSubmitting(true); + try { + const res = await apiFetch(`/api/events/${slug}/join`, { + method: 'POST', + body: { role }, + }); + setAttending(true); + setEvent((prev) => + prev ? { ...prev, attendeesCount: prev.attendeesCount + 1 } : prev + ); + showMessage( + res.flagged + ? "Attendance marked — but you've joined a lot of events very quickly, so this was flagged for review." + : 'Attendance marked. See you there!', + 'success' + ); + loadAttendees(slug); + } catch (err) { + const status = err instanceof ApiError ? err.status : 0; + if (status === 409) { + setAttending(true); + showMessage("You're already marked as attending.", 'success'); + } else if (status === 401) { + showMessage('Your session has expired. Please sign in again.', 'error'); + } else if (status === 429) { + showMessage("You're doing that too fast. Please slow down and try again.", 'error'); + } else { + showMessage('Could not mark attendance. Please try again.', 'error'); + } + } finally { + setSubmitting(false); + } + } + + async function leaveEvent() { + if (!slug || submitting) return; + setSubmitting(true); + try { + await apiFetch(`/api/events/${slug}/leave`, { method: 'DELETE' }); + setAttending(false); + setEvent((prev) => + prev + ? { ...prev, attendeesCount: Math.max(0, prev.attendeesCount - 1) } + : prev + ); + showMessage('You are no longer marked as attending.', 'success'); + loadAttendees(slug); + } catch (err) { + const status = err instanceof ApiError ? err.status : 0; + if (status === 401) { + showMessage('Your session has expired. Please sign in again.', 'error'); + } else { + showMessage('Could not update attendance. Please try again.', 'error'); + } + } finally { + setSubmitting(false); + } + } + + if (loading) { + return ( + <> +
+
+
+
+
+
+
+
+ + ); + } + + if (error || !event) { + return ( + <> +
+
+
+
📅
+

Event not found

+

We couldn't find that event.

+ Return Home +
+
+ + ); + } + + return ( + <> +
+
+
+
+

{event.name}

+
+ 📍 {event.location} + + 🗓️ {formatDateRange(event.startDate, event.endDate)} + +
+

+ Organized by{' '} + + {event.organizerDisplayName} + +

+ {event.description &&

{event.description}

} +
+ +
+
+ {event.attendeesCount}{' '} + {event.attendeesCount === 1 ? 'person is' : 'people are'} attending +
+ + {attending ? ( + + ) : ( +
+ + + +
+ )} + + {message && ( +

+ {message} +

+ )} +
+ + {attendees.length > 0 && ( +
+

Attendees

+
    + {attendees.map((a) => ( +
  • + + {a.displayName} + + + {a.role.charAt(0) + a.role.slice(1).toLowerCase()} + +
  • + ))} +
+
+ )} +
+
+ + ); +} diff --git a/apps/web/src/shared/index.ts b/apps/web/src/shared/index.ts index a1531bcd..2e4b4170 100644 --- a/apps/web/src/shared/index.ts +++ b/apps/web/src/shared/index.ts @@ -1,3 +1,10 @@ export { PLATFORMS, getProfileUrl } from './platforms'; export type { PlatformDef } from './platforms'; -export type { PublicProfile, PublicCard, PlatformLink } from './types'; +export type { + PublicProfile, + PublicCard, + PlatformLink, + EventDetail, + EventAttendee, + AttendeeRole, +} from './types'; diff --git a/apps/web/src/shared/types.ts b/apps/web/src/shared/types.ts index 4c8ac276..139b462b 100644 --- a/apps/web/src/shared/types.ts +++ b/apps/web/src/shared/types.ts @@ -21,6 +21,35 @@ export interface PublicProfile { links: PlatformLink[]; } +export type AttendeeRole = 'PARTICIPANT' | 'ORGANIZER' | 'MENTOR'; + +export interface EventDetail { + id: string; + name: string; + slug: string; + location: string; + description: string | null; + organizerId: string; + organizerUsername: string; + organizerDisplayName: string; + startDate: string; + endDate: string; + createdAt: string; + attendeesCount: number; +} + +export interface EventAttendee { + id: string; + username: string; + displayName: string; + bio: string | null; + pronouns: string | null; + company: string | null; + avatarUrl: string | null; + accentColor: string; + role: AttendeeRole; +} + export interface PublicCard { title: string; owner: { diff --git a/apps/web/test/EventPage.test.tsx b/apps/web/test/EventPage.test.tsx new file mode 100644 index 00000000..31955ec3 --- /dev/null +++ b/apps/web/test/EventPage.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ThemeProvider } from '../src/lib/theme'; + +// ─── Mock the API layer ────────────────────────────────────────────────────── +const apiFetch = vi.fn(); +const getAuthToken = vi.fn(); + +vi.mock('../src/lib/api', () => ({ + apiFetch: (...args: unknown[]) => apiFetch(...args), + getAuthToken: () => getAuthToken(), + ApiError: class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } + }, +})); + +import EventPage from '../src/pages/EventPage'; + +const MOCK_EVENT = { + id: 'event-1', + name: 'DevCard Hack 2026', + slug: 'devcard-hack-2026', + location: 'Remote', + description: 'A weekend of building.', + organizerId: 'org-1', + organizerUsername: 'janedoe', + organizerDisplayName: 'Jane Doe', + startDate: '2026-09-01T09:00:00Z', + endDate: '2026-09-02T18:00:00Z', + createdAt: '2026-01-01T00:00:00Z', + attendeesCount: 3, +}; + +/** Routes apiFetch calls to canned responses based on endpoint + method. */ +function defaultApiFetch(endpoint: string, options?: { method?: string; body?: unknown }) { + const method = options?.method ?? 'GET'; + if (endpoint === '/api/events/devcard-hack-2026' && method === 'GET') { + return Promise.resolve(MOCK_EVENT); + } + if (endpoint.endsWith('/attendees')) { + return Promise.resolve({ attendees: [], pagination: { page: 1, limit: 10, total: 0 } }); + } + if (endpoint.endsWith('/join') && method === 'POST') { + return Promise.resolve({ message: 'User joined successfully', role: 'PARTICIPANT', flagged: false }); + } + return Promise.resolve({}); +} + +function renderPage() { + return render( + + + + } /> + + + + ); +} + +describe('EventPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiFetch.mockImplementation(defaultApiFetch); + getAuthToken.mockReturnValue('mock-jwt'); + }); + + it('renders event details after fetching', async () => { + renderPage(); + expect(await screen.findByText('DevCard Hack 2026')).toBeInTheDocument(); + expect(screen.getByText(/Remote/)).toBeInTheDocument(); + expect(screen.getByText(/3/)).toBeInTheDocument(); + expect(screen.getByText(/people are/i)).toBeInTheDocument(); + }); + + it('marks attendance with the selected role and updates the UI', async () => { + renderPage(); + await screen.findByText('DevCard Hack 2026'); + + // Choose a non-default role. + fireEvent.change(screen.getByLabelText(/attending as/i), { + target: { value: 'MENTOR' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /mark attendance/i })); + + await waitFor(() => { + expect(apiFetch).toHaveBeenCalledWith('/api/events/devcard-hack-2026/join', { + method: 'POST', + body: { role: 'MENTOR' }, + }); + }); + + // Success message + attendance state flips to "Leave event". + expect(await screen.findByText(/Attendance marked/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /leave event/i })).toBeInTheDocument(); + }); + + it('prompts to sign in when no auth token is present', async () => { + getAuthToken.mockReturnValue(null); + renderPage(); + await screen.findByText('DevCard Hack 2026'); + + fireEvent.click(screen.getByRole('button', { name: /mark attendance/i })); + + expect(await screen.findByText(/need to be signed in/i)).toBeInTheDocument(); + // No join request should have been sent. + expect(apiFetch).not.toHaveBeenCalledWith( + '/api/events/devcard-hack-2026/join', + expect.anything() + ); + }); + + it('shows a not-found state when the event is missing', async () => { + apiFetch.mockImplementation((endpoint: string) => { + if (endpoint === '/api/events/devcard-hack-2026') { + return Promise.reject(new Error('Event not found')); + } + return Promise.resolve({ attendees: [], pagination: { page: 1, limit: 10, total: 0 } }); + }); + + renderPage(); + expect(await screen.findByText('Event not found')).toBeInTheDocument(); + }); +});