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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions shatter-backend/docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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`
Expand Down
10 changes: 6 additions & 4 deletions shatter-backend/docs/DATABASE_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 35 additions & 0 deletions shatter-backend/docs/REALTIME_EVENTS_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
177 changes: 177 additions & 0 deletions shatter-backend/src/controllers/leaderboard_controller.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<string>>();

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<string, number | boolean> = {};
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<string>();
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" });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading