From a1af8d6467fd4dc2a158ffb88556eb0277cb6003 Mon Sep 17 00:00:00 2001 From: mraysu Date: Wed, 14 Jan 2026 22:00:00 -0800 Subject: [PATCH 1/3] basic backend messaging conversations and persistence functionality --- backend/src/app.ts | 2 + backend/src/controllers/message.ts | 115 +++++++++++++++++++++++++++++ backend/src/models/conversation.ts | 22 ++++++ backend/src/models/message.ts | 22 ++++++ backend/src/routes/message.ts | 12 +++ 5 files changed, 173 insertions(+) create mode 100644 backend/src/controllers/message.ts create mode 100644 backend/src/models/conversation.ts create mode 100644 backend/src/models/message.ts create mode 100644 backend/src/routes/message.ts 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..6b578ec --- /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", + ); + 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/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..4a08942 --- /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", + }, + authorId: { + type: Schema.Types.ObjectId, + ref: "User", + }, + // 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..16b64c2 --- /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; From 1a0e7323d0e232875a6afb2743b11247d1baafe6 Mon Sep 17 00:00:00 2001 From: mraysu Date: Fri, 23 Jan 2026 17:15:10 -0800 Subject: [PATCH 2/3] lots and lots of socket stuff --- backend/package-lock.json | 223 ++++++++++++++++-- backend/package.json | 1 + backend/src/controllers/message.ts | 6 +- backend/src/controllers/socket.ts | 6 + backend/src/server.ts | 7 + backend/src/socket.ts | 38 +++ backend/src/util/socketlogger.ts | 3 + backend/src/util/validateEnv.ts | 1 + .../src/validators/socketAuthValidation.ts | 17 ++ frontend/package-lock.json | 93 ++++++-- frontend/package.json | 3 +- frontend/src/App.tsx | 39 +-- frontend/src/components/messages/ChatBox.tsx | 107 +++++++++ frontend/src/components/messages/Messages.tsx | 30 +++ frontend/src/components/messages/types.tsx | 9 + frontend/src/index.css | 7 + frontend/src/pages/SavedProducts.tsx | 4 +- frontend/src/utils/ChatProvider.tsx | 37 +++ frontend/tailwind.config.js | 4 + 19 files changed, 579 insertions(+), 56 deletions(-) create mode 100644 backend/src/controllers/socket.ts create mode 100644 backend/src/socket.ts create mode 100644 backend/src/util/socketlogger.ts create mode 100644 backend/src/validators/socketAuthValidation.ts create mode 100644 frontend/src/components/messages/ChatBox.tsx create mode 100644 frontend/src/components/messages/Messages.tsx create mode 100644 frontend/src/components/messages/types.tsx create mode 100644 frontend/src/utils/ChatProvider.tsx 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/controllers/message.ts b/backend/src/controllers/message.ts index 6b578ec..27c1155 100644 --- a/backend/src/controllers/message.ts +++ b/backend/src/controllers/message.ts @@ -17,9 +17,9 @@ type PersistMessageRequest = { 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", - ); + 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 }); diff --git a/backend/src/controllers/socket.ts b/backend/src/controllers/socket.ts new file mode 100644 index 0000000..30ed6fa --- /dev/null +++ b/backend/src/controllers/socket.ts @@ -0,0 +1,6 @@ +import { Socket } from "socket.io"; +import socketlog from "src/util/socketlogger"; + +export const onConnect = async (socket: Socket) => { + socketlog("Socket connected: ", socket.data.user.displayName); +}; 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..89a68af --- /dev/null +++ b/backend/src/socket.ts @@ -0,0 +1,38 @@ +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 { onConnect } from "./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) { + 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) { + next(new Error("Authentication Error")); + } +}); + +io.on("connection", onConnect); + +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..6a1f1f1 --- /dev/null +++ b/frontend/src/components/messages/ChatBox.tsx @@ -0,0 +1,107 @@ +import { FormEvent, ReactNode, useEffect, useRef, useState } from "react"; +import { UserMessage } from "src/components/messages/types"; + +const testMessages: UserMessage[] = [ + { + content: "Hello", + sender: true, + sendDate: new Date(), + }, + { + content: "My name is", + sender: true, + sendDate: new Date(), + }, + { + content: "greggg", + sender: true, + sendDate: new Date(), + }, + { + content: + "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.", + sender: false, + sendDate: new Date(), + }, + { + content: + "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.", + sender: true, + sendDate: new Date(), + }, + { + content: "🔥", + sender: true, + sendDate: new Date(), + }, +]; +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(): ReactNode { + const [messages, setMessages] = useState(testMessages); + const [input, setInput] = useState(); + const chatBoxRef = useRef(null); + const generateMessageBox = (msg: UserMessage) => { + return
{msg.content}
; + }; + + // Connect to websocket + + // Handle message update + useEffect(() => { + if (chatBoxRef.current) chatBoxRef.current.scrollTop = chatBoxRef.current.scrollHeight; + }, [messages]); + + const handleSendMessage = async () => { + if (!input) return; + setMessages((prev) => [...prev, { content: input, sender: true, sendDate: new Date() }]); + setInput(""); + }; + 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) => generateMessageBox(msg))} +
+
+
+