diff --git a/backend/controllers/admin.controller.js b/backend/controllers/admin.controller.js new file mode 100644 index 0000000..3df001b --- /dev/null +++ b/backend/controllers/admin.controller.js @@ -0,0 +1,79 @@ +const adminService = require("../services/admin.service"); +const { safeArray, safeNumber, sanitizeString, getPagination, buildPaginationMeta } = require("../utils/helpers"); + +const getDashboardStats = async (req, res) => { + try { + const data = await adminService.getDashboardStats(); + return res.status(200).json({ success: true, data }); + } catch (error) { + console.error("ADMIN DASHBOARD ERROR:", error); + return res.status(500).json({ success: false, message: "Server error" }); + } +}; + +const getUsers = async (req, res) => { + try { + const { page, limit } = getPagination(req.query.page, req.query.limit, 50); + const filters = { + search: sanitizeString(req.query.search), + status: sanitizeString(req.query.status), + role: sanitizeString(req.query.role) + }; + + const result = await adminService.getUsers(filters, page, limit); + + return res.status(200).json({ + success: true, + users: result.users, + ...buildPaginationMeta(result.total, page, limit) + }); + } catch (error) { + console.error("ADMIN GET USERS ERROR:", error); + return res.status(500).json({ success: false, message: "Server error" }); + } +}; + +const updateUserStatus = async (req, res) => { + try { + const targetId = safeNumber(req.params.id); + const status = sanitizeString(req.body.status); // 'active' or 'blocked' + + if (!targetId || !['active', 'blocked'].includes(status)) { + return res.status(400).json({ success: false, message: "Invalid payload" }); + } + + if (targetId === req.user.id) { + return res.status(400).json({ success: false, message: "Cannot modify own status" }); + } + + await adminService.updateUserStatus(req.user.id, targetId, status, req.ip, req.headers['user-agent']); + return res.status(200).json({ success: true, message: `User ${status === 'active' ? 'unblocked' : 'blocked'} successfully` }); + } catch (error) { + console.error("ADMIN UPDATE USER ERROR:", error); + return res.status(500).json({ success: false, message: "Server error" }); + } +}; + +const bulkUpdateUserStatus = async (req, res) => { + try { + const targetIds = safeArray(req.body.userIds).map(id => safeNumber(id)).filter(id => id > 0 && id !== req.user.id); + const status = sanitizeString(req.body.status); // 'active' or 'blocked' + + if (!targetIds.length || !['active', 'blocked'].includes(status)) { + return res.status(400).json({ success: false, message: "Invalid payload or users" }); + } + + await adminService.bulkUpdateUserStatus(req.user.id, targetIds, status, req.ip, req.headers['user-agent']); + return res.status(200).json({ success: true, message: `Users ${status === 'active' ? 'unblocked' : 'blocked'} successfully` }); + } catch (error) { + console.error("ADMIN BULK UPDATE ERROR:", error); + return res.status(500).json({ success: false, message: "Server error" }); + } +}; + +module.exports = { + getDashboardStats, + getUsers, + updateUserStatus, + bulkUpdateUserStatus +}; diff --git a/backend/controllers/chat.controller.js b/backend/controllers/chat.controller.js new file mode 100644 index 0000000..1583c47 --- /dev/null +++ b/backend/controllers/chat.controller.js @@ -0,0 +1,71 @@ +const chatService = require("../services/chat.service"); +const { getPagination, sanitizeString, safeNumber } = require("../utils/helpers"); + +const getConversations = async (req, res) => { + try { + const { page, limit } = getPagination(req.query.page, req.query.limit, 20); + const filters = { + status: sanitizeString(req.query.status), + assigned_to: sanitizeString(req.query.assigned_to), + search: sanitizeString(req.query.search) + }; + + const data = await chatService.getConversationList(filters, page, limit); + res.status(200).json({ success: true, ...data }); + } catch (error) { + console.error("GET CONVERSATIONS ERROR:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; + +const getConversationDetails = async (req, res) => { + try { + const id = safeNumber(req.params.id); + if (!id) return res.status(400).json({ success: false, message: "Invalid ID" }); + + const messages = await chatService.getConversationMessages(id); + res.status(200).json({ success: true, messages }); + } catch (error) { + console.error("GET CONVERSATION DETAILS ERROR:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; + +const updateStatus = async (req, res) => { + try { + const id = safeNumber(req.params.id); + const { status } = req.body; + if (!id || !['open', 'pending', 'closed'].includes(status)) { + return res.status(400).json({ success: false, message: "Invalid payload" }); + } + + await chatService.updateConversationStatus(id, status); + + // Emit socket event if needed, handled in socket logic usually + // but we return REST success + res.status(200).json({ success: true, message: `Conversation ${status}` }); + } catch (error) { + console.error("UPDATE CONV STATUS ERROR:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; + +const assignAdmin = async (req, res) => { + try { + const id = safeNumber(req.params.id); + if (!id) return res.status(400).json({ success: false, message: "Invalid ID" }); + + await chatService.assignConversation(id, req.user.id); + res.status(200).json({ success: true, message: "Conversation assigned successfully" }); + } catch (error) { + console.error("ASSIGN CONV ERROR:", error); + res.status(500).json({ success: false, message: "Server error" }); + } +}; + +module.exports = { + getConversations, + getConversationDetails, + updateStatus, + assignAdmin +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index f7a6bfd..78037d2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,22 +19,46 @@ "jsonwebtoken": "^9.0.3", "mysql2": "^3.22.3", "node-appwrite": "^26.2.0", - "nodemailer": "^9.0.0" + "nodemailer": "^9.0.0", + "socket.io": "^4.8.3" }, "devDependencies": { "nodemon": "^3.1.14" } }, + "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/@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==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "25.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -81,6 +105,15 @@ "node": "18 || 20 || >=22" } }, + "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/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -392,6 +425,79 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "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.21.0" + }, + "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/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==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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", @@ -550,21 +656,6 @@ "node": ">= 0.8" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1062,9 +1153,9 @@ } }, "node_modules/nodemailer": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.0.tgz", - "integrity": "sha512-tbPTid7d/p9jAA8CRZ3iomvrMaST0o6NYuY7v6JQZHpPRZ61mLFSPKYd7342NtOFuej9/+L48SOIxwfu2uDvtw==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -1445,6 +1536,90 @@ "node": ">=10" } }, + "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.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/sql-escaper": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", @@ -1553,9 +1728,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", - "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "license": "MIT", "engines": { "node": ">=18.17" @@ -1565,8 +1740,7 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", @@ -1591,6 +1765,27 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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 + } + } } } } diff --git a/backend/package.json b/backend/package.json index ec03746..2fcfd88 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,7 +21,8 @@ "jsonwebtoken": "^9.0.3", "mysql2": "^3.22.3", "node-appwrite": "^26.2.0", - "nodemailer": "^9.0.0" + "nodemailer": "^9.0.0", + "socket.io": "^4.8.3" }, "devDependencies": { "nodemon": "^3.1.14" diff --git a/backend/routes/adminRoutes.js b/backend/routes/adminRoutes.js new file mode 100644 index 0000000..35c0d5e --- /dev/null +++ b/backend/routes/adminRoutes.js @@ -0,0 +1,16 @@ +const express = require("express"); +const router = express.Router(); +const { getDashboardStats, getUsers, updateUserStatus, bulkUpdateUserStatus } = require("../controllers/admin.controller"); +const authMiddleware = require("../middleware/authMiddleware"); +const { authorizeRoles } = require("../middleware/rbacMiddleware"); + +// All admin routes are protected and require 'admin' role +router.use(authMiddleware); +router.use(authorizeRoles("admin")); + +router.get("/dashboard", getDashboardStats); +router.get("/users", getUsers); +router.patch("/users/:id/status", updateUserStatus); +router.post("/users/bulk-status", bulkUpdateUserStatus); + +module.exports = router; diff --git a/backend/routes/chatRoutes.js b/backend/routes/chatRoutes.js new file mode 100644 index 0000000..3aaca0d --- /dev/null +++ b/backend/routes/chatRoutes.js @@ -0,0 +1,15 @@ +const express = require("express"); +const router = express.Router(); +const { authMiddleware, authorizeRoles } = require("../middleware/authMiddleware"); +const { getConversations, getConversationDetails, updateStatus, assignAdmin } = require("../controllers/chat.controller"); + +// Admin only routes +router.use(authMiddleware); +router.use(authorizeRoles("admin")); + +router.get("/conversations", getConversations); +router.get("/conversations/:id", getConversationDetails); +router.patch("/conversations/:id/status", updateStatus); +router.patch("/conversations/:id/assign", assignAdmin); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 3da7017..2b84e56 100644 --- a/backend/server.js +++ b/backend/server.js @@ -44,6 +44,8 @@ const authRoutes = require("./routes/authRoutes"); const orderRoutes = require("./routes/orderRoutes"); const promoRoutes = require("./routes/promoRoutes"); +const adminRoutes = require("./routes/adminRoutes"); +const chatRoutes = require("./routes/chatRoutes"); const wishlistRoutes = require( @@ -53,8 +55,12 @@ const recommendationRoutes = require("./routes/recommendationRoutes"); const pincodeRoutes = require("./routes/pincodeRoutes"); -// app +// init app const app = express(); +const http = require("http"); +const server = http.createServer(app); +const { initSocket } = require("./utils/socketManager"); +initSocket(server); // constants const PORT = Number(process.env.PORT) || 5000; @@ -261,8 +267,9 @@ app.use("/api/products", productRoutes); app.use("/api/auth", authRoutes); app.use("/api/orders", orderRoutes); - app.use("/api/promos", promoRoutes); +app.use("/api/admin", adminRoutes); +app.use("/api/chat", chatRoutes); app.use( "/api/wishlist", @@ -327,7 +334,7 @@ process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); // start server -app.listen(PORT, "0.0.0.0", () => { +server.listen(PORT, "0.0.0.0", () => { console.log(`Server running on port ${PORT}`); console.log(`Environment: ${process.env.NODE_ENV || "development"}`); diff --git a/backend/services/admin.service.js b/backend/services/admin.service.js new file mode 100644 index 0000000..4df5050 --- /dev/null +++ b/backend/services/admin.service.js @@ -0,0 +1,176 @@ +const db = require("../config/db"); +const { safeArray, safeNumber, sanitizeString } = require("../utils/helpers"); + +const logAudit = async (connection, adminId, targetUserId, action, metadata, ip, userAgent) => { + const query = ` + INSERT INTO user_audit_logs (admin_id, target_user_id, action, metadata, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?, ?) + `; + await connection.query(query, [adminId, targetUserId, action, JSON.stringify(metadata || {}), ip, userAgent]); +}; + +const getDashboardStats = async () => { + // Collect basic statistics + const [userStats] = await db.query(` + SELECT + COUNT(*) as totalUsers, + SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as activeUsers, + SUM(CASE WHEN is_active = 0 THEN 1 ELSE 0 END) as blockedUsers, + SUM(CASE WHEN MONTH(created_at) = MONTH(CURRENT_DATE()) AND YEAR(created_at) = YEAR(CURRENT_DATE()) THEN 1 ELSE 0 END) as newUsersThisMonth + FROM users + `); + + const [orderStats] = await db.query(` + SELECT + COUNT(*) as totalOrders, + SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) as completedOrders, + SUM(CASE WHEN status = 'pending' OR status = 'processing' THEN 1 ELSE 0 END) as pendingOrders, + SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelledOrders, + SUM(final_amount) as totalRevenue, + SUM(CASE WHEN MONTH(created_at) = MONTH(CURRENT_DATE()) AND YEAR(created_at) = YEAR(CURRENT_DATE()) THEN final_amount ELSE 0 END) as revenueThisMonth + FROM orders + `); + + const [productStats] = await db.query(`SELECT COUNT(*) as totalProducts FROM products`); + + // Analytics: Revenue over the last 30 days (simplified) + const [revenueAnalytics] = await db.query(` + SELECT DATE(created_at) as date, SUM(final_amount) as revenue + FROM orders + WHERE created_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY) + GROUP BY DATE(created_at) + ORDER BY date ASC + `); + + // Order status distribution + const [orderStatusDistribution] = await db.query(` + SELECT status, COUNT(*) as count + FROM orders + GROUP BY status + `); + + return { + stats: { + ...userStats[0], + ...orderStats[0], + totalProducts: productStats[0].totalProducts, + averageOrderValue: orderStats[0].totalOrders ? (orderStats[0].totalRevenue / orderStats[0].totalOrders).toFixed(2) : 0 + }, + charts: { + revenue: safeArray(revenueAnalytics), + orderStatus: safeArray(orderStatusDistribution) + } + }; +}; + +const getUsers = async (filters, page, limit) => { + const offset = (page - 1) * limit; + let query = `SELECT id, name, email, role, is_active, created_at, updated_at FROM users WHERE 1=1`; + const params = []; + + if (filters.search) { + query += ` AND (name LIKE ? OR email LIKE ?)`; + params.push(`%${filters.search}%`, `%${filters.search}%`); + } + + if (filters.status) { + query += ` AND is_active = ?`; + params.push(filters.status === 'active' ? 1 : 0); + } + + if (filters.role) { + query += ` AND role = ?`; + params.push(filters.role); + } + + // Get total count + const countQuery = `SELECT COUNT(*) as total FROM (${query}) as t`; + const [countResult] = await db.query(countQuery, params); + + // Apply pagination + query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`; + params.push(limit, offset); + + const [users] = await db.query(query, params); + + return { + users: safeArray(users), + total: countResult[0].total, + page, + limit + }; +}; + +const updateUserStatus = async (adminId, targetId, status, ip, userAgent) => { + const connection = await db.getConnection(); + try { + await connection.beginTransaction(); + + const [result] = await connection.query(`UPDATE users SET is_active = ? WHERE id = ?`, [status === 'active' ? 1 : 0, targetId]); + + if (result.affectedRows === 0) { + throw new Error("User not found"); + } + + await logAudit( + connection, + adminId, + targetId, + status === 'active' ? 'USER_UNBLOCKED' : 'USER_BLOCKED', + {}, + ip, + userAgent + ); + + await connection.commit(); + return true; + } catch (e) { + await connection.rollback(); + throw e; + } finally { + connection.release(); + } +}; + +const bulkUpdateUserStatus = async (adminId, targetIds, status, ip, userAgent) => { + const connection = await db.getConnection(); + try { + await connection.beginTransaction(); + + if (safeArray(targetIds).length > 0) { + const placeholders = targetIds.map(() => '?').join(','); + const [result] = await connection.query(`UPDATE users SET is_active = ? WHERE id IN (${placeholders})`, [status === 'active' ? 1 : 0, ...targetIds]); + + if (result.affectedRows === 0) { + throw new Error("No users found to update"); + } + + for (const id of targetIds) { + await logAudit( + connection, + adminId, + id, + status === 'active' ? 'BULK_UNBLOCK' : 'BULK_BLOCK', + {}, + ip, + userAgent + ); + } + } + + await connection.commit(); + return true; + } catch (e) { + await connection.rollback(); + throw e; + } finally { + connection.release(); + } +}; + +module.exports = { + getDashboardStats, + getUsers, + updateUserStatus, + bulkUpdateUserStatus +}; diff --git a/backend/services/chat.service.js b/backend/services/chat.service.js new file mode 100644 index 0000000..a9a5ef9 --- /dev/null +++ b/backend/services/chat.service.js @@ -0,0 +1,138 @@ +const db = require("../config/db"); +const { safeArray, safeNumber } = require("../utils/helpers"); + +const findOrCreateConversation = async (customerId) => { + // Check if open or pending conversation exists + const [existing] = await db.query( + `SELECT * FROM chat_conversations WHERE customer_id = ? AND status IN ('open', 'pending') LIMIT 1`, + [customerId] + ); + + if (existing.length > 0) return existing[0]; + + // Create new + const [result] = await db.query( + `INSERT INTO chat_conversations (customer_id, status) VALUES (?, 'open')`, + [customerId] + ); + + const [newConv] = await db.query(`SELECT * FROM chat_conversations WHERE id = ?`, [result.insertId]); + return newConv[0]; +}; + +const getConversationList = async (filters, page = 1, limit = 20) => { + const offset = (page - 1) * limit; + let query = ` + SELECT c.*, u.name as customer_name, u.email as customer_email, + (SELECT message FROM chat_messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) as last_message, + (SELECT created_at FROM chat_messages m WHERE m.conversation_id = c.id ORDER BY m.created_at DESC LIMIT 1) as last_activity + FROM chat_conversations c + JOIN users u ON c.customer_id = u.id + WHERE 1=1 + `; + const params = []; + + if (filters.status) { + query += ` AND c.status = ?`; + params.push(filters.status); + } + + if (filters.assigned_to) { + if (filters.assigned_to === 'unassigned') { + query += ` AND c.assigned_admin_id IS NULL`; + } else { + query += ` AND c.assigned_admin_id = ?`; + params.push(filters.assigned_to); + } + } + + if (filters.search) { + query += ` AND (u.name LIKE ? OR u.email LIKE ?)`; + params.push(`%${filters.search}%`, `%${filters.search}%`); + } + + // Get total count + const [countResult] = await db.query(`SELECT COUNT(*) as total FROM (${query}) as t`, params); + + // Apply sorting and pagination + query += ` ORDER BY last_activity DESC LIMIT ? OFFSET ?`; + params.push(limit, offset); + + const [conversations] = await db.query(query, params); + + return { + conversations: safeArray(conversations), + total: countResult[0].total, + page, + limit + }; +}; + +const getConversationMessages = async (conversationId) => { + const [messages] = await db.query( + `SELECT m.*, u.name as sender_name + FROM chat_messages m + JOIN users u ON m.sender_id = u.id + WHERE m.conversation_id = ? + ORDER BY m.created_at ASC`, + [conversationId] + ); + return safeArray(messages); +}; + +const saveMessage = async (conversationId, senderId, senderType, message) => { + const [result] = await db.query( + `INSERT INTO chat_messages (conversation_id, sender_id, sender_type, message) VALUES (?, ?, ?, ?)`, + [conversationId, senderId, senderType, message] + ); + + // Update conversation updated_at implicitly + await db.query(`UPDATE chat_conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [conversationId]); + + const [newMsg] = await db.query( + `SELECT m.*, u.name as sender_name FROM chat_messages m JOIN users u ON m.sender_id = u.id WHERE m.id = ?`, + [result.insertId] + ); + return newMsg[0]; +}; + +const updateConversationStatus = async (conversationId, status) => { + let query = `UPDATE chat_conversations SET status = ?`; + const params = [status]; + + if (status === 'closed') { + query += `, closed_at = CURRENT_TIMESTAMP`; + } else { + query += `, closed_at = NULL`; + } + + query += ` WHERE id = ?`; + params.push(conversationId); + + await db.query(query, params); +}; + +const assignConversation = async (conversationId, adminId) => { + await db.query( + `UPDATE chat_conversations SET assigned_admin_id = ?, status = 'pending' WHERE id = ?`, + [adminId, conversationId] + ); +}; + +const verifyConversationAccess = async (conversationId, userId, role) => { + const [conv] = await db.query(`SELECT * FROM chat_conversations WHERE id = ?`, [conversationId]); + if (!conv.length) return false; + + if (role === 'admin') return true; + return conv[0].customer_id === userId; +}; + +module.exports = { + findOrCreateConversation, + getConversationList, + getConversationMessages, + saveMessage, + updateConversationStatus, + assignConversation, + verifyConversationAccess +}; diff --git a/backend/sql/admin_dashboard_schema.sql b/backend/sql/admin_dashboard_schema.sql new file mode 100644 index 0000000..4e53abb --- /dev/null +++ b/backend/sql/admin_dashboard_schema.sql @@ -0,0 +1,19 @@ +-- backend/scripts/admin_dashboard_schema.sql + +CREATE TABLE IF NOT EXISTS user_audit_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + admin_id INT NOT NULL, + target_user_id INT, + action VARCHAR(100) NOT NULL, + metadata JSON, + ip_address VARCHAR(45), + user_agent VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (admin_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL +); + +-- Index for fast queries +CREATE INDEX idx_audit_admin ON user_audit_logs(admin_id); +CREATE INDEX idx_audit_action ON user_audit_logs(action); +CREATE INDEX idx_audit_created ON user_audit_logs(created_at); diff --git a/backend/sql/chat_schema.sql b/backend/sql/chat_schema.sql new file mode 100644 index 0000000..61f9ff3 --- /dev/null +++ b/backend/sql/chat_schema.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS chat_conversations ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_id INT NOT NULL, + assigned_admin_id INT NULL, + status ENUM('open', 'pending', 'closed') DEFAULT 'open', + priority ENUM('low', 'medium', 'high') DEFAULT 'medium', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + closed_at TIMESTAMP NULL, + FOREIGN KEY (customer_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (assigned_admin_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id INT AUTO_INCREMENT PRIMARY KEY, + conversation_id INT NOT NULL, + sender_id INT NOT NULL, + sender_type ENUM('customer', 'admin', 'system') NOT NULL, + message TEXT NOT NULL, + delivered BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE, + FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_chat_customer ON chat_conversations(customer_id); +CREATE INDEX idx_chat_admin ON chat_conversations(assigned_admin_id); +CREATE INDEX idx_chat_status ON chat_conversations(status); +CREATE INDEX idx_chat_msg_conv ON chat_messages(conversation_id); diff --git a/backend/scripts/promo_schema.sql b/backend/sql/promo_schema.sql similarity index 100% rename from backend/scripts/promo_schema.sql rename to backend/sql/promo_schema.sql diff --git a/backend/utils/socketManager.js b/backend/utils/socketManager.js new file mode 100644 index 0000000..f7696a4 --- /dev/null +++ b/backend/utils/socketManager.js @@ -0,0 +1,111 @@ +const { Server } = require("socket.io"); +const jwt = require("jsonwebtoken"); +const chatService = require("../services/chat.service"); + +let io; + +const initSocket = (server) => { + io = new Server(server, { + cors: { + origin: ["http://localhost:5500", "http://127.0.0.1:5500"], + methods: ["GET", "POST"], + credentials: true + } + }); + + // Middleware for Auth + io.use((socket, next) => { + const token = socket.handshake.auth.token; + if (!token) { + return next(new Error("Authentication error")); + } + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'secret'); + socket.user = decoded; + next(); + } catch (err) { + next(new Error("Authentication error")); + } + }); + + io.on("connection", (socket) => { + console.log(`User connected: ${socket.user.id} (${socket.user.role})`); + + socket.on("join_conversation", async (data, callback) => { + try { + let conversationId = data?.conversationId; + + // If customer joins without ID, find or create their default convo + if (socket.user.role === 'customer' && !conversationId) { + const conv = await chatService.findOrCreateConversation(socket.user.id); + conversationId = conv.id; + } + + if (!conversationId) { + if (callback) callback({ success: false, message: "No conversation ID" }); + return; + } + + // Verify access + const hasAccess = await chatService.verifyConversationAccess(conversationId, socket.user.id, socket.user.role); + if (!hasAccess) { + if (callback) callback({ success: false, message: "Unauthorized access to conversation" }); + return; + } + + socket.join(`conversation:${conversationId}`); + console.log(`User ${socket.user.id} joined conversation:${conversationId}`); + + if (callback) callback({ success: true, conversationId }); + } catch (err) { + console.error("Socket Join Error:", err); + if (callback) callback({ success: false, message: "Server error" }); + } + }); + + socket.on("send_message", async (data, callback) => { + try { + const { conversationId, message } = data; + if (!conversationId || !message?.trim()) return; + + // Check access + const hasAccess = await chatService.verifyConversationAccess(conversationId, socket.user.id, socket.user.role); + if (!hasAccess) return; + + const senderType = socket.user.role === 'admin' ? 'admin' : 'customer'; + const savedMessage = await chatService.saveMessage(conversationId, socket.user.id, senderType, message); + + // Broadcast to everyone in the room including sender + io.to(`conversation:${conversationId}`).emit("message_received", savedMessage); + + // Notify admins about new message (for dashboard updates) + io.to('admin_room').emit("conversation_updated", { conversationId, last_message: message }); + + if (callback) callback({ success: true, message: savedMessage }); + } catch (err) { + console.error("Socket Send Message Error:", err); + if (callback) callback({ success: false, message: "Server error" }); + } + }); + + socket.on("join_admin_room", () => { + if (socket.user.role === 'admin') { + socket.join('admin_room'); + console.log(`Admin ${socket.user.id} joined admin_room`); + } + }); + + socket.on("disconnect", () => { + console.log(`User disconnected: ${socket.user.id}`); + }); + }); + + return io; +}; + +const getIo = () => { + if (!io) throw new Error("Socket.io not initialized"); + return io; +}; + +module.exports = { initSocket, getIo }; diff --git a/frontend/admin.html b/frontend/admin.html index 2490025..0338348 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -82,12 +82,19 @@ > - - - + + + + +
@@ -112,7 +119,7 @@