diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md new file mode 100644 index 0000000..f1f50a8 --- /dev/null +++ b/shatter-backend/docs/API_REFERENCE.md @@ -0,0 +1,1270 @@ +# Shatter Backend — API Reference + +**Last updated:** 2026-03-01 +**Base URL:** `http://localhost:4000/api` + +--- + +## Table of Contents + +- [General Information](#general-information) +- [Endpoint Summary](#endpoint-summary) +- [Authentication (`/api/auth`)](#authentication-apiauth) + - [POST /api/auth/signup](#post-apiauthsignup) + - [POST /api/auth/login](#post-apiauthlogin) + - [GET /api/auth/linkedin](#get-apiauthlinkedin) + - [GET /api/auth/linkedin/callback](#get-apiauthlinkedincallback) + - [POST /api/auth/exchange](#post-apiauthexchange) +- [Users (`/api/users`)](#users-apiusers) + - [GET /api/users](#get-apiusers) + - [POST /api/users](#post-apiusers) + - [GET /api/users/me](#get-apiusersme) + - [GET /api/users/:userId](#get-apiusersuserid) + - [GET /api/users/:userId/events](#get-apiusersuseриdevents) + - [PUT /api/users/:userId](#put-apiusersuserid) +- [Events (`/api/events`)](#events-apievents) + - [POST /api/events/createEvent](#post-apieventscreateevent) + - [GET /api/events/event/:joinCode](#get-apieventseventjoincode) + - [GET /api/events/:eventId](#get-apieventseventid) + - [PUT /api/events/:eventId/status](#put-apieventseventidstatus) + - [POST /api/events/:eventId/join/user](#post-apieventseventиdjoinuser) + - [POST /api/events/:eventId/join/guest](#post-apieventseventиdjoinguest) + - [GET /api/events/createdEvents/user/:userId](#get-apieventscreatedeventsuseriduserid) +- [Bingo (`/api/bingo`)](#bingo-apibingo) + - [POST /api/bingo/createBingo](#post-apibingocreatebingo) + - [GET /api/bingo/getBingo/:eventId](#get-apibingogetbingoeventid) + - [PUT /api/bingo/updateBingo](#put-apibingoupdatebingo) +- [Participant Connections (`/api/participantConnections`)](#participant-connections-apiparticipantconnections) + - [POST /api/participantConnections/](#post-apiparticipantconnections) + - [POST /api/participantConnections/by-emails](#post-apiparticipantconnectionsby-emails) + - [DELETE /api/participantConnections/delete](#delete-apiparticipantconnectionsdelete) + - [GET /api/participantConnections/getByParticipantAndEvent](#get-apiparticipantconnectionsgetbyparticipantandevent) + - [GET /api/participantConnections/getByUserEmailAndEvent](#get-apiparticipantconnectionsgetbyuseremailandevent) + - [GET /api/participantConnections/connected-users](#get-apiparticipantconnectionsconnected-users) +- [Planned Endpoints](#planned-endpoints-) +- [Quick Start Examples](#quick-start-examples) + +--- + +## Endpoint Summary + +Quick reference of all implemented endpoints. See detailed sections below for request/response shapes. + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/api/auth/signup` | Public | Create new user account | +| POST | `/api/auth/login` | Public | Log in with email + password | +| GET | `/api/auth/linkedin` | Public | Initiate LinkedIn OAuth flow | +| GET | `/api/auth/linkedin/callback` | Public | LinkedIn OAuth callback (not called directly) | +| POST | `/api/auth/exchange` | Public | Exchange OAuth auth code for JWT | +| GET | `/api/users` | Public | List all users | +| POST | `/api/users` | Public | Create basic user (name + email) | +| GET | `/api/users/me` | Protected | Get current user's profile | +| GET | `/api/users/:userId` | Protected | Get user by ID | +| GET | `/api/users/:userId/events` | Protected | Get user's joined events | +| PUT | `/api/users/:userId` | Protected | Update user profile (self-only) | +| POST | `/api/events/createEvent` | Protected | Create a new event | +| GET | `/api/events/event/:joinCode` | Public | Get event by join code | +| GET | `/api/events/:eventId` | Public | Get event by ID | +| PUT | `/api/events/:eventId/status` | Protected | Update event lifecycle status (host-only) | +| POST | `/api/events/:eventId/join/user` | Protected | Join event as authenticated user | +| POST | `/api/events/:eventId/join/guest` | Public | Join event as guest | +| GET | `/api/events/createdEvents/user/:userId` | Protected | Get events created by user | +| POST | `/api/bingo/createBingo` | Protected | Create bingo game for event | +| GET | `/api/bingo/getBingo/:eventId` | Public | Get bingo by event ID | +| PUT | `/api/bingo/updateBingo` | Protected | Update bingo game | +| POST | `/api/participantConnections/` | Protected | Create connection by participant IDs | +| POST | `/api/participantConnections/by-emails` | Protected | Create connection by user emails | +| DELETE | `/api/participantConnections/delete` | Protected | Delete a connection | +| GET | `/api/participantConnections/getByParticipantAndEvent` | Protected | Get connections by participant + event | +| GET | `/api/participantConnections/getByUserEmailAndEvent` | Protected | Get connections by email + event | +| GET | `/api/participantConnections/connected-users` | Protected | Get connected users' info by participant + event | + +--- + +## General Information + +### Authentication + +Protected endpoints require a JWT token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +Tokens are obtained via `/api/auth/signup`, `/api/auth/login`, `/api/auth/exchange`, or the guest join flow. + +Token payload: `{ userId, iat, exp }` — expires in 30 days by default. + +### Response Format + +> **Known inconsistency:** The API currently uses **three different error response shapes** depending on the controller. Frontend code should handle all three formats. This is a known issue and may be standardized in a future refactor. + +**Format 1** — Auth endpoints (`/api/auth/*`): +```json +{ "error": "Description of the error" } +``` + +**Format 2** — User, Event, Bingo endpoints (`success` + `error`): +```json +{ "success": false, "error": "Description" } +``` + +**Format 3** — Event join endpoints (`success` + `msg`): +```json +{ "success": false, "msg": "Description" } +``` + +**Recommended client-side handling:** +```js +// Extract error message from any response format +const getErrorMessage = (data) => data.error || data.msg || 'Unknown error'; +``` + +### Common Status Codes + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 201 | Created | +| 400 | Bad request / validation error | +| 401 | Unauthorized / invalid credentials | +| 403 | Forbidden (e.g., updating another user's profile) | +| 404 | Resource not found | +| 409 | Conflict (duplicate resource) | +| 500 | Internal server error | + +--- + +## Authentication (`/api/auth`) + +### POST `/api/auth/signup` + +Create a new user account. + +- **Auth:** Public + +**Request Body:** + +| Field | Type | Required | Notes | +|------------|--------|----------|-------| +| `name` | string | Yes | Display name | +| `email` | string | Yes | Must be valid email format | +| `password` | string | Yes | Minimum 8 characters | + +**Success Response (201):** + +```json +{ + "message": "User created successfully", + "userId": "664f1a2b3c4d5e6f7a8b9c0d", + "token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"name, email and password are required"` | +| 400 | `"Invalid email format"` | +| 400 | `"Password must be at least 8 characters long"` | +| 409 | `"Email already exists"` | + +--- + +### POST `/api/auth/login` + +Authenticate a user and return a JWT. + +- **Auth:** Public + +**Request Body:** + +| Field | Type | Required | +|------------|--------|----------| +| `email` | string | Yes | +| `password` | string | Yes | + +**Success Response (200):** + +```json +{ + "message": "Login successful", + "userId": "664f1a2b3c4d5e6f7a8b9c0d", + "token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Email and password are required"` | +| 400 | `"Invalid email format"` | +| 401 | `"Invalid credentials"` (wrong email or password — same message for security) | +| 401 | `"This account uses LinkedIn login. Please sign in with LinkedIn."` | + +**Special Behavior:** Updates `lastLogin` timestamp on successful login. + +--- + +### GET `/api/auth/linkedin` + +Initiate LinkedIn OAuth flow. Redirects the browser to LinkedIn's authorization page. + +- **Auth:** Public +- **Response:** 302 redirect to LinkedIn + +--- + +### GET `/api/auth/linkedin/callback` + +LinkedIn OAuth callback. Not called directly by frontend — LinkedIn redirects here after user authorization. + +- **Auth:** Public (called by LinkedIn) +- **Flow:** Verifies CSRF state → exchanges code for access token → fetches LinkedIn profile → upserts user → creates single-use auth code → redirects to frontend with `?code=` + +**Redirect on success:** `{FRONTEND_URL}/auth/callback?code=` +**Redirect on error:** `{FRONTEND_URL}/auth/error?message=` + +--- + +### POST `/api/auth/exchange` + +Exchange a single-use auth code (from LinkedIn OAuth callback) for a JWT token. + +- **Auth:** Public + +**Request Body:** + +| Field | Type | Required | Notes | +|--------|--------|----------|-------| +| `code` | string | Yes | Single-use auth code from OAuth redirect | + +**Success Response (200):** + +```json +{ + "message": "Authentication successful", + "userId": "664f1a2b3c4d5e6f7a8b9c0d", + "token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Auth code is required"` | +| 401 | `"Invalid or expired auth code"` | + +**Special Behavior:** Auth code is atomically deleted after use (single-use). Codes expire after 60 seconds via MongoDB TTL. + +--- + +## Users (`/api/users`) + +### GET `/api/users` + +List all users. + +- **Auth:** Public + +**Success Response (200):** + +```json +[ + { + "_id": "664f1a2b3c4d5e6f7a8b9c0d", + "name": "John Doe", + "email": "john@example.com", + "authProvider": "local", + "eventHistoryIds": [], + "createdAt": "2025-01-15T10:30:00.000Z", + "updatedAt": "2025-01-15T10:30:00.000Z" + } +] +``` + +**Note:** `passwordHash` is excluded from results by default. + +--- + +### POST `/api/users` + +Create a basic user (name + email only, no password). + +- **Auth:** Public + +**Request Body:** + +| Field | Type | Required | +|---------|--------|----------| +| `name` | string | Yes | +| `email` | string | Yes | + +**Success Response (201):** + +```json +{ + "_id": "664f1a2b3c4d5e6f7a8b9c0d", + "name": "John Doe", + "email": "john@example.com", + "authProvider": "local", + "eventHistoryIds": [], + "createdAt": "2025-01-15T10:30:00.000Z", + "updatedAt": "2025-01-15T10:30:00.000Z" +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"name and email required"` | +| 409 | `"email already exists"` | + +--- + +### GET `/api/users/me` + +Get the currently authenticated user's profile. + +- **Auth:** Protected + +**Success Response (200):** + +```json +{ + "_id": "664f1a2b3c4d5e6f7a8b9c0d", + "name": "John Doe", + "email": "john@example.com", + "authProvider": "local", + "bio": "Software developer", + "socialLinks": { "github": "https://github.com/johndoe" }, + "eventHistoryIds": ["665a..."], + "createdAt": "2025-01-15T10:30:00.000Z", + "updatedAt": "2025-01-15T10:30:00.000Z" +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 401 | Unauthorized (missing/invalid token) | +| 404 | `"User not found"` | + +--- + +### GET `/api/users/:userId` + +Get a user by their ID. + +- **Auth:** Protected + +**URL Params:** + +| Param | Type | Required | +|----------|----------|----------| +| `userId` | ObjectId | Yes | + +**Success Response (200):** + +```json +{ + "success": true, + "user": { + "_id": "664f1a2b3c4d5e6f7a8b9c0d", + "name": "John Doe", + "email": "john@example.com", + "authProvider": "local", + "eventHistoryIds": [], + "createdAt": "2025-01-15T10:30:00.000Z", + "updatedAt": "2025-01-15T10:30:00.000Z" + } +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 404 | `"User not found"` | + +--- + +### GET `/api/users/:userId/events` + +Get all events a user has joined (populates event details). + +- **Auth:** Protected + +**URL Params:** + +| Param | Type | Required | +|----------|----------|----------| +| `userId` | ObjectId | Yes | + +**Success Response (200):** + +```json +{ + "success": true, + "events": [ + { + "_id": "665a...", + "name": "Tech Meetup", + "description": "Monthly networking event", + "joinCode": "12345678", + "startDate": "2025-02-01T18:00:00.000Z", + "endDate": "2025-02-01T21:00:00.000Z", + "currentState": "In Progress" + } + ] +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 404 | `"User not found"` | + +--- + +### PUT `/api/users/:userId` + +Update a user's profile. Users can only update their own profile. + +- **Auth:** Protected (self-only) + +**URL Params:** + +| Param | Type | Required | +|----------|----------|----------| +| `userId` | ObjectId | Yes | + +**Request Body (all optional):** + +| Field | Type | Notes | +|----------------|--------|-------| +| `name` | string | Cannot be empty | +| `email` | string | Must be valid format, checked for uniqueness | +| `password` | string | Minimum 8 characters | +| `bio` | string | | +| `profilePhoto` | string | URL | +| `socialLinks` | object | `{ linkedin?, github?, other? }` | + +**Success Response (200):** + +```json +{ + "success": true, + "user": { /* updated user object */ } +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"No fields to update"` | +| 400 | `"Name cannot be empty"` | +| 400 | `"Invalid email format"` | +| 400 | `"Password must be at least 8 characters long"` | +| 403 | `"You can only update your own profile"` | +| 404 | `"User not found"` | +| 409 | `"Email already in use"` | + +**Special Behavior:** Guest users who set a password are automatically upgraded to `authProvider: 'local'`. + +--- + +## Events (`/api/events`) + +### POST `/api/events/createEvent` + +Create a new event. + +- **Auth:** Protected + +**Request Body:** + +| Field | Type | Required | Notes | +|------------------|--------|----------|-------| +| `name` | string | Yes | | +| `description` | string | Yes | Required by schema | +| `gameType` | string | Yes | Must be `"Name Bingo"` | +| `startDate` | string | Yes | ISO 8601 date | +| `endDate` | string | Yes | Must be after `startDate` | +| `maxParticipant` | number | Yes | | +| `currentState` | string | No | One of: `"Upcoming"`, `"In Progress"`, `"Completed"`. Defaults to `"Upcoming"` | +| `eventImg` | string | No | URL for event image | + +**Success Response (201):** + +```json +{ + "success": true, + "event": { + "_id": "665a...", + "name": "Tech Meetup", + "description": "Monthly networking event", + "gameType": "Name Bingo", + "joinCode": "48291037", + "startDate": "2025-02-01T18:00:00.000Z", + "endDate": "2025-02-01T21:00:00.000Z", + "maxParticipant": 50, + "participantIds": [], + "currentState": "Upcoming", + "eventImg": "https://example.com/event-image.jpg", + "createdBy": "664f...", + "createdAt": "2025-01-20T12:00:00.000Z", + "updatedAt": "2025-01-20T12:00:00.000Z" + } +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Event name is required"` | +| 404 | `"User not found"` | + +**Special Behavior:** An 8-digit `joinCode` is auto-generated. + +--- + +### GET `/api/events/event/:joinCode` + +Get event details by join code. + +- **Auth:** Public + +**URL Params:** + +| Param | Type | Required | +|------------|--------|----------| +| `joinCode` | string | Yes | + +**Success Response (200):** + +```json +{ + "success": true, + "event": { + "_id": "665a...", + "name": "Tech Meetup", + "description": "Monthly networking event", + "joinCode": "48291037", + "participantIds": [ + { "_id": "666b...", "name": "John Doe", "userId": "664f..." } + ], + "currentState": "Upcoming", + "gameType": "Name Bingo", + "createdBy": "664f...", + ... + } +} +``` + +**Note:** `participantIds` is populated with `name` and `userId` fields. + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 404 | `"Event not found"` | + +--- + +### GET `/api/events/:eventId` + +Get event details by event ID. + +- **Auth:** Public + +**URL Params:** + +| Param | Type | Required | +|-----------|----------|----------| +| `eventId` | ObjectId | Yes | + +**Success Response (200):** + +```json +{ + "success": true, + "event": { + "_id": "665a...", + "name": "Tech Meetup", + "participantIds": [ + { "_id": "666b...", "name": "John Doe", "userId": "664f..." } + ], + ... + } +} +``` + +**Note:** `participantIds` is populated with `name` and `userId` fields. + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 404 | `"Event not found"` | + +--- + +### PUT `/api/events/:eventId/status` + +Update an event's lifecycle status. Only the event host (creator) can change the status. + +- **Auth:** Protected (host-only — `event.createdBy` must match authenticated user) + +**URL Params:** + +| Param | Type | Required | +|-----------|----------|----------| +| `eventId` | ObjectId | Yes | + +**Request Body:** + +| Field | Type | Required | Notes | +|----------|--------|----------|-------| +| `status` | string | Yes | Target status. One of: `"In Progress"`, `"Completed"` | + +**Valid Transitions:** + +| From | To | +|---------------|----------------| +| `Upcoming` | `In Progress` | +| `In Progress` | `Completed` | + +**Success Response (200):** + +```json +{ + "success": true, + "event": { + "_id": "665a...", + "name": "Tech Meetup", + "currentState": "In Progress", + ... + } +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Status is required"` | +| 400 | `"Invalid status transition from to "` | +| 403 | `"Only the event host can update the event status"` | +| 404 | `"Event not found"` | + +**Side Effects:** +- **Upcoming → In Progress:** Emits Pusher event `event-started` on channel `event-{eventId}` with payload `{ status: 'In Progress' }` +- **In Progress → Completed:** Emits Pusher event `event-ended` on channel `event-{eventId}` with payload `{ status: 'Completed' }` + +--- + +### POST `/api/events/:eventId/join/user` + +Join an event as a registered (authenticated) user. + +- **Auth:** Protected + +**URL Params:** + +| Param | Type | Required | +|-----------|----------|----------| +| `eventId` | ObjectId | Yes | + +**Request Body:** + +| Field | Type | Required | Notes | +|----------|----------|----------|-------| +| `userId` | ObjectId | Yes | | +| `name` | string | Yes | Display name in this event | + +**Success Response (200):** + +```json +{ + "success": true, + "participant": { + "_id": "666b...", + "userId": "664f...", + "name": "John Doe", + "eventId": "665a..." + } +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Missing fields: userId, name, and eventId are required"` | +| 400 | `"Event is full"` | +| 400 | `"Already joined this event"` | +| 404 | `"User not found"` | +| 404 | `"Event not found"` | +| 409 | `"User already joined"` | +| 409 | `"This name is already taken in this event"` | + +**Special Behavior:** +- Creates a Participant record linking user to event +- Adds participant to event's `participantIds` array +- Adds event to user's `eventHistoryIds` array +- Triggers Pusher event `participant-joined` on channel `event-{eventId}` with payload `{ participantId, name }` + +--- + +### POST `/api/events/:eventId/join/guest` + +Join an event as a guest (no account required). + +- **Auth:** Public + +**URL Params:** + +| Param | Type | Required | +|-----------|----------|----------| +| `eventId` | ObjectId | Yes | + +**Request Body:** + +| Field | Type | Required | +|--------|--------|----------| +| `name` | string | Yes | + +**Success Response (200):** + +```json +{ + "success": true, + "participant": { + "_id": "666b...", + "userId": "664f...", + "name": "Guest User", + "eventId": "665a..." + }, + "userId": "664f...", + "token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Missing fields: guest name and eventId are required"` | +| 400 | `"Event is full"` | +| 404 | `"Event not found"` | +| 409 | `"This name is already taken in this event"` | + +**Special Behavior:** +- Creates a guest User (`authProvider: 'guest'`, no email/password) +- Returns a JWT so the guest can make authenticated requests +- Guest can later upgrade to a full account via `PUT /api/users/:userId` +- Triggers Pusher event `participant-joined` on channel `event-{eventId}` with payload `{ participantId, name }` + +--- + +### GET `/api/events/createdEvents/user/:userId` + +Get all events created by a specific user. + +- **Auth:** Protected + +**URL Params:** + +| Param | Type | Required | +|----------|----------|----------| +| `userId` | ObjectId | Yes | + +**Success Response (200):** + +```json +{ + "success": true, + "events": [ + { + "_id": "665a...", + "name": "Tech Meetup", + "description": "Monthly networking event", + "joinCode": "48291037", + "createdBy": "664f...", + ... + } + ] +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 404 | `"No events found for this user"` | + +--- + +## Bingo (`/api/bingo`) + +### POST `/api/bingo/createBingo` + +Create a bingo game for an event. + +- **Auth:** Protected + +**Request Body:** + +| Field | Type | Required | Notes | +|---------------|------------|----------|-------| +| `_eventId` | ObjectId | Yes | Must reference an existing event | +| `description` | string | No | | +| `grid` | string[][] | No | 2D array of strings | + +**Success Response (201):** + +```json +{ + "success": true, + "bingoId": "bingo_a1b2c3d4", + "bingo": { + "_id": "bingo_a1b2c3d4", + "_eventId": "665a...", + "description": "Networking Bingo", + "grid": [ + ["Has a pet", "Speaks 3 languages", "Loves hiking"], + ["Works remotely", "Free space", "Plays guitar"], + ["From another country", "Has a blog", "Codes in Rust"] + ] + } +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"_eventId is required"` | +| 400 | `"_eventId must be a valid ObjectId"` | +| 400 | `"grid must be a 2D array of strings"` | +| 404 | `"Event not found"` | + +--- + +### GET `/api/bingo/getBingo/:eventId` + +Get bingo by event ID (or bingo ID). + +- **Auth:** Public + +**URL Params:** + +| Param | Type | Required | Notes | +|-----------|--------|----------|-------| +| `eventId` | string | Yes | Tries as bingo `_id` first, then as `_eventId` | + +**Success Response (200):** + +```json +{ + "success": true, + "bingo": { + "_id": "bingo_a1b2c3d4", + "_eventId": "665a...", + "description": "Networking Bingo", + "grid": [["Has a pet", "Speaks 3 languages", ...], ...] + } +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 404 | `"Bingo not found"` | + +--- + +### PUT `/api/bingo/updateBingo` + +Update a bingo game. + +- **Auth:** Protected + +**Request Body:** + +| Field | Type | Required | Notes | +|---------------|------------|----------|-------| +| `id` | string | Yes | Bingo `_id` or event `_eventId` | +| `description` | string | No | | +| `grid` | string[][] | No | 2D array of strings | + +**Success Response (200):** + +```json +{ + "success": true, + "bingo": { /* updated bingo object */ } +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"id is required"` | +| 400 | `"description must be a string"` | +| 400 | `"grid must be a 2D array of strings"` | +| 400 | `"Nothing to update: provide description and/or grid"` | +| 404 | `"Bingo not found"` | + +**Special Behavior:** Tries to find by `_id` first, then falls back to `_eventId`. + +--- + +## Participant Connections (`/api/participantConnections`) + +### POST `/api/participantConnections/` + +Create a connection between two participants by their IDs. + +- **Auth:** Protected + +**Request Body:** + +| Field | Type | Required | Notes | +|--------------------------|----------|----------|-------| +| `_eventId` | ObjectId | Yes | | +| `primaryParticipantId` | ObjectId | Yes | Must belong to the event | +| `secondaryParticipantId` | ObjectId | Yes | Must belong to the event | +| `description` | string | No | e.g., bingo question they connected with | + +**Success Response (201):** + +```json +{ + "_id": "participantConnection_a1b2c3d4", + "_eventId": "665a...", + "primaryParticipantId": "666b...", + "secondaryParticipantId": "666c...", + "description": "Both love hiking" +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Missing required fields"` | +| 400 | `"Invalid _eventId"` / `"Invalid primaryParticipantId"` / etc. | +| 404 | `"Primary participant not found for this event"` | +| 404 | `"Secondary participant not found for this event"` | +| 409 | `"ParticipantConnection already exists for this event and participants"` | + +--- + +### POST `/api/participantConnections/by-emails` + +Create a connection between two participants by their user emails. + +- **Auth:** Protected + +**Request Body:** + +| Field | Type | Required | Notes | +|----------------------|----------|----------|-------| +| `_eventId` | ObjectId | Yes | | +| `primaryUserEmail` | string | Yes | Email of primary user | +| `secondaryUserEmail` | string | Yes | Email of secondary user, must differ from primary | +| `description` | string | No | | + +**Success Response (201):** + +```json +{ + "_id": "participantConnection_a1b2c3d4", + "_eventId": "665a...", + "primaryParticipantId": "666b...", + "secondaryParticipantId": "666c...", + "description": "Both love hiking" +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Missing required fields"` | +| 400 | `"Invalid _eventId"` | +| 400 | `"Invalid primaryUserEmail"` / `"Invalid secondaryUserEmail"` | +| 400 | `"primaryUserEmail and secondaryUserEmail must be different"` | +| 404 | `"Primary user not found"` / `"Secondary user not found"` | +| 404 | `"Primary participant not found for this event (by user email)"` | +| 404 | `"Secondary participant not found for this event (by user email)"` | +| 409 | `"ParticipantConnection already exists for this event and participants"` | + +--- + +### DELETE `/api/participantConnections/delete` + +Delete a participant connection. + +- **Auth:** Protected + +**Request Body:** + +| Field | Type | Required | Notes | +|----------------|----------|----------|-------| +| `eventId` | ObjectId | Yes | | +| `connectionId` | string | Yes | The connection's `_id` | + +**Success Response (200):** + +```json +{ + "message": "ParticipantConnection deleted successfully", + "deletedConnection": { /* deleted connection object */ } +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Invalid eventId"` | +| 400 | `"Invalid connectionId"` | +| 404 | `"ParticipantConnection not found for this event"` | + +--- + +### GET `/api/participantConnections/getByParticipantAndEvent` + +Get all connections for a participant in an event. + +- **Auth:** Protected + +**Query Params:** + +| Param | Type | Required | +|-----------------|----------|----------| +| `eventId` | ObjectId | Yes | +| `participantId` | ObjectId | Yes | + +**Success Response (200):** + +```json +[ + { + "_id": "participantConnection_a1b2c3d4", + "_eventId": "665a...", + "primaryParticipantId": "666b...", + "secondaryParticipantId": "666c...", + "description": "Both love hiking" + } +] +``` + +Returns connections where the participant is either `primaryParticipantId` or `secondaryParticipantId`. + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Invalid eventId"` | +| 400 | `"Invalid participantId"` | + +--- + +### GET `/api/participantConnections/getByUserEmailAndEvent` + +Get all connections for a user (by email) in an event. + +- **Auth:** Protected + +**Query Params:** + +| Param | Type | Required | +|-------------|----------|----------| +| `eventId` | ObjectId | Yes | +| `userEmail` | string | Yes | + +**Success Response (200):** + +```json +[ + { + "_id": "participantConnection_a1b2c3d4", + "_eventId": "665a...", + "primaryParticipantId": "666b...", + "secondaryParticipantId": "666c..." + } +] +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Invalid eventId"` | +| 400 | `"Invalid userEmail"` | +| 404 | `"Participant not found for this event (by user email)"` | + +--- + +### GET `/api/participantConnections/connected-users` + +Get all users connected with a given participant in an event, including connection descriptions. Returns all connections (including multiple connections to the same user with different descriptions). + +- **Auth:** Protected + +**Query Params:** + +| Param | Type | Required | +|-----------------|----------|----------| +| `eventId` | ObjectId | Yes | +| `participantId` | ObjectId | Yes | + +**Success Response (200):** + +```json +[ + { + "user": { + "_id": "664f...", + "name": "John Doe", + "email": "john@example.com", + "linkedinUrl": "https://linkedin.com/in/johndoe", + "bio": "Software developer", + "profilePhoto": "https://example.com/photo.jpg", + "socialLinks": { "github": "https://github.com/johndoe" } + }, + "participantId": "666b...", + "participantName": "John Doe", + "connectionDescription": "Both love hiking" + } +] +``` + +Returns an empty array `[]` if no connections exist. If a participant has no linked user (guest with null `userId`), the `user` field is `null` while `participantId` and `participantName` are still returned. + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Invalid eventId"` | +| 400 | `"Invalid participantId"` | + +--- + +## Planned Endpoints ⏳ + +These endpoints are **not yet implemented**. Do not depend on them. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/events/:eventId/leave` | Leave an event | +| DELETE | `/api/events/:eventId` | Cancel/delete an event | +| GET | `/api/events/:eventId/participants` | Search/list participants | +| GET | `/api/events/:eventId/qrcode` | Get event QR code image | +| — | `/api/activities/*` | Activity/icebreaker game endpoints | +| — | `/api/bingo/player-state/*` | Player bingo state endpoints | + +--- + +## Quick Start Examples + +### 1. Sign up + +```bash +curl -X POST http://localhost:4000/api/auth/signup \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Jane Doe", + "email": "jane@example.com", + "password": "securepassword123" + }' +``` + +### 2. Log in + +```bash +curl -X POST http://localhost:4000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "jane@example.com", + "password": "securepassword123" + }' +``` + +Save the `token` from the response for subsequent requests. + +### 3. Create an event + +```bash +curl -X POST http://localhost:4000/api/events/createEvent \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "name": "Tech Meetup", + "description": "Monthly networking event", + "gameType": "Name Bingo", + "startDate": "2025-02-01T18:00:00.000Z", + "endDate": "2025-02-01T21:00:00.000Z", + "maxParticipant": 50 + }' +``` + +### 4. Join the event (as authenticated user) + +```bash +curl -X POST http://localhost:4000/api/events//join/user \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "userId": "", + "name": "Jane Doe" + }' +``` + +### 5. Join the event (as guest) + +```bash +curl -X POST http://localhost:4000/api/events//join/guest \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Guest User" + }' +``` + +### 6. Create a bingo game for the event + +```bash +curl -X POST http://localhost:4000/api/bingo/createBingo \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "_eventId": "", + "description": "Networking Bingo", + "grid": [ + ["Has a pet", "Speaks 3 languages", "Loves hiking"], + ["Works remotely", "Free space", "Plays guitar"], + ["From another country", "Has a blog", "Codes in Rust"] + ] + }' +``` + +### 7. Get the bingo game + +```bash +curl http://localhost:4000/api/bingo/getBingo/ +``` diff --git a/shatter-backend/docs/DATABASE_SCHEMA.md b/shatter-backend/docs/DATABASE_SCHEMA.md new file mode 100644 index 0000000..97b840b --- /dev/null +++ b/shatter-backend/docs/DATABASE_SCHEMA.md @@ -0,0 +1,246 @@ +# Shatter Backend — Database Schema Reference + +**Last updated:** 2026-03-01 +**Database:** MongoDB with Mongoose ODM +**Collections:** 6 + +--- + +## Table of Contents + +- [Relationship Diagram](#relationship-diagram) +- [1. Users Collection](#1-users-collection) +- [2. Events Collection](#2-events-collection) +- [3. Participants Collection](#3-participants-collection) +- [4. Bingo Collection](#4-bingo-collection) +- [5. ParticipantConnection Collection](#5-participantconnection-collection) +- [6. AuthCode Collection](#6-authcode-collection) + +--- + +## Relationship Diagram + +``` +┌──────────┐ ┌─────────────┐ ┌──────────┐ +│ Users │◄──────│ Participant │──────►│ Events │ +│ │ userId│ (junction) │eventId│ │ +└──────────┘ └──────┬──────┘ └────┬─────┘ + │ │ │ + │ eventHistoryIds │ participantIds │ + │ (refs Event) │ (refs Participant) │ + │ │ │ + │ ┌─────┴──────────┐ ┌────┴─────┐ + │ │ Participant │ │ Bingo │ + │ │ Connection │ │ │ + │ └────────────────┘ └──────────┘ + │ + │ ┌────────────────┐ + └──────────────│ AuthCode │ + userId │ (TTL: 60s) │ + └────────────────┘ +``` + +- **User ↔ Event** is a many-to-many relationship via the **Participant** junction table +- Each **Event** can have one **Bingo** game +- **ParticipantConnection** links two participants within the same event +- **AuthCode** is a temporary, single-use token linking to a User (auto-deleted after 60s) + +--- + +## 1. Users Collection + +**Model name:** `User` +**Collection:** `users` +**Source:** `src/models/user_model.ts` + +### Fields + +| Field | Type | Required | Default | Notes | +|--------------------|-------------------|----------|------------|-------| +| `_id` | ObjectId | Auto | Auto | MongoDB default | +| `name` | String | Yes | — | Trimmed | +| `email` | String | No | — | Unique (sparse), lowercase, trimmed, regex-validated | +| `passwordHash` | String | No | — | `select: false` — excluded from queries by default | +| `linkedinId` | String | No | — | Unique (sparse) | +| `linkedinUrl` | String | No | — | Unique (sparse) | +| `bio` | String | No | — | Trimmed | +| `profilePhoto` | String | No | — | | +| `socialLinks` | Object | No | — | `{ linkedin?: String, github?: String, other?: String }` | +| `authProvider` | String (enum) | Yes | `'local'` | One of: `'local'`, `'linkedin'`, `'guest'` | +| `lastLogin` | Date | No | `null` | | +| `passwordChangedAt`| Date | No | `null` | | +| `eventHistoryIds` | [ObjectId] | No | `[]` | Refs `Event` | +| `createdAt` | Date | Auto | Auto | Mongoose timestamps | +| `updatedAt` | Date | Auto | Auto | Mongoose timestamps | + +### Indexes + +| Fields | Type | Notes | +|-------------------|----------------|-------| +| `email` | Unique, sparse | Allows multiple `null` values (guests) | +| `linkedinId` | Unique, sparse | | +| `linkedinUrl` | Unique, sparse | | + +### Pre-Save Hooks + +1. **Password requirement check:** If `authProvider` is `'local'` and `passwordHash` is missing, throws `"Password required for local authentication"` +2. **Password change tracking:** If `passwordHash` is modified on an existing document, auto-sets `passwordChangedAt` to current date + +### Key Behaviors + +- `passwordHash` is excluded from all queries by default. Use `.select('+passwordHash')` only when verifying passwords. +- Email uses a sparse unique index, allowing multiple users with `null` email (guest accounts). +- Guest users can upgrade to `local` auth by setting a password via the update endpoint. + +--- + +## 2. Events Collection + +**Model name:** `Event` +**Collection:** `events` +**Source:** `src/models/event_model.ts` + +### Fields + +| Field | Type | Required | Default | Notes | +|------------------|---------------|----------|--------------|-------| +| `_id` | ObjectId | Auto | Auto | | +| `name` | String | Yes | — | | +| `description` | String | Yes | — | | +| `joinCode` | String | Yes | — | Unique, auto-generated 8-digit number | +| `gameType` | String (enum) | Yes | — | One of: `'Name Bingo'` | +| `eventImg` | String | No | — | URL for event image | +| `startDate` | Date | Yes | — | | +| `endDate` | Date | Yes | — | Must be after `startDate` | +| `maxParticipant` | Number | Yes | — | | +| `participantIds` | [ObjectId] | No | `[]` | Refs `Participant` | +| `currentState` | String (enum) | Yes | `'Upcoming'` | One of: `'Upcoming'`, `'In Progress'`, `'Completed'` | +| `createdBy` | ObjectId | Yes | — | User who created the event (no ref set) | +| `createdAt` | Date | Auto | Auto | Mongoose timestamps | +| `updatedAt` | Date | Auto | Auto | Mongoose timestamps | + +### Indexes + +| Fields | Type | Notes | +|------------|--------|-------| +| `joinCode` | Unique | | + +### Pre-Save Hooks + +1. **Date validation:** If `endDate <= startDate`, throws `"endDate must be after startDate"` + +--- + +## 3. Participants Collection + +**Model name:** `Participant` +**Collection:** `participants` +**Source:** `src/models/participant_model.ts` + +**Purpose:** Junction table linking Users to Events (many-to-many). + +### Fields + +| Field | Type | Required | Default | Notes | +|-----------|----------|----------|---------|-------| +| `_id` | ObjectId | Auto | Auto | | +| `userId` | ObjectId | No | `null` | Refs `User`. Nullable for legacy reasons | +| `name` | String | Yes | — | Display name in the event | +| `eventId` | ObjectId | Yes | — | Refs `Event` | + +### Indexes + +| Fields | Type | Notes | +|--------------------|--------|-------| +| `(eventId, name)` | Unique | Case-insensitive collation (`locale: "en", strength: 2`) | + +### Key Behaviors + +- The compound unique index on `(eventId, name)` is case-insensitive, so "John" and "john" are treated as the same name within an event. +- No timestamps are enabled on this model. + +--- + +## 4. Bingo Collection + +**Model name:** `Bingo` +**Collection:** `bingos` +**Source:** `src/models/bingo_model.ts` + +### Fields + +| Field | Type | Required | Default | Notes | +|---------------|------------|----------|---------|-------| +| `_id` | String | Auto | Auto | Custom: `bingo_<8 random chars>` | +| `_eventId` | ObjectId | Yes | — | Refs `Event` | +| `description` | String | No | — | | +| `grid` | [[String]] | No | — | 2D array of strings | + +### Pre-Save Hooks + +1. **ID generation:** If `_id` is not set, generates `bingo_` + 8 random alphanumeric characters + +### Options + +- `versionKey: false` — no `__v` field on documents + +--- + +## 5. ParticipantConnection Collection + +**Model name:** `ParticipantConnection` +**Collection:** `participantconnections` +**Source:** `src/models/participant_connection_model.ts` + +### Fields + +| Field | Type | Required | Default | Notes | +|--------------------------|----------|----------|---------|-------| +| `_id` | String | Auto | Auto | Custom: `participantConnection_<8 random chars>` | +| `_eventId` | ObjectId | Yes | — | Refs `Event` | +| `primaryParticipantId` | ObjectId | Yes | — | Refs `Participant` | +| `secondaryParticipantId` | ObjectId | Yes | — | Refs `Participant` | +| `description` | String | No | — | e.g., the bingo question they connected with | + +### Pre-Save Hooks + +1. **ID generation:** If `_id` is not set, generates `participantConnection_` + 8 random alphanumeric characters + +### Options + +- `versionKey: false` — no `__v` field on documents + +### Key Behaviors + +- Duplicate prevention is handled at the application level (controller checks for existing connection with same `_eventId` + `primaryParticipantId` + `secondaryParticipantId`), not via a database index. + +--- + +## 6. AuthCode Collection + +**Model name:** `AuthCode` +**Collection:** `authcodes` +**Source:** `src/models/auth_code_model.ts` + +**Purpose:** Single-use authorization codes for the LinkedIn OAuth flow. + +### Fields + +| Field | Type | Required | Default | Notes | +|-------------|----------|----------|------------|-------| +| `_id` | ObjectId | Auto | Auto | | +| `code` | String | Yes | — | Unique, indexed | +| `userId` | ObjectId | Yes | — | Refs `User` | +| `createdAt` | Date | Auto | `Date.now` | TTL: auto-deleted after 60 seconds | + +### Indexes + +| Fields | Type | Notes | +|--------|--------|-------| +| `code` | Unique | Also has explicit index | + +### Key Behaviors + +- **TTL (Time-To-Live):** Documents are automatically deleted by MongoDB 60 seconds after `createdAt`. This ensures auth codes are short-lived. +- **Single-use:** The exchange endpoint uses `findOneAndDelete` to atomically consume the code. +- No timestamps option — uses manual `createdAt` for TTL. diff --git a/shatter-backend/docs/EVENT_LIFECYCLE.md b/shatter-backend/docs/EVENT_LIFECYCLE.md new file mode 100644 index 0000000..f4164ae --- /dev/null +++ b/shatter-backend/docs/EVENT_LIFECYCLE.md @@ -0,0 +1,180 @@ +# Shatter Backend — Event Lifecycle Guide + +**Last updated:** 2026-03-01 + +--- + +## Table of Contents + +- [Event States](#event-states) + - [State Enum](#state-enum) + - [State Diagram](#state-diagram) +- [Transition Rules](#transition-rules) + - [Valid Transitions](#valid-transitions) + - [Transition Endpoint](#transition-endpoint-put-apieventseventidstatus) + - [Side Effects Per Transition](#side-effects-per-transition) +- [Current Event Endpoints and State](#current-event-endpoints-and-state) +- [What's Allowed in Each State (Planned)](#whats-allowed-in-each-state-planned) +- [Frontend Integration Notes](#frontend-integration-notes) + - [UI State Mapping](#ui-state-mapping) + - [Subscribing to State Changes](#subscribing-to-state-changes) + +--- + +## Event States + +### State Enum + +The `currentState` field on the Event model is a **validated enum** with three possible values: + +```js +// event_model.ts +currentState: { + type: String, + enum: ['Upcoming', 'In Progress', 'Completed'], + default: 'Upcoming', + required: true +} +``` + +| State | Description | +|---------------|-------------| +| `Upcoming` | Event created, waiting for host to start. Participants can join. | +| `In Progress` | Event is live. Games/activities are in progress. Participants can still join (unless at capacity). | +| `Completed` | Event is over. No new joins. Results are finalized. | + +These values match the mobile app's `EventState` enum exactly (title case with spaces). + +### State Diagram + +``` +Upcoming ──► In Progress ──► Completed +``` + +- Only forward transitions are allowed +- There is no way to revert a state (e.g., `Completed` cannot go back to `In Progress`) +- Events are created with `currentState: 'Upcoming'` by default + +--- + +## Transition Rules + +### Valid Transitions + +| From | To | Who Can Trigger | Endpoint | +|---------------|----------------|--------------------|----------| +| `Upcoming` | `In Progress` | Event creator only | `PUT /api/events/:eventId/status` | +| `In Progress` | `Completed` | Event creator only | `PUT /api/events/:eventId/status` | + +Invalid transitions (e.g., `Completed` → `In Progress`, `Upcoming` → `Completed`) are rejected with a `400` error. + +### Transition Endpoint: `PUT /api/events/:eventId/status` + +**Auth:** Protected (event creator only — `event.createdBy === req.user.userId`) + +**Request Body:** + +```json +{ + "status": "In Progress" +} +``` + +**Validation:** +- Only the user in `createdBy` can change the state (403 for non-host) +- Only valid transitions are allowed (400 for invalid transitions) +- The `status` field is required (400 if missing) + +**Success Response (200):** + +```json +{ + "success": true, + "event": { + "_id": "665a...", + "name": "Tech Meetup", + "currentState": "In Progress", + ... + } +} +``` + +**Error Responses:** + +| Status | Error | +|--------|-------| +| 400 | `"Status is required"` | +| 400 | `"Invalid status transition from to "` | +| 403 | `"Only the event host can update the event status"` | +| 404 | `"Event not found"` | + +### Side Effects Per Transition + +#### `Upcoming` → `In Progress` +- Triggers Pusher event `event-started` on channel `event-{eventId}` +- Payload: `{ status: 'In Progress' }` +- Frontend should transition from lobby/waiting UI to active game UI + +#### `In Progress` → `Completed` +- Triggers Pusher event `event-ended` on channel `event-{eventId}` +- Payload: `{ status: 'Completed' }` +- Frontend should show results/summary screen + +--- + +## Current Event Endpoints and State + +| Endpoint | State Behavior | +|----------|---------------| +| `POST /api/events/createEvent` | Sets `currentState` to `'Upcoming'` by default (can be overridden with a valid enum value) | +| `GET /api/events/:eventId` | Returns `currentState` as-is | +| `GET /api/events/event/:joinCode` | Returns `currentState` as-is | +| `PUT /api/events/:eventId/status` | Validates and transitions `currentState` (host-only) | +| `POST /api/events/:eventId/join/user` | Does **not** check `currentState` | +| `POST /api/events/:eventId/join/guest` | Does **not** check `currentState` | + +--- + +## What's Allowed in Each State (Planned) + +| Action | `Upcoming` | `In Progress` | `Completed` | +|--------|-----------|----------------|-------------| +| Join event | Yes | Yes (if not full) | No | +| Leave event | Yes | Yes | No | +| View participants | Yes | Yes | Yes | +| Play bingo / activities | No | Yes | No | +| Update bingo grid | No | Yes | No | +| View results | No | No | Yes | +| Create connections | No | Yes | No | + +--- + +## Frontend Integration Notes + +### UI State Mapping + +| `currentState` | Suggested UI | +|----------------|-------------| +| `Upcoming` | Lobby / waiting room. Show participant list, join code, QR code. "Waiting for host to start..." | +| `In Progress` | Game screen. Show bingo grid, active activities, connection creation. | +| `Completed` | Results screen. Show final scores, connections made, event summary. | + +### Subscribing to State Changes + +Subscribe to Pusher events on the event channel to react to state transitions in real time: + +```js +const channel = pusher.subscribe(`event-${eventId}`); + +channel.bind('event-started', (data) => { + // data.status === 'In Progress' + // Transition UI from lobby to active game + setEventState('In Progress'); +}); + +channel.bind('event-ended', (data) => { + // data.status === 'Completed' + // Transition UI from active game to results + setEventState('Completed'); +}); +``` diff --git a/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md b/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md new file mode 100644 index 0000000..987aeea --- /dev/null +++ b/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md @@ -0,0 +1,296 @@ +# Shatter Backend — Real-Time Events Guide + +**Last updated:** 2026-03-01 + +--- + +## Table of Contents + +- [Why Pusher?](#why-pusher) +- [Setup](#setup) +- [Channel Naming Convention](#channel-naming-convention) +- [Implemented Events](#implemented-events-) + - [`participant-joined`](#participant-joined) + - [`event-started`](#event-started) + - [`event-ended`](#event-ended) +- [Planned Events](#planned-events-) + - [`bingo-achieved`](#bingo-achieved) +- [Client Integration Examples](#client-integration-examples) + - [React (Web Dashboard)](#react-web-dashboard) + - [React Native (Mobile App)](#react-native-mobile-app) + - [Error Handling](#error-handling) +- [Testing Tips](#testing-tips) + +--- + +## Why Pusher? + +Shatter's backend is deployed on Vercel, which runs serverless functions. Serverless functions freeze between requests and cannot maintain persistent WebSocket connections (ruling out Socket.IO or raw WebSockets). + +**Pusher** provides a managed WebSocket service where: +- The **backend** sends events to Pusher's servers via HTTP (works from serverless) +- **Clients** (mobile/web) maintain WebSocket connections to Pusher directly +- No long-lived server process needed + +--- + +## Setup + +### Client-Side Configuration + +Install the Pusher client library: + +```bash +# Web (React) +npm install pusher-js + +# React Native (Expo) +npx expo install pusher-js +``` + +Initialize the Pusher client with your credentials: + +```js +import Pusher from 'pusher-js'; + +const pusher = new Pusher(process.env.PUSHER_KEY, { + cluster: process.env.PUSHER_CLUSTER, // e.g., 'us2' +}); +``` + +**Required environment variables** (get these from the backend team or Pusher dashboard): +- `PUSHER_KEY` — Your Pusher app key (public, safe for client-side) +- `PUSHER_CLUSTER` — Your Pusher cluster region (e.g., `us2`, `eu`, `ap1`) + +> **Note:** The `PUSHER_SECRET` is only used server-side and must never be exposed to clients. + +--- + +## Channel Naming Convention + +All real-time channels follow this pattern: + +``` +event-{eventId} +``` + +Where `eventId` is the MongoDB ObjectId of the event. For example: + +``` +event-665a1b2c3d4e5f6a7b8c9d0e +``` + +Each event has its own channel. Subscribe when a user enters an event, unsubscribe when they leave. + +--- + +## Implemented Events ✅ + +### `participant-joined` + +**Channel:** `event-{eventId}` + +**Triggered when:** +- A registered user joins an event (`POST /api/events/:eventId/join/user`) +- A guest joins an event (`POST /api/events/:eventId/join/guest`) + +**Payload:** + +```json +{ + "participantId": "666b1a2b3c4d5e6f7a8b9c0d", + "name": "John Doe" +} +``` + +| Field | Type | Description | +|-----------------|----------|-------------| +| `participantId` | ObjectId | The new participant's ID | +| `name` | string | The participant's display name | + +**Use case:** Update the live participant list in the event lobby/dashboard without polling. + +--- + +### `event-started` + +**Channel:** `event-{eventId}` + +**Triggered when:** +- The event host transitions the event status from `Upcoming` to `In Progress` (`PUT /api/events/:eventId/status`) + +**Payload:** + +```json +{ + "status": "In Progress" +} +``` + +| Field | Type | Description | +|----------|--------|-------------| +| `status` | string | The new event status (`"In Progress"`) | + +**Use case:** Transition the UI from the lobby/waiting room to the active game screen. + +--- + +### `event-ended` + +**Channel:** `event-{eventId}` + +**Triggered when:** +- The event host transitions the event status from `In Progress` to `Completed` (`PUT /api/events/:eventId/status`) + +**Payload:** + +```json +{ + "status": "Completed" +} +``` + +| Field | Type | Description | +|----------|--------|-------------| +| `status` | string | The new event status (`"Completed"`) | + +**Use case:** Transition the UI from the active game screen to the results/summary screen. + +--- + +## Planned Events ⏳ + +These events are **not yet implemented**. Do not depend on them. + +### `bingo-achieved` + +**Channel:** `event-{eventId}` + +Triggered when a participant completes a bingo line/card. + +**Expected payload:** + +```json +{ + "participantId": "666b...", + "participantName": "Jane Doe", + "achievementType": "line" +} +``` + +--- + +## Client Integration Examples + +### React (Web Dashboard) + +```jsx +import { useEffect, useState } from 'react'; +import Pusher from 'pusher-js'; + +function EventLobby({ eventId }) { + const [participants, setParticipants] = useState([]); + + useEffect(() => { + const pusher = new Pusher(process.env.REACT_APP_PUSHER_KEY, { + cluster: process.env.REACT_APP_PUSHER_CLUSTER, + }); + + const channel = pusher.subscribe(`event-${eventId}`); + + channel.bind('participant-joined', (data) => { + setParticipants((prev) => [ + ...prev, + { participantId: data.participantId, name: data.name }, + ]); + }); + + // Clean up on unmount + return () => { + channel.unbind_all(); + pusher.unsubscribe(`event-${eventId}`); + pusher.disconnect(); + }; + }, [eventId]); + + return ( +
    + {participants.map((p) => ( +
  • {p.name}
  • + ))} +
