diff --git a/backend/package-lock.json b/backend/package-lock.json index 1814cb4..c11f62c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,6 +23,7 @@ "mongoose": "^8.9.5", "multer": "^1.4.5-lts.1", "nodemailer": "^6.10.1", + "socket.io": "^4.8.3", "uuid": "^11.0.5" }, "devDependencies": { @@ -1057,6 +1058,12 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -1119,7 +1126,6 @@ "version": "2.8.13", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.13.tgz", "integrity": "sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -1803,6 +1809,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/bignumber.js": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", @@ -2183,11 +2198,12 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -2198,11 +2214,6 @@ } } }, - "node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2379,6 +2390,44 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/envalid": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/envalid/-/envalid-7.3.1.tgz", @@ -5956,6 +6005,47 @@ "npm": ">= 3.0.0" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socks": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", @@ -6691,6 +6781,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "devOptional": true }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -7483,6 +7594,11 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, + "@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -7540,7 +7656,6 @@ "version": "2.8.13", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.13.tgz", "integrity": "sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==", - "dev": true, "requires": { "@types/node": "*" } @@ -8032,6 +8147,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==" + }, "bignumber.js": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", @@ -8311,18 +8431,11 @@ } }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "requires": { - "ms": "2.1.2" - }, - "dependencies": { - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - } + "ms": "^2.1.3" } }, "deep-is": { @@ -8453,6 +8566,34 @@ "once": "^1.4.0" } }, + "engine.io": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "requires": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "dependencies": { + "cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + } + } + }, + "engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==" + }, "envalid": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/envalid/-/envalid-7.3.1.tgz", @@ -10977,6 +11118,38 @@ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" }, + "socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "requires": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + } + }, + "socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "requires": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + } + }, "socks": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", @@ -11497,6 +11670,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "devOptional": true }, + "ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "requires": {} + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index cfb70bf..c9fbd7b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "mongoose": "^8.9.5", "multer": "^1.4.5-lts.1", "nodemailer": "^6.10.1", + "socket.io": "^4.8.3", "uuid": "^11.0.5" }, "name": "backend", diff --git a/backend/src/app.ts b/backend/src/app.ts index e8d80d9..897a79c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,7 @@ import { isHttpError } from "http-errors"; import productRoutes from "src/routes/product"; import userRoutes from "src/routes/user"; import interestEmailRoute from "src/routes/interestEmail"; +import messageRoutes from "src/routes/message"; const app = express(); // initializes Express to accept JSON in the request/response body @@ -28,6 +29,7 @@ app.use( app.use("/api/products", productRoutes); app.use("/api/users", userRoutes); app.use("/api/interestEmail", interestEmailRoute); +app.use("/api/message", messageRoutes); /** * Error handler; all errors thrown by server are handled here. * Explicit typings required here because TypeScript cannot infer the argument types. diff --git a/backend/src/controllers/message.ts b/backend/src/controllers/message.ts new file mode 100644 index 0000000..27c1155 --- /dev/null +++ b/backend/src/controllers/message.ts @@ -0,0 +1,115 @@ +import { AuthenticatedRequest } from "src/validators/authUserMiddleware"; +import { Response } from "express"; +import ConversationModel from "src/models/conversation"; +import MessageModel from "src/models/message"; +import UserModel from "src/models/user"; +import { Types } from "mongoose"; + +//util and helpers +type CreateConversationRequest = { + participantEmails?: [string]; +}; + +type PersistMessageRequest = { + content: string; +}; + +const getConversationsByUser = async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) return res.status(404).json({ message: "User not found" }); + const conversation = await ConversationModel.find({ participants: req.user._id }) + .populate("lastMessage") + .populate("participants"); + return res.status(200).json(conversation); + } catch (e) { + return res.status(500).json({ message: "Error getting conversation:", e }); + } +}; + +const getMessagesByConversationId = async (req: AuthenticatedRequest, res: Response) => { + try { + const messages = await MessageModel.find({ conversationId: req.params.id }).sort({ + createdAt: -1, + }); + return res.status(200).json(messages); + } catch (e) { + return res.status(500).json({ message: "Error getting messages: ", e }); + } +}; + +const createConversation = async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) return res.status(404).json({ message: "User not found" }); + const emails = (req.body as CreateConversationRequest).participantEmails ?? []; // allow user to make conversation with themself + + // extract userids, given participant email list + const users = await UserModel.find({ userEmail: { $in: emails } }); + const foundEmails = new Set(users.map((u) => u.userEmail)); + const failedEmails = emails.filter((email) => !foundEmails.has(email)); + if (failedEmails.length > 0) + return res.status(400).json({ + message: "Failed to create a new conversation. Could not find users with emails: ", + failedEmails, + }); + + const participants = users.map((u) => u._id); + participants.push(req.user._id); + // END extract userids + + // Check conversation does not yet exist + const existingConversation = await ConversationModel.findOne({ + participants: participants.sort(), + }); + if (existingConversation) + return res + .status(409) + .json({ message: `Conversation already exists: ${existingConversation._id}` }); + // END check conversation does not yet exist + + const newConversation = await ConversationModel.create({ + participants, + }); + + if (!newConversation) + return res.status(400).json({ message: "Failed to create a new conversation" }); + return res.status(200).json(newConversation); + } catch (error) { + return res.status(500).json({ message: "Error creating conversation: ", error }); + } +}; + +const persistMessage = async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) return res.status(404).json({ message: "User not found" }); + const content = (req.body as PersistMessageRequest).content ?? ""; + const conversationId = req.params.id; + if (!Types.ObjectId.isValid(conversationId)) + return res.status(400).json({ message: "Invalid conversation ID" }); + const conversation = await ConversationModel.findOne({ _id: conversationId }); + if (!conversation) return res.status(404).json({ message: "Conversation does not exist" }); + + const newMessage = await MessageModel.create({ + conversationId, + authorId: req.user._id, + content, + }); + + if (!newMessage) return res.status(400).json({ message: "Failed to create a a new message" }); + + await ConversationModel.updateOne( + { _id: conversationId }, + { $set: { lastMessage: newMessage._id } }, + ); + + return res.status(200).json(newMessage); + } catch (e) { + return res.status(500).json({ message: "Error persisting message: ", e }); + } +}; + +export default { + getConversationsByUser, + persistMessage, + createConversation, + getMessagesByConversationId, +}; diff --git a/backend/src/controllers/socket.ts b/backend/src/controllers/socket.ts new file mode 100644 index 0000000..e5784ab --- /dev/null +++ b/backend/src/controllers/socket.ts @@ -0,0 +1,91 @@ +import { Socket } from "socket.io"; +import { User } from "src/models/user"; +import socketlog from "src/util/socketlogger"; +import ConversationModel from "src/models/conversation"; +import MessageModel from "src/models/message"; + +type SocketResponse = { + status: "OK" | "BAD"; + body?: unknown; + err?: { msg: string }; +}; + +type JoinConversationPayload = { + id: string; +}; + +type SendMessagePayload = { + conversationId: string; + content: string; +}; + +export const onConnect = async (socket: Socket) => { + socketlog("Socket connected: ", socket.data.user.displayName); +}; + +export function onJoinConversation(socket: Socket) { + return async (payload: JoinConversationPayload, callback: (response: SocketResponse) => void) => { + if (typeof callback !== "function") return; + try { + const user: User = socket.data.user; + const id = payload.id; + const conversation = await ConversationModel.findById(id); + + if (!user) throw new Error("Unauthenticated Request"); + if (!conversation) throw new Error("No conversation found"); + if (!conversation.participants.includes(user._id)) + throw new Error("User is not in this conversation"); + + const newRoom = `conversation:${id}`; + if (socket.data.currentConversation) socket.leave(socket.data.currentConversation); + socket.data.currentConversation = newRoom; + socket.join(newRoom); + + socketlog(`Current rooms for ${user.displayName}:`, ...socket.rooms); + + // --- Fetch all messages for this conversation --- + const messages = await MessageModel.find({ conversationId: id }).sort({ createdAt: 1 }); // sort oldest -> newest + + // Send messages to this socket only + return callback({ status: "OK", body: messages }); + } catch (err) { + return callback({ + status: "BAD", + err: { msg: err instanceof Error ? err.message : String(err) }, + }); + } + }; +} + +export function onSendMessage(socket: Socket) { + return async (payload: SendMessagePayload, callback: (r: SocketResponse) => void) => { + try { + const sender = socket.data.user; + if (!sender) throw new Error("Unauthenticated"); + + const conversation = await ConversationModel.findById(payload.conversationId); + if (!conversation) throw new Error("No conversation found"); + + if (!conversation.participants.some((id) => id.equals(sender._id))) { + throw new Error("Not in conversation"); + } + + const message = await MessageModel.create({ + content: payload.content, + authorUid: sender.firebaseUid, + conversationId: payload.conversationId, + }); + + conversation.lastMessage = message._id; + await conversation.save(); + + return callback({ status: "OK" }); + } catch (e) { + console.error(e); + return callback({ + status: "BAD", + err: { msg: e instanceof Error ? e.message : "Internal Error" }, + }); + } + }; +} diff --git a/backend/src/models/conversation.ts b/backend/src/models/conversation.ts new file mode 100644 index 0000000..9aea92a --- /dev/null +++ b/backend/src/models/conversation.ts @@ -0,0 +1,22 @@ +import { InferSchemaType, model, Schema } from "mongoose"; + +const ConversationSchema = new Schema( + { + participants: { + type: [Schema.Types.ObjectId], + ref: "User", + }, + lastMessage: { + type: Schema.Types.ObjectId, + ref: "Message", + required: false, + }, + }, + { + timestamps: true, + }, +); +ConversationSchema.index({ participants: 1 }, { unique: true }); + +export type Conversation = InferSchemaType; +export default model("Conversation", ConversationSchema); diff --git a/backend/src/models/message.ts b/backend/src/models/message.ts new file mode 100644 index 0000000..f1006f9 --- /dev/null +++ b/backend/src/models/message.ts @@ -0,0 +1,22 @@ +import { InferSchemaType, model, Schema } from "mongoose"; + +const MessageSchema = new Schema( + { + conversationId: { + type: Schema.Types.ObjectId, + ref: "User", + }, + // refers to firebase uid + authorUid: { + type: String, + }, + // text only for now, do images later + content: String, + }, + { + timestamps: true, + }, +); + +export type Message = InferSchemaType; +export default model("Message", MessageSchema); diff --git a/backend/src/routes/message.ts b/backend/src/routes/message.ts new file mode 100644 index 0000000..8c63c3e --- /dev/null +++ b/backend/src/routes/message.ts @@ -0,0 +1,12 @@ +import express from "express"; +import MessageController from "src/controllers/message"; +import { authenticateUser } from "src/validators/authUserMiddleware"; + +const router = express.Router(); + +router.get("/conversation", authenticateUser, MessageController.getConversationsByUser); +//router.get("/:id", authenticateUser, MessageController.getMessagesByConversationId); +router.post("/conversation", authenticateUser, MessageController.createConversation); +//router.post("/:id", authenticateUser, MessageController.persistMessage); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index 42a442c..4658c9d 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -6,8 +6,11 @@ import "module-alias/register"; import mongoose from "mongoose"; import app from "src/app"; import env from "src/util/validateEnv"; +import socketServer from "src/socket"; +import socketlog from "src/util/socketlogger"; const PORT = env.PORT; +const SOCKET_PORT = env.SOCKET_PORT; const MONGODB_URI = env.MONGODB_URI; mongoose @@ -19,3 +22,7 @@ mongoose }); }) .catch(console.error); + +socketServer.listen(SOCKET_PORT, () => { + socketlog(`Server listening on ${SOCKET_PORT}`); +}); diff --git a/backend/src/socket.ts b/backend/src/socket.ts new file mode 100644 index 0000000..15a2463 --- /dev/null +++ b/backend/src/socket.ts @@ -0,0 +1,53 @@ +import "dotenv/config"; +import http from "http"; +import { Server } from "socket.io"; +import socketlog from "src/util/socketlogger"; +import { validateToken } from "src/validators/socketAuthValidation"; +import { onSendMessage, onJoinConversation } from "src/controllers/socket"; + +const httpServer = http.createServer(); + +const io = new Server(httpServer, { + cors: { + origin: process.env.FRONTEND_ORIGIN, + methods: ["GET", "POST"], + }, +}); + +// auth protection bc we're good little coders :) +io.use(async (socket, next) => { + const token = socket.handshake.auth.token; + + if (!token) { + socketlog("Error: No token provided"); + return next(new Error("No token provided")); + } + + try { + const user = await validateToken(token); + if (!user) return next(new Error("User not found")); + + socket.data.user = user; + next(); + } catch (err) { + socketlog("Error: Authentication error"); + next(new Error("Authentication Error")); + } +}); + +io.on("connection", (socket) => { + socketlog("Socket connected: ", socket.data.user.displayName, " id: ", socket.id); + socket.onAny((eventName, payload) => { + socketlog( + `User: ${socket.data.user.displayName}; Event: ${eventName}; Payload: ${JSON.stringify( + payload, + )}`, + ); + }); + + // event handling + socket.on("conversation:join", onJoinConversation(socket)); + socket.on("message:send", onSendMessage(socket)); +}); + +export default httpServer; diff --git a/backend/src/util/socketlogger.ts b/backend/src/util/socketlogger.ts new file mode 100644 index 0000000..8f259d7 --- /dev/null +++ b/backend/src/util/socketlogger.ts @@ -0,0 +1,3 @@ +export default function socketlog(...msg: string[]) { + console.log("[SOCKET.IO LOG]\t", ...msg); +} diff --git a/backend/src/util/validateEnv.ts b/backend/src/util/validateEnv.ts index 2bfc9b3..af6ad94 100644 --- a/backend/src/util/validateEnv.ts +++ b/backend/src/util/validateEnv.ts @@ -11,4 +11,5 @@ export default cleanEnv(process.env, { MONGODB_URI: str(), FIREBASE_PRIVATE_KEY_BASE64: str(), FIREBASE_PROJECT_ID: str(), + SOCKET_PORT: port(), }); diff --git a/backend/src/validators/socketAuthValidation.ts b/backend/src/validators/socketAuthValidation.ts new file mode 100644 index 0000000..c751b08 --- /dev/null +++ b/backend/src/validators/socketAuthValidation.ts @@ -0,0 +1,17 @@ +import UserModel, { User } from "src/models/user"; +import admin from "firebase-admin"; + +export async function validateToken(token: string) { + try { + const decodedToken = await admin.auth().verifyIdToken(token); + + const uid = decodedToken.uid; + + const user = await UserModel.findOne({ firebaseUid: uid }); + + return user; + } catch (error) { + console.error("Token validation failed:", error); + return null; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d706f4..e6427ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,8 @@ "react-google-button": "^0.8.0", "react-helmet-async": "^2.0.5", "react-icons": "^5.4.0", - "react-router-dom": "^6.27.0" + "react-router-dom": "^6.27.0", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@testing-library/jest-dom": "^6.5.0", @@ -2019,6 +2020,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", @@ -3499,12 +3506,12 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -3713,6 +3720,28 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -6110,10 +6139,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/mz": { "version": "2.7.0", @@ -7354,6 +7383,34 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8553,10 +8610,10 @@ "dev": true }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -8588,6 +8645,14 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index d84cf48..d4b6ca8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,8 @@ "react-google-button": "^0.8.0", "react-helmet-async": "^2.0.5", "react-icons": "^5.4.0", - "react-router-dom": "^6.27.0" + "react-router-dom": "^6.27.0", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@testing-library/jest-dom": "^6.5.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2a21084..64d633d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,16 +2,17 @@ import { HelmetProvider } from "react-helmet-async"; import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { Footer } from "src/components/Footer"; import { Navbar } from "src/components/Navbar"; +import { PrivateRoute } from "src/components/PrivateRoute"; +import { Messages } from "src/components/messages/Messages"; import { Home } from "src/pages"; +import { AddProduct } from "src/pages/AddProduct"; +import { EditProduct } from "src/pages/EditProduct"; +import { IndividualProductPage } from "src/pages/Individual-product-page"; import { Marketplace } from "src/pages/Marketplace"; - -import { PrivateRoute } from "../src/components/PrivateRoute"; -import { AddProduct } from "../src/pages/AddProduct"; -import { EditProduct } from "../src/pages/EditProduct"; -import { IndividualProductPage } from "../src/pages/Individual-product-page"; -import { PageNotFound } from "../src/pages/PageNotFound"; -import FirebaseProvider from "../src/utils/FirebaseProvider"; -import { SavedProducts } from "./pages/SavedProducts"; +import { PageNotFound } from "src/pages/PageNotFound"; +import { SavedProducts } from "src/pages/SavedProducts"; +import ChatProvider from "src/utils/ChatProvider"; +import FirebaseProvider from "src/utils/FirebaseProvider"; const router = createBrowserRouter([ { @@ -58,6 +59,14 @@ const router = createBrowserRouter([ ), }, + { + path: "/messages", + element: ( + + + + ), + }, { path: "*", element: , @@ -68,13 +77,15 @@ export default function App() { return ( -
- -
- + +
+ +
+ +
+
-
-
+ ); diff --git a/frontend/src/components/messages/ChatBox.tsx b/frontend/src/components/messages/ChatBox.tsx new file mode 100644 index 0000000..1d24e1a --- /dev/null +++ b/frontend/src/components/messages/ChatBox.tsx @@ -0,0 +1,108 @@ +import { + Dispatch, + FormEvent, + ReactNode, + SetStateAction, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { SocketResponse, isOk } from "src/components/messages/api"; +import { Conversation, UserMessage } from "src/components/messages/types"; +import { ChatContext } from "src/utils/ChatProvider"; +import { FirebaseContext } from "src/utils/FirebaseProvider"; + +const chatBoxStyling = "rounded rounded-lg p-3 w-fit max-w-[75%] text-wrap break-all"; +const senderStyling = chatBoxStyling + " ml-auto bg-default-teal"; +const receiverStyling = chatBoxStyling + " bg-default-gray"; + +export function ChatBox({ + messages, + setMessages, + currConvo, +}: { + messages: UserMessage[]; + setMessages: Dispatch>; + currConvo: Conversation | null; +}): ReactNode { + const { socket } = useContext(ChatContext); + const { user } = useContext(FirebaseContext); + const [input, setInput] = useState(); + const chatBoxRef = useRef(null); + + // Handle message update + useEffect(() => { + if (chatBoxRef.current) chatBoxRef.current.scrollTop = chatBoxRef.current.scrollHeight; + }, [messages]); + + const handleSendMessage = async () => { + if (!user) return; + if (!input) return; + socket?.emit( + "message:send", + { conversationId: currConvo?._id, content: input }, + (response: SocketResponse) => { + if (!isOk(response)) { + alert(`unable to send message ${response.err?.msg}`); + } + setInput(""); + }, + ); + setMessages((prev) => [ + ...prev, + { content: input, authorUid: user.uid, sender: true, updatedAt: new Date().toISOString() }, + ]); + }; + const handleInputResize = async (e: FormEvent) => { + const target = e.target as HTMLTextAreaElement; + + const maxHeight = 150; + target.style.height = "auto"; + if (target.scrollHeight > maxHeight) { + target.style.height = `${maxHeight}px`; + target.style.overflowY = "auto"; + } else { + target.style.height = `${target.scrollHeight}px`; + target.style.overflowY = "hidden"; + } + }; + return ( +
+
+ {messages.map((msg, idx) => { + return ( +
+ {msg.content} +
+ ); + })} +
+
+
+