Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
13 changes: 11 additions & 2 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
113 changes: 108 additions & 5 deletions apps/backend/src/__tests__/event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const prismaMock = {
eventAttendee: {
create: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
};

Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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(),
});

Expand All @@ -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 () => {
Expand Down Expand Up @@ -488,17 +586,20 @@ 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;
} {
return {
id: `attendee-${profile.id}`,
userId: profile.id,
eventId: MOCK_EVENT.id,
role,
joinedAt: new Date(),
user: { ...profile },
};
Expand Down Expand Up @@ -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 },
});

Expand All @@ -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 () => {
Expand Down
Loading