diff --git a/shatter-backend/src/app.ts b/shatter-backend/src/app.ts index 401d13a..7f5f7ea 100644 --- a/shatter-backend/src/app.ts +++ b/shatter-backend/src/app.ts @@ -1,10 +1,11 @@ import express from "express"; import cors from "cors"; -import userRoutes from './routes/user_route'; // these routes define how to handle requests to /api/users +import userRoutes from './routes/user_route'; import authRoutes from './routes/auth_routes'; import eventRoutes from './routes/event_routes'; import bingoRoutes from './routes/bingo_routes'; +import participantConnectionRoutes from "./routes/participant_connections_routes"; const app = express(); @@ -43,5 +44,6 @@ app.use('/api/users', userRoutes); app.use('/api/auth', authRoutes); app.use('/api/events', eventRoutes); app.use('/api/bingo', bingoRoutes); +app.use("/api/participantConnections", participantConnectionRoutes); -export default app; +export default app; \ No newline at end of file diff --git a/shatter-backend/src/controllers/participant_connections_controller.ts b/shatter-backend/src/controllers/participant_connections_controller.ts new file mode 100644 index 0000000..d206b22 --- /dev/null +++ b/shatter-backend/src/controllers/participant_connections_controller.ts @@ -0,0 +1,297 @@ +// controllers/participant_connections_controller.ts + +import { Request, Response } from "express"; +import { Types } from "mongoose"; + +import { check_req_fields } from "../utils/requests_utils"; +import { User } from "../models/user_model"; +import { Participant } from "../models/participant_model"; +import { ParticipantConnection } from "../models/participant_connection_model"; + +/** + * POST /api/participantConnections + * Create a new ParticipantConnection document. + * The server generates the ParticipantConnection `_id` automatically (pre-save hook). + * + * @param req.body._eventId - MongoDB ObjectId of the event (required) + * @param req.body.primaryParticipantId - MongoDB ObjectId of the primary participant (required) + * @param req.body.secondaryParticipantId - MongoDB ObjectId of the secondary participant (required) + * @param req.body.description - Optional description for the connection, can be the bingo question the participants connected with (optional) + * + * Behavior notes: + * - Validates ObjectId format before hitting the DB + * - Ensures BOTH participants exist AND belong to the given event + * - Prevents duplicates with exact same (_eventId, primaryParticipantId, secondaryParticipantId) + * + * @returns 201 - Created ParticipantConnection document + * @returns 400 - Missing required fields or invalid ObjectId format + * @returns 404 - Primary participant or secondary participant not found for this event + * @returns 409 - ParticipantConnection already exists for this event and participants + * @returns 500 - Internal server error + */ +export async function createParticipantConnection(req: Request, res: Response) { + try { + 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; + + // Validate ObjectId format before hitting the DB + 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}` }); + } + } + + // 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"), + ]); + + if (!primaryParticipant) { + 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" }); + } + + // Prevent duplicates with exact same (_eventId, primaryParticipantId, secondaryParticipantId) + const existing = await ParticipantConnection.findOne({ + _eventId, + primaryParticipantId, + secondaryParticipantId, + }); + + if (existing) { + return res.status(409).json({ + error: "ParticipantConnection already exists for this event and participants", + existingConnection: existing, + }); + } + + const newConnection = await ParticipantConnection.create({ + _eventId, + primaryParticipantId, + secondaryParticipantId, + description, + }); + + return res.status(201).json(newConnection); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +/** + * POST /api/participantConnections/by-emails + * Create a new ParticipantConnection by providing USER emails. + * + * Looks up users by email, then finds their Participant records for the given event, + * then saves the connection using the participants' MongoDB ObjectIds. + * The server generates the ParticipantConnection `_id` automatically (pre-save hook). + * + * @param req.body._eventId - MongoDB ObjectId of the event (required) + * @param req.body.primaryUserEmail - Email of the primary user (required) + * @param req.body.secondaryUserEmail - Email of the secondary user (required) + * @param req.body.description - Optional description for the connection (optional) + * + * Behavior notes: + * - Validates _eventId ObjectId format + * - Validates emails + prevents same email provided twice + * - Maps email -> User -> Participant (for that event) + * - Prevents duplicates with exact same (_eventId, primaryParticipantId, secondaryParticipantId) + * + * @returns 201 - Created ParticipantConnection document + * @returns 400 - Missing required fields, invalid ObjectId, invalid emails, or same email provided twice + * @returns 404 - Primary/secondary user not found OR participant not found for this event + * @returns 409 - ParticipantConnection already exists for this event and participants + * @returns 500 - Internal server error + */ +export async function createParticipantConnectionByEmails(req: Request, res: Response) { + try { + 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; + + if (!Types.ObjectId.isValid(_eventId)) { + return res.status(400).json({ error: "Invalid _eventId" }); + } + + const primaryEmail = String(primaryUserEmail).trim().toLowerCase(); + const secondaryEmail = String(secondaryUserEmail).trim().toLowerCase(); + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + if (!emailRegex.test(primaryEmail)) { + return res.status(400).json({ error: "Invalid primaryUserEmail" }); + } + if (!emailRegex.test(secondaryEmail)) { + return res.status(400).json({ error: "Invalid secondaryUserEmail" }); + } + + if (primaryEmail === secondaryEmail) { + return res.status(400).json({ error: "primaryUserEmail and secondaryUserEmail must be different" }); + } + + // Find users by email + const [primaryUser, secondaryUser] = await Promise.all([ + User.findOne({ email: primaryEmail }).select("_id"), + 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" }); + + // 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"), + ]); + + if (!primaryParticipant) { + 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)" }); + } + + // Prevent duplicates with exact same (_eventId, primaryParticipantId, secondaryParticipantId) + const existing = await ParticipantConnection.findOne({ + _eventId, + primaryParticipantId: primaryParticipant._id, + secondaryParticipantId: secondaryParticipant._id, + }); + + if (existing) { + return res.status(409).json({ + error: "ParticipantConnection already exists for this event and participants", + existingConnection: existing, + }); + } + + const newConnection = await ParticipantConnection.create({ + _eventId, + primaryParticipantId: primaryParticipant._id, + secondaryParticipantId: secondaryParticipant._id, + description, + }); + + return res.status(201).json(newConnection); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +/** + * DELETE /api/participantConnections/delete + * + * Deletes a participant connection only if it belongs to the given event. + * + * @param req.body.eventId - MongoDB ObjectId of the event (required) + * @param req.body.connectionId - ParticipantConnection string _id (required) + * + * @returns 200 - Deleted connection + * @returns 400 - Missing/invalid body params + * @returns 404 - ParticipantConnection not found for this event + * @returns 500 - Internal server error + */ +export async function deleteParticipantConnection(req: Request, res: Response) { + try { + const { eventId, connectionId } = req.body; + + if (!eventId || !Types.ObjectId.isValid(eventId)) { + return res.status(400).json({ error: "Invalid eventId" }); + } + + if (!connectionId || typeof connectionId !== "string") { + return res.status(400).json({ error: "Invalid connectionId" }); + } + + const deleted = await ParticipantConnection.findOneAndDelete({ + _id: connectionId, + _eventId: eventId, + }); + + if (!deleted) { + return res.status(404).json({ error: "ParticipantConnection not found for this event" }); + } + + return res.status(200).json({ + message: "ParticipantConnection deleted successfully", + deletedConnection: deleted, + }); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +export async function getConnectionsByParticipantAndEvent(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 }, + ], + }); + + return res.status(200).json(connections); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} + +export async function getConnectionsByUserEmailAndEvent(req: Request, res: Response) { + try { + const { eventId, userEmail } = req.query; + + if (!eventId || typeof eventId !== "string" || !Types.ObjectId.isValid(eventId)) { + return res.status(400).json({ error: "Invalid eventId" }); + } + + if (!userEmail || typeof userEmail !== "string") { + return res.status(400).json({ error: "Invalid userEmail" }); + } + + const email = userEmail.trim().toLowerCase(); + const user = await User.findOne({ email }).select("_id"); + + if (!user) { + return res.status(400).json({ error: "Invalid userEmail" }); + } + + 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)" }); + } + + const connections = await ParticipantConnection.find({ + _eventId: eventId, + $or: [ + { primaryParticipantId: participant._id }, + { secondaryParticipantId: participant._id } + ], + }); + + return res.status(200).json(connections); + } catch (_error) { + return res.status(500).json({ error: "Internal server error" }); + } +} \ No newline at end of file diff --git a/shatter-backend/src/models/participant_connection_model.ts b/shatter-backend/src/models/participant_connection_model.ts new file mode 100644 index 0000000..5f48106 --- /dev/null +++ b/shatter-backend/src/models/participant_connection_model.ts @@ -0,0 +1,46 @@ +import { Schema, model, Types, Document } from "mongoose"; + +export interface ParticipantConnection extends Document { + _id: string; + _eventId: Types.ObjectId; + primaryParticipantId: Types.ObjectId; + secondaryParticipantId: Types.ObjectId; + description?: string; +} + +const participantConnectionSchema = new Schema( + { + _id: { type: String }, + _eventId: { + type: Schema.Types.ObjectId, + ref: "Event", + required: true, + }, + primaryParticipantId: { + type: Schema.Types.ObjectId, + ref: "Participant", + required: true, + }, + secondaryParticipantId: { + type: Schema.Types.ObjectId, + ref: "Participant", + required: true, + }, + description: { type: String }, + }, + { + versionKey: false, + } +); + +participantConnectionSchema.pre("save", function (next) { + if (!this._id) { + this._id = `participantConnection_${Math.random().toString(36).slice(2, 10)}`; + } + next(); +}); + +export const ParticipantConnection = model( + "ParticipantConnection", + participantConnectionSchema +); \ No newline at end of file diff --git a/shatter-backend/src/routes/participant_connections_routes.ts b/shatter-backend/src/routes/participant_connections_routes.ts new file mode 100644 index 0000000..383168e --- /dev/null +++ b/shatter-backend/src/routes/participant_connections_routes.ts @@ -0,0 +1,60 @@ +// routes/participant_connections_routes.ts + +import { Router } from "express"; +import { + createParticipantConnection, + createParticipantConnectionByEmails, + deleteParticipantConnection, + getConnectionsByParticipantAndEvent, + getConnectionsByUserEmailAndEvent, +} from "../controllers/participant_connections_controller"; +import { authMiddleware } from "../middleware/auth_middleware"; + +const router = Router(); + +/** + * Base path (mounted in app): /api/participantConnections + * + * Routes: + * - POST /api/participantConnections + * Create connection by participant ObjectIds + * + * - POST /api/participantConnections/by-emails + * Create connection by user emails (controller maps email -> User -> Participant -> ObjectIds) + * + * - DELETE /api/participantConnections/delete + * Delete connection (eventId + connectionId in request body) + * + * - PUT /api/participantConnections/getByParticipantAndEvent + * Get all connections for (eventId + participantId) where participant is primary or secondary + * + * - PUT /api/participantConnections/getByUserEmailAndEvent + * Get all connections for (eventId + userEmail) where the user's participant is primary or secondary + */ + + + + + + +// Create connection by participant ObjectIds +// POST /api/participantConnections +router.post("/", authMiddleware, createParticipantConnection); + +// Create connection by user emails (controller converts to participant ObjectIds) +// POST /api/participantConnections/by-emails +router.post("/by-emails", authMiddleware, createParticipantConnectionByEmails); + +// Delete connection (eventId + connectionId in request body) +// DELETE /api/participantConnections/delete +router.delete("/delete", authMiddleware, deleteParticipantConnection); + +// Get all connections for (eventId + participantId) where participant is primary or secondary +// GET /api/participantConnections/getByParticipantAndEvent +router.get("/getByParticipantAndEvent", authMiddleware, getConnectionsByParticipantAndEvent); + +// Get all connections for (eventId + userEmail) where user's participant is primary or secondary +// GET /api/participantConnections/getByUserEmailAndEvent +router.get("/getByUserEmailAndEvent", authMiddleware, getConnectionsByUserEmailAndEvent); + +export default router; \ No newline at end of file diff --git a/shatter-backend/src/utils/requests_utils.ts b/shatter-backend/src/utils/requests_utils.ts new file mode 100644 index 0000000..7eb8258 --- /dev/null +++ b/shatter-backend/src/utils/requests_utils.ts @@ -0,0 +1,16 @@ +import { Request } from "express"; + +/** + * Checks if all required fields are present in the request body. + * @param req - Express request object + * @param requiredFields - Array of required field names + * @returns boolean indicating if all required fields are present + */ +export function check_req_fields(req: Request, requiredFields: string[]): boolean { + for (const field of requiredFields) { + if (!req.body[field]) { + return false; + } + } + return true; +} \ No newline at end of file