diff --git a/package-lock.json b/package-lock.json index f6287bf..b6d5be0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,29 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", - "pg": "^8.16.3" + "pg": "^8.16.3", + "socket.io": "^4.8.1" + } + }, + "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==" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.0.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz", + "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", + "dependencies": { + "undici-types": "~7.16.0" } }, "node_modules/accepts": { @@ -27,6 +49,14 @@ "node": ">= 0.6" } }, + "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==", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/body-parser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", @@ -194,6 +224,88 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "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.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "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==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -845,6 +957,131 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "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.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -882,6 +1119,11 @@ "node": ">= 0.6" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -903,6 +1145,26 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "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", diff --git a/package.json b/package.json index 9f9fdf7..7912985 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", - "pg": "^8.16.3" + "pg": "^8.16.3", + "socket.io": "^4.8.1" } } diff --git a/routes/chatRoutes.js b/routes/chatRoutes.js new file mode 100644 index 0000000..9200d65 --- /dev/null +++ b/routes/chatRoutes.js @@ -0,0 +1,196 @@ +import express from "express"; +import pool from "../db/pool.js"; +import { getIO, getSocketId } from "../socket.js"; + +const router = express.Router(); + +// 1. POST /conversations (Create or Get existing) +router.post("/conversations", async (req, res) => { + try { + // We expect explicit buyer/seller ID, or just two user IDs. + // Based on schema, we have buyer_id and seller_id. + // Ideally, the client sends which is which, or we just treat them as user1/user2 and check both permutations. + const { buyer_id, seller_id } = req.body; + + if (!buyer_id || !seller_id) { + return res.status(400).json({ error: "buyer_id and seller_id are required" }); + } + + // Check if conversation exists (bidirectional check for robustness, although schema has specific columns) + const queryCheck = ` + SELECT * FROM conversations + WHERE (buyer_id = $1 AND seller_id = $2) + OR (buyer_id = $2 AND seller_id = $1) + AND deleted = FALSE + `; + const checkResult = await pool.query(queryCheck, [buyer_id, seller_id]); + + if (checkResult.rows.length > 0) { + return res.json(checkResult.rows[0]); + } + + // Create new + const queryInsert = ` + INSERT INTO conversations (buyer_id, seller_id) + VALUES ($1, $2) + RETURNING * + `; + const insertResult = await pool.query(queryInsert, [buyer_id, seller_id]); + + res.status(201).json(insertResult.rows[0]); + } catch (err) { + console.error("Error creating/getting conversation:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// 2. GET /conversations/user/:userId (Inbox) +router.get("/conversations/user/:userId", async (req, res) => { + try { + const { userId } = req.params; + + // Fetch conversations where user is involved. + // Also fetch the OTHER user's name/avatar. + const queryText = ` + SELECT + c.*, + CASE + WHEN c.buyer_id = $1 THEN u_seller.name + ELSE u_buyer.name + END as other_user_name, + CASE + WHEN c.buyer_id = $1 THEN u_seller.avatar_url + ELSE u_buyer.avatar_url + END as other_user_avatar, + CASE + WHEN c.buyer_id = $1 THEN u_seller.id + ELSE u_buyer.id + END as other_user_id + FROM conversations c + JOIN users u_buyer ON c.buyer_id = u_buyer.id + JOIN users u_seller ON c.seller_id = u_seller.id + WHERE (c.buyer_id = $1 OR c.seller_id = $1) + AND c.deleted = FALSE + ORDER BY c.updated_at DESC + `; + const result = await pool.query(queryText, [userId]); + res.json(result.rows); + } catch (err) { + console.error("Error fetching user conversations:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// 3. GET /conversations/:conversationId/messages (History) +router.get("/conversations/:conversationId/messages", async (req, res) => { + try { + const { conversationId } = req.params; + const { limit = 50, offset = 0 } = req.query; + + const queryText = ` + SELECT + m.*, + cm.media_url, + cm.media_type as media_file_type, + cm.thumbnail_url + FROM messages m + LEFT JOIN conversation_media cm ON m.media_id = cm.id + WHERE m.conversation_id = $1 + AND m.deleted = FALSE + ORDER BY m.created_at DESC + LIMIT $2 OFFSET $3 + `; + const result = await pool.query(queryText, [conversationId, limit, offset]); + + // Reverse for frontend if needed, but API usually sends standard order. + // Sending newest first (DESC) is common for pagination. + res.json(result.rows); + } catch (err) { + console.error("Error fetching messages:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// 4. POST /messages (Send Message) +router.post("/messages", async (req, res) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const { conversation_id, sender_id, receiver_id, content, message_type = 'text', media } = req.body; + + let media_id = null; + + // Insert Media if present and message type allows it + if ((message_type === 'media' || message_type === 'both') && media) { + const insertMediaQuery = ` + INSERT INTO conversation_media (media_type, media_url, thumbnail_url) + VALUES ($1, $2, $3) + RETURNING id + `; + const mediaResult = await client.query(insertMediaQuery, [media.media_type, media.media_url, media.thumbnail_url]); + media_id = mediaResult.rows[0].id; + } + + // Insert Message + const insertMessageQuery = ` + INSERT INTO messages (conversation_id, sender_id, receiver_id, message_type, content, media_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `; + const messageResult = await client.query(insertMessageQuery, [conversation_id, sender_id, receiver_id, message_type, content, media_id]); + + // Update conversation timestamp + const updateConvQuery = `UPDATE conversations SET updated_at = NOW() WHERE id = $1`; + await client.query(updateConvQuery, [conversation_id]); + + await client.query("COMMIT"); + + // Real-time update via Socket.io + const receiverSocketId = getSocketId(receiver_id); + if (receiverSocketId) { + getIO().to(receiverSocketId).emit("receive_message", messageResult.rows[0]); + } + + res.status(201).json(messageResult.rows[0]); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error sending message:", err); + res.status(500).json({ error: "Internal server error" }); + } finally { + client.release(); + } +}); + +// 5. PUT /messages/:messageId/read (Mark Read) +router.put("/messages/:messageId/read", async (req, res) => { + try { + const { messageId } = req.params; + const queryText = ` + UPDATE messages + SET is_read = TRUE, read_at = NOW() + WHERE id = $1 + RETURNING * + `; + const result = await pool.query(queryText, [messageId]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: "Message not found" }); + } + + res.json(result.rows[0]); + + // Real-time update via Socket.io to the sender (so they know it's read) + const updatedMessage = result.rows[0]; + const senderId = updatedMessage.sender_id; + const senderSocketId = getSocketId(senderId); + + if (senderSocketId) { + getIO().to(senderSocketId).emit("message_read", updatedMessage); + } + } catch (err) { + console.error("Error marking message as read:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/server.js b/server.js index eadcac4..792c4c1 100644 --- a/server.js +++ b/server.js @@ -10,10 +10,18 @@ const PORT = process.env.PORT || 3200; // Add routes here import listingRoutes from "./routes/listingRoutes.js"; import locationRoutes from "./routes/locationRoutes.js"; +import chatRoutes from "./routes/chatRoutes.js"; + app.use("/listings", listingRoutes); app.use("/locations", locationRoutes); +app.use("/chat", chatRoutes); +import http from "http"; +import { initSocket } from "./socket.js"; -app.listen(PORT, () => { +const server = http.createServer(app); +initSocket(server); + +server.listen(PORT, () => { console.log(`BuySellService is running on port ${PORT}`); }); diff --git a/socket.js b/socket.js new file mode 100644 index 0000000..d46afa8 --- /dev/null +++ b/socket.js @@ -0,0 +1,54 @@ +import { Server } from "socket.io"; + +let io; +const userSocketMap = new Map(); // userId -> socketId + +// Initialize Socket.IO +export const initSocket = (server) => { + io = new Server(server, { + cors: { + origin: "*", // Adjust as needed for production + methods: ["GET", "POST"], + }, + }); + + io.on("connection", (socket) => { + console.log("A user connected:", socket.id); + + // Event for user to register their ID + socket.on("register", (userId) => { + if (userId) { + userSocketMap.set(userId, socket.id); + console.log(`Registered user ${userId} to socket ${socket.id}`); + } + }); + + socket.on("disconnect", () => { + console.log("User disconnected:", socket.id); + // Optional: Remove user from map. This is inefficient O(N). + // Better: keep reverse map or just iterate. Map iteration is okay for small scale. + for (const [userId, socketId] of userSocketMap.entries()) { + if (socketId === socket.id) { + userSocketMap.delete(userId); + console.log(`Unregistered user ${userId}`); + break; + } + } + }); + }); + + return io; +}; + +// Get the IO instance +export const getIO = () => { + if (!io) { + throw new Error("Socket.io not initialized!"); + } + return io; +}; + +// Get socket ID for a user +export const getSocketId = (userId) => { + return userSocketMap.get(userId); +};