+ ); +} +``` + +### React Native (Mobile App) + +```jsx +import { useEffect, useState } from 'react'; +import Pusher from 'pusher-js/react-native'; +import { FlatList, Text } from 'react-native'; + +function EventLobby({ eventId }) { + const [participants, setParticipants] = useState([]); + + useEffect(() => { + const pusher = new Pusher(PUSHER_KEY, { + cluster: PUSHER_CLUSTER, + }); + + const channel = pusher.subscribe(`event-${eventId}`); + + channel.bind('participant-joined', (data) => { + setParticipants((prev) => [ + ...prev, + { participantId: data.participantId, name: data.name }, + ]); + }); + + return () => { + channel.unbind_all(); + pusher.unsubscribe(`event-${eventId}`); + pusher.disconnect(); + }; + }, [eventId]); + + return ( + item.participantId} + renderItem={({ item }) => {item.name}} + /> + ); +} +``` + +### Error Handling + +```js +// Connection state monitoring +pusher.connection.bind('state_change', (states) => { + console.log('Pusher state:', states.previous, '->', states.current); +}); + +pusher.connection.bind('error', (err) => { + console.error('Pusher connection error:', err); +}); + +// Subscription error handling +const channel = pusher.subscribe(`event-${eventId}`); +channel.bind('pusher:subscription_error', (error) => { + console.error('Subscription failed:', error); +}); +``` + +--- + +## Testing Tips + +1. **Pusher Dashboard Debug Console:** Go to your Pusher app dashboard → "Debug Console" to see events in real-time as they're triggered by the backend. + +2. **Trigger a test event manually:** Join an event via the API and watch the debug console for `participant-joined` events. + +3. **Check channel subscriptions:** The Pusher dashboard shows active connections and subscriptions, useful for debugging client-side issues. + +4. **Local development:** Both the backend and frontend can use the same Pusher credentials locally. The backend triggers events via HTTP (no WebSocket needed server-side), so it works even without Pusher's client SDK. diff --git a/shatter-backend/src/controllers/event_controller.ts b/shatter-backend/src/controllers/event_controller.ts index f884d3a..c2c0c91 100644 --- a/shatter-backend/src/controllers/event_controller.ts +++ b/shatter-backend/src/controllers/event_controller.ts @@ -35,6 +35,8 @@ export async function createEvent(req: Request, res: Response) { endDate, maxParticipant, currentState, + gameType, + eventImg, } = req.body; const createdBy = req.user!.userId; @@ -64,6 +66,8 @@ export async function createEvent(req: Request, res: Response) { maxParticipant, participantIds: [], currentState, + gameType, + eventImg, createdBy, // user id }); @@ -71,6 +75,9 @@ export async function createEvent(req: Request, res: Response) { res.status(201).json({ success: true, event: savedEvent }); } catch (err: any) { + if (err.name === 'ValidationError') { + return res.status(400).json({ success: false, error: err.message }); + } res.status(500).json({ success: false, error: err.message }); } } @@ -206,10 +213,18 @@ export async function joinEventAsUser(req: Request, res: Response) { }); } catch (e: any) { if (e.code === 11000) { - return res.status(409).json({ - success: false, - msg: "This name is already taken in this event", - }); + if (e.keyPattern?.name && e.keyPattern?.eventId) { + return res.status(409).json({ + success: false, + msg: "This name is already taken in this event", + }); + } + if (e.keyPattern?.email) { + return res.status(409).json({ + success: false, + msg: "A user with this email already exists", + }); + } } console.error("JOIN EVENT ERROR:", e); return res.status(500).json({ success: false, msg: "Internal error" }); @@ -297,10 +312,18 @@ export async function joinEventAsGuest(req: Request, res: Response) { }); } catch (e: any) { if (e.code === 11000) { - return res.status(409).json({ - success: false, - msg: "This name is already taken in this event", - }); + if (e.keyPattern?.name && e.keyPattern?.eventId) { + return res.status(409).json({ + success: false, + msg: "This name is already taken in this event", + }); + } + if (e.keyPattern?.email) { + return res.status(409).json({ + success: false, + msg: "A user with this email already exists", + }); + } } console.error("JOIN GUEST ERROR:", e); return res.status(500).json({ success: false, msg: "Internal error" }); @@ -355,6 +378,73 @@ export async function getEventById(req: Request, res: Response) { * @returns 400 if userId is missing * @returns 404 if no events are found for the user */ +/** + * PUT /api/events/:eventId/status + * Update event status (host only) + * + * @param req.params.eventId - Event ID (required) + * @param req.body.status - New status: "In Progress" or "Completed" (required) + * @param req.user.userId - Authenticated user ID (from access token) + * + * @returns 200 with updated event on success + * @returns 400 if status is invalid or transition is not allowed + * @returns 403 if user is not the event host + * @returns 404 if event is not found + */ +export async function updateEventStatus(req: Request, res: Response) { + try { + const { eventId } = req.params; + const { status } = req.body; + + const validStatuses = ['In Progress', 'Completed']; + if (!status || !validStatuses.includes(status)) { + return res.status(400).json({ + success: false, + error: `Invalid status. Must be one of: ${validStatuses.join(', ')}`, + }); + } + + const event = await Event.findById(eventId); + if (!event) { + return res.status(404).json({ success: false, error: "Event not found" }); + } + + // Only the host can change event status + if (event.createdBy.toString() !== req.user!.userId) { + return res.status(403).json({ + success: false, + error: "Only the event host can update the event status", + }); + } + + // Validate allowed transitions + const allowedTransitions: Record = { + 'Upcoming': 'In Progress', + 'In Progress': 'Completed', + }; + + if (allowedTransitions[event.currentState] !== status) { + return res.status(400).json({ + success: false, + error: `Cannot transition from "${event.currentState}" to "${status}"`, + }); + } + + event.currentState = status; + const updatedEvent = await event.save(); + + // Emit Pusher events for real-time updates + const pusherEvent = status === 'In Progress' ? 'event-started' : 'event-ended'; + await pusher.trigger(`event-${eventId}`, pusherEvent, { + status, + }); + + return res.status(200).json({ success: true, event: updatedEvent }); + } catch (err: any) { + return res.status(500).json({ success: false, error: err.message }); + } +} + export async function getEventsByUserId(req: Request, res: Response) { try { const { userId } = req.params; diff --git a/shatter-backend/src/controllers/participant_connections_controller.ts b/shatter-backend/src/controllers/participant_connections_controller.ts index d206b22..e66874e 100644 --- a/shatter-backend/src/controllers/participant_connections_controller.ts +++ b/shatter-backend/src/controllers/participant_connections_controller.ts @@ -31,15 +31,28 @@ import { ParticipantConnection } from "../models/participant_connection_model"; */ export async function createParticipantConnection(req: Request, res: Response) { try { - const requiredFields = ["_eventId", "primaryParticipantId", "secondaryParticipantId"]; + const requiredFields = [ + "_eventId", + "primaryParticipantId", + "secondaryParticipantId", + ]; if (!check_req_fields(req, requiredFields)) { return res.status(400).json({ error: "Missing required fields" }); } - const { _eventId, primaryParticipantId, secondaryParticipantId, description } = req.body; + const { + _eventId, + primaryParticipantId, + secondaryParticipantId, + description, + } = req.body; // Validate ObjectId format before hitting the DB - const idsToValidate = { _eventId, primaryParticipantId, secondaryParticipantId }; + const idsToValidate = { + _eventId, + primaryParticipantId, + secondaryParticipantId, + }; for (const [key, value] of Object.entries(idsToValidate)) { if (!Types.ObjectId.isValid(value)) { return res.status(400).json({ error: `Invalid ${key}` }); @@ -48,15 +61,25 @@ export async function createParticipantConnection(req: Request, res: Response) { // Ensure both participants exist AND belong to the event const [primaryParticipant, secondaryParticipant] = await Promise.all([ - Participant.findOne({ _id: primaryParticipantId, eventId: _eventId }).select("_id"), - Participant.findOne({ _id: secondaryParticipantId, eventId: _eventId }).select("_id"), + Participant.findOne({ + _id: primaryParticipantId, + eventId: _eventId, + }).select("_id"), + Participant.findOne({ + _id: secondaryParticipantId, + eventId: _eventId, + }).select("_id"), ]); if (!primaryParticipant) { - return res.status(404).json({ error: "Primary participant not found for this event" }); + return res + .status(404) + .json({ error: "Primary participant not found for this event" }); } if (!secondaryParticipant) { - return res.status(404).json({ error: "Secondary participant not found for this event" }); + return res + .status(404) + .json({ error: "Secondary participant not found for this event" }); } // Prevent duplicates with exact same (_eventId, primaryParticipantId, secondaryParticipantId) @@ -68,7 +91,8 @@ export async function createParticipantConnection(req: Request, res: Response) { if (existing) { return res.status(409).json({ - error: "ParticipantConnection already exists for this event and participants", + error: + "ParticipantConnection already exists for this event and participants", existingConnection: existing, }); } @@ -111,14 +135,22 @@ export async function createParticipantConnection(req: Request, res: Response) { * @returns 409 - ParticipantConnection already exists for this event and participants * @returns 500 - Internal server error */ -export async function createParticipantConnectionByEmails(req: Request, res: Response) { +export async function createParticipantConnectionByEmails( + req: Request, + res: Response, +) { try { - const requiredFields = ["_eventId", "primaryUserEmail", "secondaryUserEmail"]; + const requiredFields = [ + "_eventId", + "primaryUserEmail", + "secondaryUserEmail", + ]; if (!check_req_fields(req, requiredFields)) { return res.status(400).json({ error: "Missing required fields" }); } - const { _eventId, primaryUserEmail, secondaryUserEmail, description } = req.body; + const { _eventId, primaryUserEmail, secondaryUserEmail, description } = + req.body; if (!Types.ObjectId.isValid(_eventId)) { return res.status(400).json({ error: "Invalid _eventId" }); @@ -136,7 +168,9 @@ export async function createParticipantConnectionByEmails(req: Request, res: Res } if (primaryEmail === secondaryEmail) { - return res.status(400).json({ error: "primaryUserEmail and secondaryUserEmail must be different" }); + return res.status(400).json({ + error: "primaryUserEmail and secondaryUserEmail must be different", + }); } // Find users by email @@ -145,20 +179,32 @@ export async function createParticipantConnectionByEmails(req: Request, res: Res User.findOne({ email: secondaryEmail }).select("_id"), ]); - if (!primaryUser) return res.status(404).json({ error: "Primary user not found" }); - if (!secondaryUser) return res.status(404).json({ error: "Secondary user not found" }); + if (!primaryUser) + return res.status(404).json({ error: "Primary user not found" }); + if (!secondaryUser) + return res.status(404).json({ error: "Secondary user not found" }); // Map User -> Participant (for the event) const [primaryParticipant, secondaryParticipant] = await Promise.all([ - Participant.findOne({ eventId: _eventId, userId: primaryUser._id }).select("_id"), - Participant.findOne({ eventId: _eventId, userId: secondaryUser._id }).select("_id"), + Participant.findOne({ + eventId: _eventId, + userId: primaryUser._id, + }).select("_id"), + Participant.findOne({ + eventId: _eventId, + userId: secondaryUser._id, + }).select("_id"), ]); if (!primaryParticipant) { - return res.status(404).json({ error: "Primary participant not found for this event (by user email)" }); + return res.status(404).json({ + error: "Primary participant not found for this event (by user email)", + }); } if (!secondaryParticipant) { - return res.status(404).json({ error: "Secondary participant not found for this event (by user email)" }); + return res.status(404).json({ + error: "Secondary participant not found for this event (by user email)", + }); } // Prevent duplicates with exact same (_eventId, primaryParticipantId, secondaryParticipantId) @@ -170,7 +216,8 @@ export async function createParticipantConnectionByEmails(req: Request, res: Res if (existing) { return res.status(409).json({ - error: "ParticipantConnection already exists for this event and participants", + error: + "ParticipantConnection already exists for this event and participants", existingConnection: existing, }); } @@ -219,7 +266,9 @@ export async function deleteParticipantConnection(req: Request, res: Response) { }); if (!deleted) { - return res.status(404).json({ error: "ParticipantConnection not found for this event" }); + return res + .status(404) + .json({ error: "ParticipantConnection not found for this event" }); } return res.status(200).json({ @@ -231,15 +280,26 @@ export async function deleteParticipantConnection(req: Request, res: Response) { } } -export async function getConnectionsByParticipantAndEvent(req: Request, res: Response) { +export async function getConnectionsByParticipantAndEvent( + req: Request, + res: Response, +) { try { const { eventId, participantId } = req.query; - if (!eventId || typeof eventId !== "string" || !Types.ObjectId.isValid(eventId)) { + if ( + !eventId || + typeof eventId !== "string" || + !Types.ObjectId.isValid(eventId) + ) { return res.status(400).json({ error: "Invalid eventId" }); } - if (!participantId || typeof participantId !== "string" || !Types.ObjectId.isValid(participantId)) { + if ( + !participantId || + typeof participantId !== "string" || + !Types.ObjectId.isValid(participantId) + ) { return res.status(400).json({ error: "Invalid participantId" }); } @@ -257,11 +317,18 @@ export async function getConnectionsByParticipantAndEvent(req: Request, res: Res } } -export async function getConnectionsByUserEmailAndEvent(req: Request, res: Response) { +export async function getConnectionsByUserEmailAndEvent( + req: Request, + res: Response, +) { try { const { eventId, userEmail } = req.query; - if (!eventId || typeof eventId !== "string" || !Types.ObjectId.isValid(eventId)) { + if ( + !eventId || + typeof eventId !== "string" || + !Types.ObjectId.isValid(eventId) + ) { return res.status(400).json({ error: "Invalid eventId" }); } @@ -276,17 +343,22 @@ export async function getConnectionsByUserEmailAndEvent(req: Request, res: Respo return res.status(400).json({ error: "Invalid userEmail" }); } - const participant = await Participant.findOne({ eventId, userId: user._id }).select("_id"); + const participant = await Participant.findOne({ + eventId, + userId: user._id, + }).select("_id"); if (!participant) { - return res.status(404).json({ error: "Participant not found for this event (by user email)" }); + return res.status(404).json({ + error: "Participant not found for this event (by user email)", + }); } const connections = await ParticipantConnection.find({ _eventId: eventId, $or: [ { primaryParticipantId: participant._id }, - { secondaryParticipantId: participant._id } + { secondaryParticipantId: participant._id }, ], }); @@ -294,4 +366,102 @@ export async function getConnectionsByUserEmailAndEvent(req: Request, res: Respo } catch (_error) { return res.status(500).json({ error: "Internal server error" }); } -} \ No newline at end of file +} + +/** + * GET /api/participantConnections/connected-users + * + * Get all users connected with a given participant in an event, + * including the description of the connection. + * + * @param req.query.eventId - MongoDB ObjectId of the event (required) + * @param req.query.participantId - MongoDB ObjectId of the participant (required) + * + * @returns 200 - Array of connected users with connection descriptions (empty array if none) + * @returns 400 - Missing/invalid params + * @returns 500 - Internal server error + */ +export async function getConnectedUsersInfo(req: Request, res: Response) { + try { + const { eventId, participantId } = req.query; + + if ( + !eventId || + typeof eventId !== "string" || + !Types.ObjectId.isValid(eventId) + ) { + return res.status(400).json({ error: "Invalid eventId" }); + } + + if ( + !participantId || + typeof participantId !== "string" || + !Types.ObjectId.isValid(participantId) + ) { + return res.status(400).json({ error: "Invalid participantId" }); + } + + const connections = await ParticipantConnection.find({ + _eventId: eventId, + $or: [ + { primaryParticipantId: participantId }, + { secondaryParticipantId: participantId }, + ], + }); + + if (!connections.length) { + return res.status(200).json([]); + } + + // Map each connection to the other participant's ID + description + const connectedItems = connections.map((conn) => { + const otherParticipantId = + conn.primaryParticipantId.toString() === participantId + ? conn.secondaryParticipantId + : conn.primaryParticipantId; + return { + participantId: otherParticipantId, + description: conn.description || null, + }; + }); + + const participantIds = [ + ...new Set(connectedItems.map((item) => item.participantId.toString())), + ]; + + // Single query with populate to get participants + their users + const participants = await Participant.find({ + _id: { $in: participantIds }, + }) + .select("userId name") + .populate("userId", "name email linkedinUrl bio profilePhoto socialLinks"); + + const participantMap = new Map( + participants.map((p) => [(p._id as Types.ObjectId).toString(), p]), + ); + + const result = connectedItems.map((item) => { + const participant = participantMap.get(item.participantId.toString()); + + if (!participant) { + return { + user: null, + participantId: item.participantId, + participantName: null, + connectionDescription: item.description, + }; + } + + return { + user: participant.userId || null, + participantId: participant._id, + participantName: participant.name, + connectionDescription: item.description, + }; + }); + + return res.status(200).json(result); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} diff --git a/shatter-backend/src/models/event_model.ts b/shatter-backend/src/models/event_model.ts index 04dc55f..28aec64 100644 --- a/shatter-backend/src/models/event_model.ts +++ b/shatter-backend/src/models/event_model.ts @@ -12,6 +12,8 @@ export interface IEvent extends Document { maxParticipant: number; participantIds: Schema.Types.ObjectId[]; currentState: string; + gameType: string; + eventImg?: string; createdBy: Schema.Types.ObjectId; } @@ -24,7 +26,18 @@ const EventSchema = new Schema( endDate: { type: Date, required: true }, maxParticipant: { type: Number, required: true }, participantIds: [{ type: Schema.Types.ObjectId, ref: "Participant" }], - currentState: { type: String, required: true }, + currentState: { + type: String, + enum: ['Upcoming', 'In Progress', 'Completed'], + default: 'Upcoming', + required: true, + }, + gameType: { + type: String, + enum: ['Name Bingo'], + required: true, + }, + eventImg: { type: String, required: false }, createdBy: { type: Schema.Types.ObjectId, required: true, diff --git a/shatter-backend/src/models/user_model.ts b/shatter-backend/src/models/user_model.ts index 521553e..78e76c9 100644 --- a/shatter-backend/src/models/user_model.ts +++ b/shatter-backend/src/models/user_model.ts @@ -45,7 +45,6 @@ const UserSchema = new Schema( lowercase: true, unique: true, sparse: true, // allows multiple users without email (guests) - index: true, match: [ /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/, "Please provide a valid email address", diff --git a/shatter-backend/src/routes/event_routes.ts b/shatter-backend/src/routes/event_routes.ts index cf03550..03e2bf9 100644 --- a/shatter-backend/src/routes/event_routes.ts +++ b/shatter-backend/src/routes/event_routes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { createEvent, getEventByJoinCode, getEventById, joinEventAsUser, joinEventAsGuest, getEventsByUserId } from '../controllers/event_controller'; +import { createEvent, getEventByJoinCode, getEventById, joinEventAsUser, joinEventAsGuest, getEventsByUserId, updateEventStatus } from '../controllers/event_controller'; import { authMiddleware } from '../middleware/auth_middleware'; const router = Router(); @@ -7,6 +7,7 @@ const router = Router(); router.post("/createEvent", authMiddleware, createEvent); router.get("/event/:joinCode", getEventByJoinCode); +router.put("/:eventId/status", authMiddleware, updateEventStatus); router.get("/:eventId", getEventById); router.post("/:eventId/join/user", authMiddleware, joinEventAsUser); router.post("/:eventId/join/guest", joinEventAsGuest); diff --git a/shatter-backend/src/routes/participant_connections_routes.ts b/shatter-backend/src/routes/participant_connections_routes.ts index 383168e..8bb6dc8 100644 --- a/shatter-backend/src/routes/participant_connections_routes.ts +++ b/shatter-backend/src/routes/participant_connections_routes.ts @@ -5,6 +5,7 @@ import { createParticipantConnection, createParticipantConnectionByEmails, deleteParticipantConnection, + getConnectedUsersInfo, getConnectionsByParticipantAndEvent, getConnectionsByUserEmailAndEvent, } from "../controllers/participant_connections_controller"; @@ -57,4 +58,8 @@ router.get("/getByParticipantAndEvent", authMiddleware, getConnectionsByParticip // GET /api/participantConnections/getByUserEmailAndEvent router.get("/getByUserEmailAndEvent", authMiddleware, getConnectionsByUserEmailAndEvent); +// Get all user's information that connected with the participant +// GET /api/participantConnections/connected-users +router.get("/connected-users", authMiddleware, getConnectedUsersInfo); + export default router; \ No newline at end of file