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..5466b73 100644 --- a/shatter-backend/src/controllers/event_controller.ts +++ b/shatter-backend/src/controllers/event_controller.ts @@ -35,6 +35,8 @@ export async function createEvent(req: Request, res: Response) { endDate, maxParticipant, currentState, + gameType, + eventImg, } = req.body; const createdBy = req.user!.userId; @@ -64,6 +66,8 @@ export async function createEvent(req: Request, res: Response) { maxParticipant, participantIds: [], currentState, + gameType, + eventImg, createdBy, // user id }); @@ -71,6 +75,9 @@ export async function createEvent(req: Request, res: Response) { res.status(201).json({ success: true, event: savedEvent }); } catch (err: any) { + if (err.name === 'ValidationError') { + return res.status(400).json({ success: false, error: err.message }); + } res.status(500).json({ success: false, error: err.message }); } } @@ -355,6 +362,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);