Skip to content

Commit 4e45c06

Browse files
committed
feat: attendance marking + spam prevention
1 parent b0a6223 commit 4e45c06

14 files changed

Lines changed: 948 additions & 27 deletions

File tree

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,39 @@ The following error cases are implemented:
278278
| **Set Default Card** | 404 | `{ error: 'Card not found' }` — when card ID doesn't exist or doesn't belong to authenticated user |
279279
| **Successful Deletion** | 204 | No content |
280280

281+
## Events & Attendance
282+
283+
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.
284+
285+
| Method | Endpoint | Auth | Description |
286+
|--------|----------|------|-------------|
287+
| `GET` | `/api/events/:slug` | No | Event details + attendee count |
288+
| `GET` | `/api/events/:slug/attendees` | No | Paginated attendee list, each with a public `role` |
289+
| `POST` | `/api/events/:slug/join` | Yes | Mark attendance. Body: `{ "role": "PARTICIPANT" \| "ORGANIZER" \| "MENTOR" }` (optional, defaults to `PARTICIPANT`). Returns `{ message, role, flagged }` |
290+
| `DELETE` | `/api/events/:slug/leave` | Yes | Remove your attendance |
291+
292+
```jsonc
293+
// POST /api/events/devcard-hack-2026/join
294+
{ "role": "MENTOR" }
295+
// → 201 { "message": "User joined successfully", "role": "MENTOR", "flagged": false }
296+
```
297+
298+
| Scenario | Status | Response |
299+
|----------|--------|----------|
300+
| **Join** | 400 | `{ error: 'Bad request' }` — invalid `role` |
301+
| **Join / Leave** | 404 | `{ error: 'Event not found' }` |
302+
| **Join** | 409 | `{ error: 'Already joined' }` — attendance already marked |
303+
| **Join** | 429 | Rate limited (see below) |
304+
| **Leave** | 404 | `{ error: 'User not found' }` — not currently attending |
305+
| **Leave** | 204 | No content |
306+
307+
### Attendance spam rules
308+
309+
To keep normal sign-ups frictionless while catching mass-marking ("marking every hackathon without attending"), two independent guardrails run on the join route:
310+
311+
- **Rate limit (hard block):** the join route is capped at **10 requests/minute**; excess requests are rejected with **HTTP 429**. This stops scripted retries.
312+
- **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`.
313+
281314
## Good First Issues
282315

283316
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:
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- CreateEnum
2+
CREATE TYPE "AttendeeRole" AS ENUM ('PARTICIPANT', 'ORGANIZER', 'MENTOR');
3+
4+
-- AlterTable
5+
ALTER TABLE "event_attendees" ADD COLUMN "role" "AttendeeRole" NOT NULL DEFAULT 'PARTICIPANT',
6+
ADD COLUMN "flagged" BOOLEAN NOT NULL DEFAULT false,
7+
ALTER COLUMN "joinedAt" SET DEFAULT CURRENT_TIMESTAMP;
8+
9+
-- CreateIndex
10+
CREATE INDEX "event_attendees_userId_joinedAt_idx" ON "event_attendees"("userId", "joinedAt");

apps/backend/prisma/schema.prisma

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,16 +236,25 @@ model Event {
236236
@@map("events")
237237
}
238238

239+
enum AttendeeRole {
240+
PARTICIPANT
241+
ORGANIZER
242+
MENTOR
243+
}
244+
239245
model EventAttendee {
240-
id String @id @default(uuid())
246+
id String @id @default(uuid())
241247
userId String
242248
eventId String
243-
joinedAt DateTime
249+
role AttendeeRole @default(PARTICIPANT)
250+
flagged Boolean @default(false)
251+
joinedAt DateTime @default(now())
244252
245253
event Event @relation(fields: [eventId], references: [id])
246254
user User @relation(fields: [userId], references: [id])
247255
248256
@@unique([userId, eventId])
257+
@@index([userId, joinedAt])
249258
@@map("event_attendees")
250259
}
251260

apps/backend/src/__tests__/event.test.ts

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const prismaMock = {
5656
eventAttendee: {
5757
create: vi.fn(),
5858
delete: vi.fn(),
59+
count: vi.fn(),
5960
},
6061
};
6162

@@ -125,6 +126,8 @@ describe('Events API', () => {
125126
vi.clearAllMocks();
126127
// Default: authenticated as MOCK_USER_ID
127128
mockJwtVerify.mockResolvedValue({ id: MOCK_USER_ID });
129+
// Default: user has no recent joins (spam heuristic inactive).
130+
prismaMock.eventAttendee.count.mockResolvedValue(0);
128131
app = await buildApp();
129132
});
130133

@@ -314,12 +317,14 @@ describe('Events API', () => {
314317
// ── POST /api/events/:slug/join ────────────────────────────────────────────
315318

316319
describe('POST /api/events/:slug/join — join event', () => {
317-
it('201 — authenticated user joins an existing event', async () => {
320+
it('201 — authenticated user joins an existing event (defaults to PARTICIPANT)', async () => {
318321
prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT);
319322
prismaMock.eventAttendee.create.mockResolvedValue({
320323
id: 'attendee-uuid-001',
321324
userId: MOCK_OTHER_USER_ID,
322325
eventId: MOCK_EVENT.id,
326+
role: 'PARTICIPANT',
327+
flagged: false,
323328
joinedAt: new Date(),
324329
});
325330

@@ -332,11 +337,104 @@ describe('Events API', () => {
332337
});
333338

334339
expect(res.statusCode).toBe(201);
335-
expect(res.json()).toMatchObject({ message: 'User joined successfully' });
340+
expect(res.json()).toMatchObject({
341+
message: 'User joined successfully',
342+
role: 'PARTICIPANT',
343+
flagged: false,
344+
});
336345

337346
const callData = prismaMock.eventAttendee.create.mock.calls[0][0].data;
338347
expect(callData.eventId).toBe(MOCK_EVENT.id);
339348
expect(callData.userId).toBe(MOCK_OTHER_USER_ID);
349+
expect(callData.role).toBe('PARTICIPANT');
350+
expect(callData.flagged).toBe(false);
351+
});
352+
353+
it('201 — persists the chosen attendee role (ORGANIZER/MENTOR)', async () => {
354+
prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT);
355+
prismaMock.eventAttendee.create.mockResolvedValue({
356+
id: 'attendee-uuid-002',
357+
userId: MOCK_USER_ID,
358+
eventId: MOCK_EVENT.id,
359+
role: 'MENTOR',
360+
flagged: false,
361+
joinedAt: new Date(),
362+
});
363+
364+
const res = await app.inject({
365+
method: 'POST',
366+
url: '/api/events/devcard-conf-2025/join',
367+
headers: authHeader(),
368+
payload: { role: 'MENTOR' },
369+
});
370+
371+
expect(res.statusCode).toBe(201);
372+
const callData = prismaMock.eventAttendee.create.mock.calls[0][0].data;
373+
expect(callData.role).toBe('MENTOR');
374+
});
375+
376+
it('400 — rejects an invalid attendee role', async () => {
377+
prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT);
378+
379+
const res = await app.inject({
380+
method: 'POST',
381+
url: '/api/events/devcard-conf-2025/join',
382+
headers: authHeader(),
383+
payload: { role: 'SUPERHERO' },
384+
});
385+
386+
expect(res.statusCode).toBe(400);
387+
expect(prismaMock.eventAttendee.create).not.toHaveBeenCalled();
388+
});
389+
390+
it('flags the join when the user has joined too many events recently', async () => {
391+
prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT);
392+
// Simulate a burst: user already joined the max in the window.
393+
prismaMock.eventAttendee.count.mockResolvedValue(8);
394+
prismaMock.eventAttendee.create.mockResolvedValue({
395+
id: 'attendee-uuid-003',
396+
userId: MOCK_USER_ID,
397+
eventId: MOCK_EVENT.id,
398+
role: 'PARTICIPANT',
399+
flagged: true,
400+
joinedAt: new Date(),
401+
});
402+
403+
const res = await app.inject({
404+
method: 'POST',
405+
url: '/api/events/devcard-conf-2025/join',
406+
headers: authHeader(),
407+
});
408+
409+
// Join still succeeds (soft flag, no friction)...
410+
expect(res.statusCode).toBe(201);
411+
expect(res.json()).toMatchObject({ flagged: true });
412+
// ...but the record is persisted as flagged for review.
413+
const callData = prismaMock.eventAttendee.create.mock.calls[0][0].data;
414+
expect(callData.flagged).toBe(true);
415+
});
416+
417+
it('does NOT flag a normal join below the spam threshold', async () => {
418+
prismaMock.event.findUnique.mockResolvedValue(MOCK_EVENT);
419+
prismaMock.eventAttendee.count.mockResolvedValue(2);
420+
prismaMock.eventAttendee.create.mockResolvedValue({
421+
id: 'attendee-uuid-004',
422+
userId: MOCK_USER_ID,
423+
eventId: MOCK_EVENT.id,
424+
role: 'PARTICIPANT',
425+
flagged: false,
426+
joinedAt: new Date(),
427+
});
428+
429+
const res = await app.inject({
430+
method: 'POST',
431+
url: '/api/events/devcard-conf-2025/join',
432+
headers: authHeader(),
433+
});
434+
435+
expect(res.statusCode).toBe(201);
436+
const callData = prismaMock.eventAttendee.create.mock.calls[0][0].data;
437+
expect(callData.flagged).toBe(false);
340438
});
341439

342440
it('401 — rejects unauthenticated request', async () => {
@@ -488,17 +586,20 @@ describe('Events API', () => {
488586
/** Builds a raw EventAttendee row as Prisma returns it (with nested user) */
489587
function makeAttendeeRow(
490588
profile: typeof MOCK_USER_PROFILE | typeof MOCK_OTHER_USER_PROFILE,
589+
role: 'PARTICIPANT' | 'ORGANIZER' | 'MENTOR' = 'PARTICIPANT',
491590
) : {
492591
id: string;
493592
userId: string;
494593
eventId: string;
594+
role: string;
495595
joinedAt: Date;
496596
user: typeof MOCK_USER_PROFILE | typeof MOCK_OTHER_USER_PROFILE;
497597
} {
498598
return {
499599
id: `attendee-${profile.id}`,
500600
userId: profile.id,
501601
eventId: MOCK_EVENT.id,
602+
role,
502603
joinedAt: new Date(),
503604
user: { ...profile },
504605
};
@@ -613,10 +714,10 @@ describe('Events API', () => {
613714
expect(body.pagination.total).toBe(0);
614715
});
615716

616-
it('200 — public profiles do not leak sensitive fields', async () => {
717+
it('200 — exposes the attendee role but not sensitive fields', async () => {
617718
prismaMock.event.findUnique.mockResolvedValue({
618719
...MOCK_EVENT,
619-
attendees: [makeAttendeeRow(MOCK_USER_PROFILE)],
720+
attendees: [makeAttendeeRow(MOCK_USER_PROFILE, 'ORGANIZER')],
620721
_count: { attendees: 1 },
621722
});
622723

@@ -632,12 +733,14 @@ describe('Events API', () => {
632733
expect(attendee).toHaveProperty('username');
633734
expect(attendee).toHaveProperty('displayName');
634735
expect(attendee).toHaveProperty('accentColor');
736+
// The attendance role is public and drives the UI badge.
737+
expect(attendee.role).toBe('ORGANIZER');
635738

636739
// These fields MUST NOT be present
637740
expect(attendee).not.toHaveProperty('email');
638741
expect(attendee).not.toHaveProperty('provider');
639742
expect(attendee).not.toHaveProperty('providerId');
640-
expect(attendee).not.toHaveProperty('role');
743+
expect(attendee).not.toHaveProperty('flagged');
641744
});
642745

643746
it('404 — returns 404 for unknown event slug', async () => {

0 commit comments

Comments
 (0)