From 019dd8c4da4895484f5984b7e2eb76e211030086 Mon Sep 17 00:00:00 2001 From: rxmox Date: Sun, 1 Mar 2026 15:00:04 -0700 Subject: [PATCH 1/7] Fix guest join 409 error caused by duplicate email index Remove redundant non-sparse index on user email field that prevented multiple guest users (who have no email) from being created. The sparse unique index from unique+sparse is sufficient. Also improve E11000 error handling in joinEventAsUser and joinEventAsGuest to distinguish between duplicate name vs duplicate email errors instead of assuming all duplicates are name conflicts. --- .../src/controllers/event_controller.ts | 32 ++++++++++++++----- shatter-backend/src/models/user_model.ts | 1 - 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/shatter-backend/src/controllers/event_controller.ts b/shatter-backend/src/controllers/event_controller.ts index f884d3a..122968c 100644 --- a/shatter-backend/src/controllers/event_controller.ts +++ b/shatter-backend/src/controllers/event_controller.ts @@ -206,10 +206,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 +305,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" }); 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", From 9eefba0a0785654cd416a56ce9e2bf52b961ba36 Mon Sep 17 00:00:00 2001 From: rxmox Date: Sun, 1 Mar 2026 16:22:44 -0700 Subject: [PATCH 2/7] Add backend documentation suite for frontend/mobile integration - API_REFERENCE.md: Complete endpoint reference for all 20 implemented routes with request/response shapes, error codes, and curl quick-start examples - REALTIME_EVENTS_GUIDE.md: Pusher integration guide with client setup, channel naming, event payloads, and React/React Native code examples - DATABASE_SCHEMA.md: Schema reference for all 6 collections with field tables, indexes, pre-save hooks, and relationship diagram - EVENT_LIFECYCLE.md: Current state behavior and planned state machine with transitions and side effects --- shatter-backend/docs/API_REFERENCE.md | 1085 +++++++++++++++++ shatter-backend/docs/DATABASE_SCHEMA.md | 232 ++++ shatter-backend/docs/EVENT_LIFECYCLE.md | 150 +++ shatter-backend/docs/REALTIME_EVENTS_GUIDE.md | 263 ++++ 4 files changed, 1730 insertions(+) create mode 100644 shatter-backend/docs/API_REFERENCE.md create mode 100644 shatter-backend/docs/DATABASE_SCHEMA.md create mode 100644 shatter-backend/docs/EVENT_LIFECYCLE.md create mode 100644 shatter-backend/docs/REALTIME_EVENTS_GUIDE.md diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md new file mode 100644 index 0000000..798234d --- /dev/null +++ b/shatter-backend/docs/API_REFERENCE.md @@ -0,0 +1,1085 @@ +# Shatter Backend — API Reference + +**Last updated:** 2026-03-01 +**Base URL:** `http://localhost:4000/api` + +--- + +## 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. + +### Standard Error Format + +Most endpoints return errors as: + +```json +{ "error": "Description of the error" } +``` + +Some endpoints use an alternative format: + +```json +{ "success": false, "error": "Description" } +``` + +or: + +```json +{ "success": false, "msg": "Description" } +``` + +### 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": "active" + } + ] +} +``` + +**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 | +| `startDate` | string | Yes | ISO 8601 date | +| `endDate` | string | Yes | Must be after `startDate` | +| `maxParticipant` | number | Yes | | +| `currentState` | string | Yes | Free-form string (no enum) | + +**Success Response (201):** + +```json +{ + "success": true, + "event": { + "_id": "665a...", + "name": "Tech Meetup", + "description": "Monthly networking event", + "joinCode": "48291037", + "startDate": "2025-02-01T18:00:00.000Z", + "endDate": "2025-02-01T21:00:00.000Z", + "maxParticipant": 50, + "participantIds": [], + "currentState": "pending", + "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": "active", + "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"` | + +--- + +### 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)"` | + +--- + +## Planned Endpoints ⏳ + +These endpoints are **not yet implemented**. Do not depend on them. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| PUT | `/api/events/:eventId/status` | Update event lifecycle state (host-only) | +| 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", + "startDate": "2025-02-01T18:00:00.000Z", + "endDate": "2025-02-01T21:00:00.000Z", + "maxParticipant": 50, + "currentState": "pending" + }' +``` + +### 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..7372285 --- /dev/null +++ b/shatter-backend/docs/DATABASE_SCHEMA.md @@ -0,0 +1,232 @@ +# Shatter Backend — Database Schema Reference + +**Last updated:** 2026-03-01 +**Database:** MongoDB with Mongoose ODM +**Collections:** 6 + +--- + +## 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 | +| `startDate` | Date | Yes | — | | +| `endDate` | Date | Yes | — | Must be after `startDate` | +| `maxParticipant` | Number | Yes | — | | +| `participantIds` | [ObjectId] | No | `[]` | Refs `Participant` | +| `currentState` | String | Yes | — | Free-form string (no enum validation) | +| `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..b759f91 --- /dev/null +++ b/shatter-backend/docs/EVENT_LIFECYCLE.md @@ -0,0 +1,150 @@ +# Shatter Backend — Event Lifecycle Guide + +**Last updated:** 2026-03-01 + +--- + +## Current State ✅ + +### How `currentState` Works Today + +The `currentState` field on the Event model is a **free-form string** with no enum, no validation, and no transition enforcement. + +```js +// event_model.ts +currentState: { type: String, required: true } +``` + +- Any string value is accepted when creating an event +- There is no endpoint to update the state after creation +- No logic gates behavior based on state (joining, games, etc. work regardless of state value) +- The backend does not enforce any state machine — the frontend can pass whatever string it wants + +### Current Event Endpoints and State + +| Endpoint | State Behavior | +|----------|---------------| +| `POST /api/events/createEvent` | Sets `currentState` from request body (any string) | +| `GET /api/events/:eventId` | Returns `currentState` as-is | +| `GET /api/events/event/:joinCode` | Returns `currentState` as-is | +| `POST /api/events/:eventId/join/user` | Does **not** check `currentState` | +| `POST /api/events/:eventId/join/guest` | Does **not** check `currentState` | + +**In practice**, frontends have been passing values like `"pending"`, `"active"`, or similar, but the backend does not enforce these. + +--- + +## Planned State Machine ⏳ + +> **This section describes planned functionality that is NOT yet implemented.** + +### States + +``` +pending ──► active ──► ended +``` + +| State | Description | +|-----------|-------------| +| `pending` | Event created, waiting for host to start. Participants can join. | +| `active` | Event is live. Games/activities are in progress. Participants can still join (unless at capacity). | +| `ended` | Event is over. No new joins. Results are finalized. | + +### Transition Rules + +| From | To | Who Can Trigger | Endpoint | +|-----------|----------|-----------------|----------| +| `pending` | `active` | Event creator only | `PUT /api/events/:eventId/status` | +| `active` | `ended` | Event creator only | `PUT /api/events/:eventId/status` | + +Invalid transitions (e.g., `ended` → `active`, `pending` → `ended`) will be rejected. + +### Planned Endpoint: `PUT /api/events/:eventId/status` + +**Auth:** Protected (event creator only) + +**Request Body:** + +```json +{ + "status": "active" +} +``` + +**Validation:** +- Only the user in `createdBy` can change the state +- Only valid transitions are allowed (`pending` → `active`, `active` → `ended`) + +### Side Effects Per Transition + +#### `pending` → `active` +- Trigger Pusher event `event-started` on channel `event-{eventId}` +- Payload: `{ eventId, state: "active", startedAt: }` +- Frontend should transition from lobby/waiting UI to active game UI + +#### `active` → `ended` +- Trigger Pusher event `event-ended` on channel `event-{eventId}` +- Payload: `{ eventId, state: "ended", endedAt: }` +- Lock bingo state (no more updates to player grids) +- Frontend should show results/summary screen + +--- + +## What's Allowed in Each State (Planned) + +| Action | `pending` | `active` | `ended` | +|--------|-----------|----------|---------| +| 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 | +|----------------|-------------| +| `pending` | Lobby / waiting room. Show participant list, join code, QR code. "Waiting for host to start..." | +| `active` | Game screen. Show bingo grid, active activities, connection creation. | +| `ended` | Results screen. Show final scores, connections made, event summary. | + +### Subscribing to State Changes + +Once the state transition endpoint and Pusher events are implemented, subscribe to state changes: + +```js +const channel = pusher.subscribe(`event-${eventId}`); + +channel.bind('event-started', (data) => { + // Transition UI from lobby to active game + setEventState('active'); +}); + +channel.bind('event-ended', (data) => { + // Transition UI from active game to results + setEventState('ended'); +}); +``` + +### Polling Fallback (Current Workaround) + +Since state change events are not yet implemented, frontends can poll the event endpoint: + +```js +// Poll every 5 seconds for state changes +const interval = setInterval(async () => { + const res = await fetch(`/api/events/${eventId}`); + const { event } = await res.json(); + if (event.currentState !== currentState) { + setCurrentState(event.currentState); + } +}, 5000); +``` + +This is a temporary approach and should be replaced with Pusher events once implemented. diff --git a/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md b/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md new file mode 100644 index 0000000..67353e9 --- /dev/null +++ b/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md @@ -0,0 +1,263 @@ +# Shatter Backend — Real-Time Events Guide + +**Last updated:** 2026-03-01 + +--- + +## 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. + +--- + +## Planned Events ⏳ + +These events are **not yet implemented**. Do not depend on them. + +### `event-started` + +**Channel:** `event-{eventId}` + +Triggered when the host starts the event (transitions state to `active`). + +**Expected payload:** + +```json +{ + "eventId": "665a...", + "state": "active", + "startedAt": "2025-02-01T18:00:00.000Z" +} +``` + +### `event-ended` + +**Channel:** `event-{eventId}` + +Triggered when the host ends the event (transitions state to `ended`). + +**Expected payload:** + +```json +{ + "eventId": "665a...", + "state": "ended", + "endedAt": "2025-02-01T21:00:00.000Z" +} +``` + +### `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. From f4ed706b22bc13b5bf7b547d06d54c740eb8f462 Mon Sep 17 00:00:00 2001 From: rxmox Date: Sun, 1 Mar 2026 16:28:15 -0700 Subject: [PATCH 3/7] Add table of contents, endpoint summary table, and response format guide - Add table of contents to all 4 documentation files - Add quick-reference endpoint summary table to API_REFERENCE.md - Document the response format inconsistency across controllers with recommended client-side handling pattern --- shatter-backend/docs/API_REFERENCE.md | 90 +++++++++++++++++-- shatter-backend/docs/DATABASE_SCHEMA.md | 12 +++ shatter-backend/docs/EVENT_LIFECYCLE.md | 18 ++++ shatter-backend/docs/REALTIME_EVENTS_GUIDE.md | 19 ++++ 4 files changed, 133 insertions(+), 6 deletions(-) diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md index 798234d..a28569b 100644 --- a/shatter-backend/docs/API_REFERENCE.md +++ b/shatter-backend/docs/API_REFERENCE.md @@ -5,6 +5,79 @@ --- +## 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) + - [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) +- [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 | +| 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 | + +--- + ## General Information ### Authentication @@ -19,26 +92,31 @@ Tokens are obtained via `/api/auth/signup`, `/api/auth/login`, `/api/auth/exchan Token payload: `{ userId, iat, exp }` — expires in 30 days by default. -### Standard Error Format +### Response Format -Most endpoints return errors as: +> **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" } ``` -Some endpoints use an alternative format: - +**Format 2** — User, Event, Bingo endpoints (`success` + `error`): ```json { "success": false, "error": "Description" } ``` -or: - +**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 | diff --git a/shatter-backend/docs/DATABASE_SCHEMA.md b/shatter-backend/docs/DATABASE_SCHEMA.md index 7372285..30c2c7a 100644 --- a/shatter-backend/docs/DATABASE_SCHEMA.md +++ b/shatter-backend/docs/DATABASE_SCHEMA.md @@ -6,6 +6,18 @@ --- +## 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 ``` diff --git a/shatter-backend/docs/EVENT_LIFECYCLE.md b/shatter-backend/docs/EVENT_LIFECYCLE.md index b759f91..438661a 100644 --- a/shatter-backend/docs/EVENT_LIFECYCLE.md +++ b/shatter-backend/docs/EVENT_LIFECYCLE.md @@ -4,6 +4,24 @@ --- +## Table of Contents + +- [Current State](#current-state-) + - [How `currentState` Works Today](#how-currentstate-works-today) + - [Current Event Endpoints and State](#current-event-endpoints-and-state) +- [Planned State Machine](#planned-state-machine-) + - [States](#states) + - [Transition Rules](#transition-rules) + - [Planned Endpoint](#planned-endpoint-put-apieventseventidstatus) + - [Side Effects Per Transition](#side-effects-per-transition) +- [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) + - [Polling Fallback](#polling-fallback-current-workaround) + +--- + ## Current State ✅ ### How `currentState` Works Today diff --git a/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md b/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md index 67353e9..7b5f074 100644 --- a/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md +++ b/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md @@ -4,6 +4,25 @@ --- +## Table of Contents + +- [Why Pusher?](#why-pusher) +- [Setup](#setup) +- [Channel Naming Convention](#channel-naming-convention) +- [Implemented Events](#implemented-events-) + - [`participant-joined`](#participant-joined) +- [Planned Events](#planned-events-) + - [`event-started`](#event-started) + - [`event-ended`](#event-ended) + - [`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). From 4f50e26fdbd58090576812496f3521ba992c23fd Mon Sep 17 00:00:00 2001 From: rxmox Date: Mon, 2 Mar 2026 12:01:41 -0700 Subject: [PATCH 4/7] Add gameType, eventImg fields and status transition API to Event model - Add gameType (enum: 'Name Bingo', required) and eventImg (optional) to Event schema - Change currentState from free-form string to validated enum ('Upcoming', 'In Progress', 'Completed') with default 'Upcoming' - Add PUT /api/events/:eventId/status endpoint for host-only status transitions - Emit Pusher event-started/event-ended events on status changes - Update API reference, database schema, event lifecycle, and real-time events docs --- shatter-backend/docs/API_REFERENCE.md | 74 +++++++- shatter-backend/docs/DATABASE_SCHEMA.md | 30 +-- shatter-backend/docs/EVENT_LIFECYCLE.md | 176 ++++++++++-------- shatter-backend/docs/REALTIME_EVENTS_GUIDE.md | 44 +++-- .../src/controllers/event_controller.ts | 71 +++++++ shatter-backend/src/models/event_model.ts | 15 +- shatter-backend/src/routes/event_routes.ts | 3 +- 7 files changed, 293 insertions(+), 120 deletions(-) diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md index a28569b..b1a11a4 100644 --- a/shatter-backend/docs/API_REFERENCE.md +++ b/shatter-backend/docs/API_REFERENCE.md @@ -26,6 +26,7 @@ - [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) @@ -64,6 +65,7 @@ Quick reference of all implemented endpoints. See detailed sections below for re | 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 | @@ -416,7 +418,7 @@ Get all events a user has joined (populates event details). "joinCode": "12345678", "startDate": "2025-02-01T18:00:00.000Z", "endDate": "2025-02-01T21:00:00.000Z", - "currentState": "active" + "currentState": "In Progress" } ] } @@ -492,10 +494,12 @@ Create a new event. |------------------|--------|----------|-------| | `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 | Yes | Free-form string (no enum) | +| `currentState` | string | No | One of: `"Upcoming"`, `"In Progress"`, `"Completed"`. Defaults to `"Upcoming"` | +| `eventImg` | string | No | URL for event image | **Success Response (201):** @@ -506,12 +510,14 @@ Create a new 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": "pending", + "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" @@ -555,7 +561,8 @@ Get event details by join code. "participantIds": [ { "_id": "666b...", "name": "John Doe", "userId": "664f..." } ], - "currentState": "active", + "currentState": "Upcoming", + "gameType": "Name Bingo", "createdBy": "664f...", ... } @@ -610,6 +617,60 @@ Get event details by event ID. --- +### 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. @@ -1064,7 +1125,6 @@ These endpoints are **not yet implemented**. Do not depend on them. | Method | Endpoint | Description | |--------|----------|-------------| -| PUT | `/api/events/:eventId/status` | Update event lifecycle state (host-only) | | POST | `/api/events/:eventId/leave` | Leave an event | | DELETE | `/api/events/:eventId` | Cancel/delete an event | | GET | `/api/events/:eventId/participants` | Search/list participants | @@ -1110,10 +1170,10 @@ curl -X POST http://localhost:4000/api/events/createEvent \ -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, - "currentState": "pending" + "maxParticipant": 50 }' ``` diff --git a/shatter-backend/docs/DATABASE_SCHEMA.md b/shatter-backend/docs/DATABASE_SCHEMA.md index 30c2c7a..97b840b 100644 --- a/shatter-backend/docs/DATABASE_SCHEMA.md +++ b/shatter-backend/docs/DATABASE_SCHEMA.md @@ -102,20 +102,22 @@ ### 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 | -| `startDate` | Date | Yes | — | | -| `endDate` | Date | Yes | — | Must be after `startDate` | -| `maxParticipant` | Number | Yes | — | | -| `participantIds` | [ObjectId] | No | `[]` | Refs `Participant` | -| `currentState` | String | Yes | — | Free-form string (no enum validation) | -| `createdBy` | ObjectId | Yes | — | User who created the event (no ref set) | -| `createdAt` | Date | Auto | Auto | Mongoose timestamps | -| `updatedAt` | Date | Auto | Auto | Mongoose timestamps | +| 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 diff --git a/shatter-backend/docs/EVENT_LIFECYCLE.md b/shatter-backend/docs/EVENT_LIFECYCLE.md index 438661a..f4164ae 100644 --- a/shatter-backend/docs/EVENT_LIFECYCLE.md +++ b/shatter-backend/docs/EVENT_LIFECYCLE.md @@ -6,112 +6,139 @@ ## Table of Contents -- [Current State](#current-state-) - - [How `currentState` Works Today](#how-currentstate-works-today) - - [Current Event Endpoints and State](#current-event-endpoints-and-state) -- [Planned State Machine](#planned-state-machine-) - - [States](#states) - - [Transition Rules](#transition-rules) - - [Planned Endpoint](#planned-endpoint-put-apieventseventidstatus) +- [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) - - [Polling Fallback](#polling-fallback-current-workaround) --- -## Current State ✅ +## Event States -### How `currentState` Works Today +### State Enum -The `currentState` field on the Event model is a **free-form string** with no enum, no validation, and no transition enforcement. +The `currentState` field on the Event model is a **validated enum** with three possible values: ```js // event_model.ts -currentState: { type: String, required: true } +currentState: { + type: String, + enum: ['Upcoming', 'In Progress', 'Completed'], + default: 'Upcoming', + required: true +} ``` -- Any string value is accepted when creating an event -- There is no endpoint to update the state after creation -- No logic gates behavior based on state (joining, games, etc. work regardless of state value) -- The backend does not enforce any state machine — the frontend can pass whatever string it wants +| 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. | -### Current Event Endpoints and State +These values match the mobile app's `EventState` enum exactly (title case with spaces). -| Endpoint | State Behavior | -|----------|---------------| -| `POST /api/events/createEvent` | Sets `currentState` from request body (any string) | -| `GET /api/events/:eventId` | Returns `currentState` as-is | -| `GET /api/events/event/:joinCode` | Returns `currentState` as-is | -| `POST /api/events/:eventId/join/user` | Does **not** check `currentState` | -| `POST /api/events/:eventId/join/guest` | Does **not** check `currentState` | +### State Diagram -**In practice**, frontends have been passing values like `"pending"`, `"active"`, or similar, but the backend does not enforce these. +``` +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 -## Planned State Machine ⏳ +--- -> **This section describes planned functionality that is NOT yet implemented.** +## Transition Rules -### States +### Valid Transitions -``` -pending ──► active ──► ended -``` +| 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` | -| State | Description | -|-----------|-------------| -| `pending` | Event created, waiting for host to start. Participants can join. | -| `active` | Event is live. Games/activities are in progress. Participants can still join (unless at capacity). | -| `ended` | Event is over. No new joins. Results are finalized. | +Invalid transitions (e.g., `Completed` → `In Progress`, `Upcoming` → `Completed`) are rejected with a `400` error. -### Transition Rules +### Transition Endpoint: `PUT /api/events/:eventId/status` -| From | To | Who Can Trigger | Endpoint | -|-----------|----------|-----------------|----------| -| `pending` | `active` | Event creator only | `PUT /api/events/:eventId/status` | -| `active` | `ended` | Event creator only | `PUT /api/events/:eventId/status` | +**Auth:** Protected (event creator only — `event.createdBy === req.user.userId`) -Invalid transitions (e.g., `ended` → `active`, `pending` → `ended`) will be rejected. +**Request Body:** -### Planned Endpoint: `PUT /api/events/:eventId/status` +```json +{ + "status": "In Progress" +} +``` -**Auth:** Protected (event creator only) +**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) -**Request Body:** +**Success Response (200):** ```json { - "status": "active" + "success": true, + "event": { + "_id": "665a...", + "name": "Tech Meetup", + "currentState": "In Progress", + ... + } } ``` -**Validation:** -- Only the user in `createdBy` can change the state -- Only valid transitions are allowed (`pending` → `active`, `active` → `ended`) +**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 -#### `pending` → `active` -- Trigger Pusher event `event-started` on channel `event-{eventId}` -- Payload: `{ eventId, state: "active", startedAt: }` +#### `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 -#### `active` → `ended` -- Trigger Pusher event `event-ended` on channel `event-{eventId}` -- Payload: `{ eventId, state: "ended", endedAt: }` -- Lock bingo state (no more updates to player grids) +#### `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 | `pending` | `active` | `ended` | -|--------|-----------|----------|---------| +| Action | `Upcoming` | `In Progress` | `Completed` | +|--------|-----------|----------------|-------------| | Join event | Yes | Yes (if not full) | No | | Leave event | Yes | Yes | No | | View participants | Yes | Yes | Yes | @@ -128,41 +155,26 @@ Invalid transitions (e.g., `ended` → `active`, `pending` → `ended`) will be | `currentState` | Suggested UI | |----------------|-------------| -| `pending` | Lobby / waiting room. Show participant list, join code, QR code. "Waiting for host to start..." | -| `active` | Game screen. Show bingo grid, active activities, connection creation. | -| `ended` | Results screen. Show final scores, connections made, event summary. | +| `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 -Once the state transition endpoint and Pusher events are implemented, subscribe 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('active'); + setEventState('In Progress'); }); channel.bind('event-ended', (data) => { + // data.status === 'Completed' // Transition UI from active game to results - setEventState('ended'); + setEventState('Completed'); }); ``` - -### Polling Fallback (Current Workaround) - -Since state change events are not yet implemented, frontends can poll the event endpoint: - -```js -// Poll every 5 seconds for state changes -const interval = setInterval(async () => { - const res = await fetch(`/api/events/${eventId}`); - const { event } = await res.json(); - if (event.currentState !== currentState) { - setCurrentState(event.currentState); - } -}, 5000); -``` - -This is a temporary approach and should be replaced with Pusher events once implemented. diff --git a/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md b/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md index 7b5f074..987aeea 100644 --- a/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md +++ b/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md @@ -11,9 +11,9 @@ - [Channel Naming Convention](#channel-naming-convention) - [Implemented Events](#implemented-events-) - [`participant-joined`](#participant-joined) -- [Planned Events](#planned-events-) - [`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) @@ -112,42 +112,56 @@ Each event has its own channel. Subscribe when a user enters an event, unsubscri --- -## Planned Events ⏳ - -These events are **not yet implemented**. Do not depend on them. - ### `event-started` **Channel:** `event-{eventId}` -Triggered when the host starts the event (transitions state to `active`). +**Triggered when:** +- The event host transitions the event status from `Upcoming` to `In Progress` (`PUT /api/events/:eventId/status`) -**Expected payload:** +**Payload:** ```json { - "eventId": "665a...", - "state": "active", - "startedAt": "2025-02-01T18:00:00.000Z" + "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 host ends the event (transitions state to `ended`). +**Triggered when:** +- The event host transitions the event status from `In Progress` to `Completed` (`PUT /api/events/:eventId/status`) -**Expected payload:** +**Payload:** ```json { - "eventId": "665a...", - "state": "ended", - "endedAt": "2025-02-01T21:00:00.000Z" + "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}` diff --git a/shatter-backend/src/controllers/event_controller.ts b/shatter-backend/src/controllers/event_controller.ts index f884d3a..ad29ad6 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 }); @@ -355,6 +359,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/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/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); From 15b36309ab1e88df3c25dd6b24135fb1e471049f Mon Sep 17 00:00:00 2001 From: rxmox Date: Mon, 2 Mar 2026 12:16:42 -0700 Subject: [PATCH 5/7] Return 400 for Mongoose validation errors in createEvent The catch block was returning 500 for all errors, including Mongoose ValidationError (e.g., missing or invalid gameType). Now checks for ValidationError by name and returns 400 instead. --- shatter-backend/src/controllers/event_controller.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shatter-backend/src/controllers/event_controller.ts b/shatter-backend/src/controllers/event_controller.ts index ad29ad6..5466b73 100644 --- a/shatter-backend/src/controllers/event_controller.ts +++ b/shatter-backend/src/controllers/event_controller.ts @@ -75,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 }); } } From 065f1c8a5196c2a2cfe2e94b8aecaeea338a0ea1 Mon Sep 17 00:00:00 2001 From: Le Nguyen Quang Minh <101281380+lnqminh3003@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:26:28 -0700 Subject: [PATCH 6/7] Fetch connected user's info --- .../participant_connections_controller.ts | 236 +++++++++++++++--- .../src/models/participant_model.ts | 2 +- .../routes/participant_connections_routes.ts | 4 + 3 files changed, 212 insertions(+), 30 deletions(-) diff --git a/shatter-backend/src/controllers/participant_connections_controller.ts b/shatter-backend/src/controllers/participant_connections_controller.ts index d206b22..b2b5a9e 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,110 @@ 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/getParticipantConnections/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 + * @returns 400 - Missing/invalid params + * @returns 404 - Participant not found or no connections + * @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(404) + .json({ error: "No connections found for this participant" }); + } + + const connectedMap = connections.map((conn) => { + const otherParticipantId = + conn.primaryParticipantId.toString() === participantId + ? conn.secondaryParticipantId + : conn.primaryParticipantId; + return { + participantId: otherParticipantId, + description: conn.description || null, + }; + }); + + // Remove duplicate connections for the same participant + const uniqueMap = Array.from( + new Map( + connectedMap.map((item) => [item.participantId.toString(), item]), + ).values(), + ); + + const participantIds = uniqueMap.map((item) => item.participantId); + + const participants = await Participant.find({ + _id: { $in: participantIds }, + }).select("userId name"); + const userIds = participants.map((p) => p.userId); + + const users = await User.find({ _id: { $in: userIds } }).select( + "name email linkedinUrl bio profilePhoto socialLinks", + ); + + const result = uniqueMap + .map((item) => { + const participant = participants.find( + (p) => p._id && p._id.toString() === item.participantId.toString(), + ); + + if (!participant || !participant.userId) return null; + + const user = users.find( + (u) => u._id.toString() === participant.userId.toString(), + ); + + if (!user) return null; + + return { + user, + participantId: participant._id, + participantName: participant.name, + connectionDescription: item.description, + }; + }) + .filter(Boolean); + + return res.status(200).json(result); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} diff --git a/shatter-backend/src/models/participant_model.ts b/shatter-backend/src/models/participant_model.ts index f26a574..67f10bb 100644 --- a/shatter-backend/src/models/participant_model.ts +++ b/shatter-backend/src/models/participant_model.ts @@ -1,7 +1,7 @@ import { Schema, model, Document } from "mongoose"; export interface IParticipant extends Document { - userId: Schema.Types.ObjectId | null; + userId: Schema.Types.ObjectId; name: string; eventId: Schema.Types.ObjectId; } diff --git a/shatter-backend/src/routes/participant_connections_routes.ts b/shatter-backend/src/routes/participant_connections_routes.ts index 383168e..0cea1a6 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,7 @@ router.get("/getByParticipantAndEvent", authMiddleware, getConnectionsByParticip // GET /api/participantConnections/getByUserEmailAndEvent router.get("/getByUserEmailAndEvent", authMiddleware, getConnectionsByUserEmailAndEvent); +// Get all user's information that connected with the participant +router.get("/getParticipantConnections/connected-users", authMiddleware, getConnectedUsersInfo); + export default router; \ No newline at end of file From f1433b3fb989020335b7424c47e6276947c5eb44 Mon Sep 17 00:00:00 2001 From: rxmox Date: Fri, 6 Mar 2026 21:28:48 -0700 Subject: [PATCH 7/7] Improve getConnectedUsersInfo endpoint and update docs Simplify route path to /connected-users, return 200 with empty array instead of 404 when no connections exist, remove dedup to preserve all connections with different descriptions, use populate to reduce DB queries, handle null userId gracefully, and restore nullable userId type on Participant interface. --- shatter-backend/docs/API_REFERENCE.md | 47 +++++++++++++ .../participant_connections_controller.ts | 66 ++++++++----------- .../src/models/participant_model.ts | 2 +- .../routes/participant_connections_routes.ts | 3 +- 4 files changed, 79 insertions(+), 39 deletions(-) diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md index b1a11a4..f1f50a8 100644 --- a/shatter-backend/docs/API_REFERENCE.md +++ b/shatter-backend/docs/API_REFERENCE.md @@ -40,6 +40,7 @@ - [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) @@ -77,6 +78,7 @@ Quick reference of all implemented endpoints. See detailed sections below for re | 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 | --- @@ -1119,6 +1121,51 @@ Get all connections for a user (by email) in an event. --- +### 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. diff --git a/shatter-backend/src/controllers/participant_connections_controller.ts b/shatter-backend/src/controllers/participant_connections_controller.ts index b2b5a9e..e66874e 100644 --- a/shatter-backend/src/controllers/participant_connections_controller.ts +++ b/shatter-backend/src/controllers/participant_connections_controller.ts @@ -369,7 +369,7 @@ export async function getConnectionsByUserEmailAndEvent( } /** - * GET /api/participantConnections/getParticipantConnections/connected-users + * GET /api/participantConnections/connected-users * * Get all users connected with a given participant in an event, * including the description of the connection. @@ -377,9 +377,8 @@ export async function getConnectionsByUserEmailAndEvent( * @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 + * @returns 200 - Array of connected users with connection descriptions (empty array if none) * @returns 400 - Missing/invalid params - * @returns 404 - Participant not found or no connections * @returns 500 - Internal server error */ export async function getConnectedUsersInfo(req: Request, res: Response) { @@ -411,12 +410,11 @@ export async function getConnectedUsersInfo(req: Request, res: Response) { }); if (!connections.length) { - return res - .status(404) - .json({ error: "No connections found for this participant" }); + return res.status(200).json([]); } - const connectedMap = connections.map((conn) => { + // Map each connection to the other participant's ID + description + const connectedItems = connections.map((conn) => { const otherParticipantId = conn.primaryParticipantId.toString() === participantId ? conn.secondaryParticipantId @@ -427,46 +425,40 @@ export async function getConnectedUsersInfo(req: Request, res: Response) { }; }); - // Remove duplicate connections for the same participant - const uniqueMap = Array.from( - new Map( - connectedMap.map((item) => [item.participantId.toString(), item]), - ).values(), - ); - - const participantIds = uniqueMap.map((item) => item.participantId); + 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"); - const userIds = participants.map((p) => p.userId); + }) + .select("userId name") + .populate("userId", "name email linkedinUrl bio profilePhoto socialLinks"); - const users = await User.find({ _id: { $in: userIds } }).select( - "name email linkedinUrl bio profilePhoto socialLinks", + const participantMap = new Map( + participants.map((p) => [(p._id as Types.ObjectId).toString(), p]), ); - const result = uniqueMap - .map((item) => { - const participant = participants.find( - (p) => p._id && p._id.toString() === item.participantId.toString(), - ); - - if (!participant || !participant.userId) return null; - - const user = users.find( - (u) => u._id.toString() === participant.userId.toString(), - ); - - if (!user) return null; + const result = connectedItems.map((item) => { + const participant = participantMap.get(item.participantId.toString()); + if (!participant) { return { - user, - participantId: participant._id, - participantName: participant.name, + user: null, + participantId: item.participantId, + participantName: null, connectionDescription: item.description, }; - }) - .filter(Boolean); + } + + return { + user: participant.userId || null, + participantId: participant._id, + participantName: participant.name, + connectionDescription: item.description, + }; + }); return res.status(200).json(result); } catch (_error) { diff --git a/shatter-backend/src/models/participant_model.ts b/shatter-backend/src/models/participant_model.ts index 67f10bb..f26a574 100644 --- a/shatter-backend/src/models/participant_model.ts +++ b/shatter-backend/src/models/participant_model.ts @@ -1,7 +1,7 @@ import { Schema, model, Document } from "mongoose"; export interface IParticipant extends Document { - userId: Schema.Types.ObjectId; + userId: Schema.Types.ObjectId | null; name: string; eventId: Schema.Types.ObjectId; } diff --git a/shatter-backend/src/routes/participant_connections_routes.ts b/shatter-backend/src/routes/participant_connections_routes.ts index 0cea1a6..8bb6dc8 100644 --- a/shatter-backend/src/routes/participant_connections_routes.ts +++ b/shatter-backend/src/routes/participant_connections_routes.ts @@ -59,6 +59,7 @@ router.get("/getByParticipantAndEvent", authMiddleware, getConnectionsByParticip router.get("/getByUserEmailAndEvent", authMiddleware, getConnectionsByUserEmailAndEvent); // Get all user's information that connected with the participant -router.get("/getParticipantConnections/connected-users", authMiddleware, getConnectedUsersInfo); +// GET /api/participantConnections/connected-users +router.get("/connected-users", authMiddleware, getConnectedUsersInfo); export default router; \ No newline at end of file