diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md index a3ba4b7..d67b14b 100644 --- a/shatter-backend/docs/API_REFERENCE.md +++ b/shatter-backend/docs/API_REFERENCE.md @@ -39,6 +39,8 @@ - [DELETE `/api/events/:eventId`](#delete-apieventseventid) - [GET `/api/events/createdEvents/user/:userId`](#get-apieventscreatedeventsuseruserid) - [PUT `/api/events/:eventId`](#put-apieventseventid) + - [GET `/api/events/:eventId/leaderboard`](#get-apieventseventidleaderboard) + - [PUT `/api/events/:eventId/leaderboard/score`](#put-apieventseventidleaderboardscore) - [Bingo (`/api/bingo`)](#bingo-apibingo) - [POST `/api/bingo/createBingo`](#post-apibingocreatebingo) - [GET `/api/bingo/getBingo/:eventId`](#get-apibingogetbingoeventid) @@ -90,6 +92,8 @@ Quick reference of all implemented endpoints. See detailed sections below for re | POST | `/api/events/:eventId/leave` | Protected | Leave event as participant | | DELETE | `/api/events/:eventId` | Protected | Delete/cancel event (host-only) | | GET | `/api/events/createdEvents/user/:userId` | Protected | Get events created by user | +| GET | `/api/events/:eventId/leaderboard` | Protected | Get bingo leaderboard for event | +| PUT | `/api/events/:eventId/leaderboard/score` | Protected | Update own bingo score | | 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 | @@ -1068,6 +1072,90 @@ Update an existing event's basic information (host only). Only the fields provid --- +### GET `/api/events/:eventId/leaderboard` + +Get the bingo leaderboard for an event. Returns participants sorted by completion status and lines completed. Connections count is computed from ParticipantConnection records (unique connected partners). + +- **Auth:** Protected + +**URL Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `eventId` | ObjectId | Yes | The event ID | + +**Response (200):** + +```json +[ + { + "participantId": "abc123", + "name": "Jane Doe", + "profilePhoto": "https://example.com/photo.jpg", + "connectionsCount": 5, + "linesCompleted": 3, + "completed": true + } +] +``` + +**Sort order:** `completed` desc (completed first), then `linesCompleted` desc, then `connectionsCount` desc. + +**Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `participantId` | string | Participant document ID | +| `name` | string | User's display name | +| `profilePhoto` | string \| null | User's profile photo URL | +| `connectionsCount` | number | Number of unique people connected with (from ParticipantConnection records) | +| `linesCompleted` | number | Completed bingo lines (vertical, horizontal, diagonal) | +| `completed` | boolean | Whether the entire bingo sheet is filled | + +--- + +### PUT `/api/events/:eventId/leaderboard/score` + +Update the authenticated user's bingo score for an event. Triggers a Pusher `leaderboard-updated` event on channel `event-{eventId}`. + +- **Auth:** Protected + +**URL Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `eventId` | ObjectId | Yes | The event ID | + +**Request Body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `linesCompleted` | number | No | Number of completed bingo lines | +| `completed` | boolean | No | Whether the entire bingo sheet is filled | + +At least one field must be provided. + +**Response (200):** + +```json +{ + "participantId": "abc123", + "name": "Jane Doe", + "linesCompleted": 3, + "completed": false, + "connectionsCount": 2 +} +``` + +**Error Responses:** + +| Status | Description | +|--------|-------------| +| 400 | Invalid eventId or no valid fields provided | +| 404 | Participant not found for this event | + +--- + ## Bingo (`/api/bingo`) ### POST `/api/bingo/createBingo` diff --git a/shatter-backend/docs/DATABASE_SCHEMA.md b/shatter-backend/docs/DATABASE_SCHEMA.md index e68af11..4835720 100644 --- a/shatter-backend/docs/DATABASE_SCHEMA.md +++ b/shatter-backend/docs/DATABASE_SCHEMA.md @@ -145,10 +145,12 @@ | 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` | +| `_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` | +| `linesCompleted` | Number | No | `0` | Completed bingo lines (vertical, horizontal, diagonal) | +| `completed` | Boolean | No | `false` | Whether the entire bingo sheet is filled | ### Indexes diff --git a/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md b/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md index 76a4d52..6c4aa5c 100644 --- a/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md +++ b/shatter-backend/docs/REALTIME_EVENTS_GUIDE.md @@ -15,6 +15,7 @@ - [`event-ended`](#event-ended) - [`participant-left`](#participant-left) - [`event-deleted`](#event-deleted) + - [`leaderboard-updated`](#leaderboard-updated) - [Planned Events](#planned-events-) - [`bingo-achieved`](#bingo-achieved) - [Client Integration Examples](#client-integration-examples) @@ -210,6 +211,40 @@ Each event has its own channel. Subscribe when a user enters an event, unsubscri --- +### `leaderboard-updated` + +**Channel:** `event-{eventId}` + +Triggered when a participant updates their bingo score via `PUT /api/events/:eventId/leaderboard/score`. + +**Payload:** + +```json +{ + "participantId": "666b...", + "name": "Jane Doe", + "linesCompleted": 3, + "connectionsCount": 5, + "completed": false +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `participantId` | string | The participant who updated their score | +| `name` | string | Participant's display name | +| `linesCompleted` | number | Completed bingo lines (vertical, horizontal, diagonal) | +| `connectionsCount` | number | Unique connected partners count | +| `completed` | boolean | Whether the entire bingo sheet is filled | + +**Sources:** +- `src/controllers/leaderboard_controller.ts` → `updateScore()` (score changes) +- `src/controllers/participant_connections_controller.ts` → `createParticipantConnection()`, `createParticipantConnectionByEmails()`, `deleteParticipantConnection()` (connection changes — payload is `{}`) + +**Recommended client action:** Re-fetch the full leaderboard via `GET /api/events/:eventId/leaderboard` to get the latest sorted data. + +--- + ## Planned Events ⏳ These events are **not yet implemented**. Do not depend on them. diff --git a/shatter-backend/src/controllers/leaderboard_controller.ts b/shatter-backend/src/controllers/leaderboard_controller.ts new file mode 100644 index 0000000..3305169 --- /dev/null +++ b/shatter-backend/src/controllers/leaderboard_controller.ts @@ -0,0 +1,177 @@ +import { Request, Response } from "express"; +import { Types } from "mongoose"; +import { Participant } from "../models/participant_model.js"; +import { ParticipantConnection } from "../models/participant_connection_model.js"; +import { pusher } from "../utils/pusher_websocket.js"; + +/** + * GET /api/events/:eventId/leaderboard + * + * Returns the bingo leaderboard for an event, sorted by completion status + * and lines completed. Connections count is computed from ParticipantConnection + * records (unique connected partners per participant). + * + * @returns 200 - Sorted leaderboard array + * @returns 400 - Invalid eventId + * @returns 500 - Internal server error + */ +export async function getLeaderboard(req: Request, res: Response) { + try { + const eventId = req.params.eventId as string; + + if (!Types.ObjectId.isValid(eventId)) { + return res.status(400).json({ error: "Invalid eventId" }); + } + + const eventObjectId = new Types.ObjectId(eventId); + + // Fetch participants and connections in parallel + const [participants, connections] = await Promise.all([ + Participant.find({ eventId: eventObjectId }) + .select("name linesCompleted completed userId") + .populate("userId", "name profilePhoto") + .lean(), + ParticipantConnection.find({ _eventId: eventObjectId }) + .select("primaryParticipantId secondaryParticipantId") + .lean(), + ]); + + // Build connections count: unique connected partners per participant + const connectionsMap = new Map>(); + + for (const conn of connections) { + const primaryId = conn.primaryParticipantId.toString(); + const secondaryId = conn.secondaryParticipantId.toString(); + + if (!connectionsMap.has(primaryId)) { + connectionsMap.set(primaryId, new Set()); + } + if (!connectionsMap.has(secondaryId)) { + connectionsMap.set(secondaryId, new Set()); + } + + connectionsMap.get(primaryId)!.add(secondaryId); + connectionsMap.get(secondaryId)!.add(primaryId); + } + + // Build leaderboard entries + const leaderboard = participants.map((p) => { + const participantId = (p._id as Types.ObjectId).toString(); + const user = p.userId as { name?: string; profilePhoto?: string } | null; + + return { + participantId, + name: user?.name || p.name, + profilePhoto: user?.profilePhoto || null, + connectionsCount: connectionsMap.get(participantId)?.size || 0, + linesCompleted: p.linesCompleted || 0, + completed: p.completed || false, + }; + }); + + // Sort: completed first, then by linesCompleted desc, then connectionsCount desc + leaderboard.sort((a, b) => { + if (a.completed !== b.completed) return a.completed ? -1 : 1; + if (a.linesCompleted !== b.linesCompleted) + return b.linesCompleted - a.linesCompleted; + return b.connectionsCount - a.connectionsCount; + }); + + return res.status(200).json(leaderboard); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +/** + * PUT /api/events/:eventId/leaderboard/score + * + * Updates the authenticated user's bingo score for an event. + * Triggers a Pusher event for live leaderboard updates. + * + * @param req.body.linesCompleted - Number of completed lines (optional) + * @param req.body.completed - Whether the entire bingo sheet is filled (optional) + * + * @returns 200 - Updated score fields + * @returns 400 - Invalid eventId or no valid fields provided + * @returns 404 - Participant not found for this event + * @returns 500 - Internal server error + */ +export async function updateScore(req: Request, res: Response) { + try { + const eventId = req.params.eventId as string; + const userId = req.user?.userId; + + if (!Types.ObjectId.isValid(eventId)) { + return res.status(400).json({ error: "Invalid eventId" }); + } + + const { linesCompleted, completed } = req.body as { + linesCompleted?: number; + completed?: boolean; + }; + + // Build update object with only provided fields + const update: Record = {}; + if (typeof linesCompleted === "number") update.linesCompleted = linesCompleted; + if (typeof completed === "boolean") update.completed = completed; + + if (Object.keys(update).length === 0) { + return res.status(400).json({ + error: "At least one of linesCompleted or completed must be provided", + }); + } + + const participant = await Participant.findOneAndUpdate( + { userId, eventId }, + { $set: update }, + { new: true } + ).select("name linesCompleted completed"); + + if (!participant) { + return res.status(404).json({ + error: "Participant not found for this event", + }); + } + + // Compute connections count for the Pusher payload + const participantId = (participant._id as Types.ObjectId).toString(); + const connections = await ParticipantConnection.find({ + _eventId: new Types.ObjectId(eventId), + $or: [ + { primaryParticipantId: participant._id }, + { secondaryParticipantId: participant._id }, + ], + }) + .select("primaryParticipantId secondaryParticipantId") + .lean(); + + const uniquePartners = new Set(); + for (const conn of connections) { + const otherId = + conn.primaryParticipantId.toString() === participantId + ? conn.secondaryParticipantId.toString() + : conn.primaryParticipantId.toString(); + uniquePartners.add(otherId); + } + + // Trigger live update + await pusher.trigger(`event-${eventId}`, "leaderboard-updated", { + participantId, + name: participant.name, + linesCompleted: participant.linesCompleted, + connectionsCount: uniquePartners.size, + completed: participant.completed, + }); + + return res.status(200).json({ + participantId, + name: participant.name, + linesCompleted: participant.linesCompleted, + completed: participant.completed, + connectionsCount: uniquePartners.size, + }); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} diff --git a/shatter-backend/src/controllers/participant_connections_controller.ts b/shatter-backend/src/controllers/participant_connections_controller.ts index d2ee3e6..08653b1 100644 --- a/shatter-backend/src/controllers/participant_connections_controller.ts +++ b/shatter-backend/src/controllers/participant_connections_controller.ts @@ -7,6 +7,7 @@ import { check_req_fields } from "../utils/requests_utils.js"; import { User } from "../models/user_model.js"; import { Participant } from "../models/participant_model.js"; import { ParticipantConnection } from "../models/participant_connection_model.js"; +import { pusher } from "../utils/pusher_websocket.js"; /** * POST /api/participantConnections @@ -104,6 +105,8 @@ export async function createParticipantConnection(req: Request, res: Response) { description, }); + await pusher.trigger(`event-${_eventId}`, "leaderboard-updated", {}); + return res.status(201).json(newConnection); } catch (_error) { return res.status(500).json({ error: "Internal server error" }); @@ -229,6 +232,8 @@ export async function createParticipantConnectionByEmails( description, }); + await pusher.trigger(`event-${_eventId}`, "leaderboard-updated", {}); + return res.status(201).json(newConnection); } catch (_error) { return res.status(500).json({ error: "Internal server error" }); @@ -271,6 +276,8 @@ export async function deleteParticipantConnection(req: Request, res: Response) { .json({ error: "ParticipantConnection not found for this event" }); } + await pusher.trigger(`event-${eventId}`, "leaderboard-updated", {}); + return res.status(200).json({ message: "ParticipantConnection deleted successfully", deletedConnection: deleted, diff --git a/shatter-backend/src/models/participant_model.ts b/shatter-backend/src/models/participant_model.ts index f26a574..0e45b94 100644 --- a/shatter-backend/src/models/participant_model.ts +++ b/shatter-backend/src/models/participant_model.ts @@ -4,12 +4,16 @@ export interface IParticipant extends Document { userId: Schema.Types.ObjectId | null; name: string; eventId: Schema.Types.ObjectId; + linesCompleted: number; + completed: boolean; } const ParticipantSchema = new Schema({ userId: { type: Schema.Types.ObjectId, ref: "User", default: null }, name: { type: String, required: true }, eventId: { type: Schema.Types.ObjectId, ref: "Event", required: true }, + linesCompleted: { type: Number, default: 0 }, + completed: { type: Boolean, default: false }, }); ParticipantSchema.index( diff --git a/shatter-backend/src/routes/event_routes.ts b/shatter-backend/src/routes/event_routes.ts index 6d3e447..ee4558e 100644 --- a/shatter-backend/src/routes/event_routes.ts +++ b/shatter-backend/src/routes/event_routes.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { createEvent, getEventByJoinCode, getEventById, joinEventAsUser, joinEventAsGuest, getEventsByUserId, updateEventStatus, leaveEvent, deleteEvent, updateEvent } from '../controllers/event_controller.js'; +import { getLeaderboard, updateScore } from '../controllers/leaderboard_controller.js'; import { authMiddleware } from '../middleware/auth_middleware.js'; const router = Router(); @@ -15,5 +16,7 @@ router.post("/:eventId/leave", authMiddleware, leaveEvent); router.delete("/:eventId", authMiddleware, deleteEvent); router.get("/createdEvents/user/:userId", authMiddleware, getEventsByUserId); router.put("/:eventId", authMiddleware, updateEvent); +router.get("/:eventId/leaderboard", authMiddleware, getLeaderboard); +router.put("/:eventId/leaderboard/score", authMiddleware, updateScore); export default router;