From dbb313c85a4ff9ceadcede2a61b71f71cb39ba75 Mon Sep 17 00:00:00 2001 From: wilfred12-hub Date: Mon, 15 Dec 2025 14:16:31 +0100 Subject: [PATCH 1/2] implemented real time updates --- backend/package-lock.json | 206 ++++++++- backend/package.json | 21 +- .../src/controllers/submissionController.js | 24 ++ backend/src/server.js | 37 +- backend/src/services/websocket.js | 251 +++++++++++ frontend/package.json | 3 + frontend/src/App.tsx | 10 +- frontend/src/components/AdminDashboard.tsx | 59 ++- frontend/src/components/Navigation.tsx | 11 + .../src/components/NotificationSystem.tsx | 308 +++++++++++++ ...port React, { useState, useEffect } fr.tsx | 308 +++++++++++++ frontend/src/hooks/useWebSocket.ts | 403 ++++++++++++++++++ frontend/src/lib/api.ts | 3 +- frontend/src/lib/auth-context.tsx | 44 +- 14 files changed, 1675 insertions(+), 13 deletions(-) create mode 100644 backend/src/services/websocket.js create mode 100644 frontend/src/components/NotificationSystem.tsx create mode 100644 frontend/src/components/import React, { useState, useEffect } fr.tsx create mode 100644 frontend/src/hooks/useWebSocket.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index debec08..b8517f2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,7 +20,8 @@ "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.3", "morgan": "^1.10.0", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "socket.io": "^4.8.1" }, "devDependencies": { "nodemon": "^3.0.2" @@ -35,6 +36,27 @@ "sparse-bitfield": "^3.0.3" } }, + "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/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -96,6 +118,14 @@ "dev": true, "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==", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -444,6 +474,54 @@ "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/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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1688,6 +1766,107 @@ "node": ">=10" } }, + "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-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "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-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -1812,6 +1991,11 @@ "dev": true, "license": "MIT" }, + "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", @@ -1876,6 +2060,26 @@ "node": ">=18" } }, + "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/backend/package.json b/backend/package.json index 2ddf91e..2e9219e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,22 +9,29 @@ "dev": "nodemon src/server.js", "test": "echo \"Error: no test specified\" && exit 1" }, - "keywords": ["wikipedia", "verification", "api", "express", "mongodb"], + "keywords": [ + "wikipedia", + "verification", + "api", + "express", + "mongodb" + ], "author": "", "license": "MIT", "dependencies": { - "express": "^4.18.2", - "mongoose": "^8.0.3", "bcryptjs": "^2.4.3", - "jsonwebtoken": "^9.0.2", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", "express-validator": "^7.0.1", "helmet": "^7.1.0", - "express-rate-limit": "^7.1.5", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.0.3", "morgan": "^1.10.0", - "cookie-parser": "^1.4.6", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "socket.io": "^4.8.1" }, "devDependencies": { "nodemon": "^3.0.2" diff --git a/backend/src/controllers/submissionController.js b/backend/src/controllers/submissionController.js index 39e49c3..3dad783 100644 --- a/backend/src/controllers/submissionController.js +++ b/backend/src/controllers/submissionController.js @@ -3,6 +3,7 @@ import User from '../models/User.js'; import AppError from '../utils/AppError.js'; import { ErrorCodes } from '../utils/errorCodes.js'; + // @desc Create new submission // @route POST /api/submissions // @access Private @@ -30,6 +31,11 @@ export const createSubmission = async (req, res, next) => { const populatedSubmission = await Submission.findById(submission._id) .populate('submitter', 'username country'); + // Emit real-time notification for new submission + if (global.webSocketService) { + global.webSocketService.broadcastSubmissionCreated(populatedSubmission); + } + res.status(201).json({ success: true, submission: populatedSubmission @@ -156,12 +162,18 @@ export const updateSubmission = async (req, res, next) => { const { title, publisher, wikipediaArticle, category } = req.body; + submission = await Submission.findByIdAndUpdate( req.params.id, { title, publisher, wikipediaArticle, category }, { new: true, runValidators: true } ).populate('submitter', 'username country'); + // Emit real-time notification for submission update + if (global.webSocketService) { + global.webSocketService.broadcastSubmissionUpdated(submission, req.user); + } + res.status(200).json({ success: true, submission @@ -187,8 +199,14 @@ export const deleteSubmission = async (req, res, next) => { return next(new AppError('Not authorized to delete this submission', 403, ErrorCodes.UNAUTHORIZED_ACCESS)); } + await submission.deleteOne(); + // Emit real-time notification for submission deletion + if (global.webSocketService) { + global.webSocketService.broadcastSubmissionDeleted(submission, req.user); + } + res.status(200).json({ success: true, message: 'Submission deleted successfully' @@ -250,10 +268,16 @@ export const verifySubmission = async (req, res, next) => { $inc: { points: 5 } }); + submission = await Submission.findById(submission._id) .populate('submitter', 'username country') .populate('verifier', 'username country'); + // Emit real-time notification for submission verification + if (global.webSocketService) { + global.webSocketService.broadcastSubmissionVerified(submission, req.user); + } + res.status(200).json({ success: true, submission diff --git a/backend/src/server.js b/backend/src/server.js index b8d6994..5503bb7 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,9 +1,12 @@ + import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import morgan from 'morgan'; import cookieParser from 'cookie-parser'; import rateLimit from 'express-rate-limit'; +import { createServer } from 'http'; +import { Server as SocketIOServer } from 'socket.io'; import connectDB from './config/database.js'; import errorHandler from './middleware/errorHandler.js'; import authRoutes from './routes/authRoutes.js'; @@ -13,6 +16,7 @@ import adminRoutes from './routes/adminRoutes.js'; import countryRoutes from './routes/countryRoutes.js'; import systemRoutes from './routes/systemRoutes.js'; import reportsRoutes from './routes/reportsRoutes.js'; +import WebSocketService from './services/websocket.js'; //importing the config file where all the environment variables are stored and loaded import config from './config/config.js'; @@ -115,10 +119,41 @@ app.use((req, res) => { }); }); + const PORT = process.env.PORT || 5000; -const server = app.listen(config.port, () => { +// Create HTTP server +const httpServer = createServer(app); + +// Initialize Socket.IO with CORS configuration +const io = new SocketIOServer(httpServer, { + cors: { + origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin) return callback(null, true); + + if (allowedOrigins.indexOf(origin) !== -1) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, + methods: ['GET', 'POST'] + }, + transports: ['websocket', 'polling'] +}); + +// Initialize WebSocket Service +const webSocketService = new WebSocketService(io); + +// Make WebSocket service available globally +global.webSocketService = webSocketService; + + +const server = httpServer.listen(config.port, () => { console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`); + console.log(`🚀 WebSocket server ready for real-time connections`); }); // Handle unhandled promise rejections diff --git a/backend/src/services/websocket.js b/backend/src/services/websocket.js new file mode 100644 index 0000000..adc5bba --- /dev/null +++ b/backend/src/services/websocket.js @@ -0,0 +1,251 @@ +import jwt from 'jsonwebtoken'; +import User from '../models/User.js'; + +class WebSocketService { + constructor(io) { + this.io = io; + this.connectedUsers = new Map(); // userId -> socket + this.userSockets = new Map(); // socketId -> userId + + this.setupMiddleware(); + this.setupEventHandlers(); + } + + setupMiddleware() { + // Authentication middleware for WebSocket connections + this.io.use(async (socket, next) => { + try { + const token = socket.handshake.auth.token || socket.handshake.headers.authorization?.replace('Bearer ', ''); + + if (!token) { + return next(new Error('Authentication token required')); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const user = await User.findById(decoded.id).select('-password'); + + if (!user) { + return next(new Error('User not found')); + } + + socket.user = user; + next(); + } catch (error) { + next(new Error('Invalid authentication token')); + } + }); + } + + setupEventHandlers() { + this.io.on('connection', (socket) => { + console.log(`🔌 User ${socket.user.username} connected via WebSocket`); + + // Store user connection + this.connectedUsers.set(socket.user._id.toString(), socket); + this.userSockets.set(socket.id, socket.user._id.toString()); + + // Join user-specific room + socket.join(`user_${socket.user._id}`); + + // Join role-based rooms + socket.join(`role_${socket.user.role}`); + + // Join country-based room if user is verifier or admin + if (socket.user.role === 'verifier' || socket.user.role === 'admin') { + socket.join(`country_${socket.user.country}`); + } + + // Send connection confirmation + socket.emit('connected', { + message: 'Connected to real-time updates', + userId: socket.user._id, + role: socket.user.role, + country: socket.user.country + }); + + // Handle disconnection + socket.on('disconnect', () => { + console.log(`🔌 User ${socket.user.username} disconnected`); + this.connectedUsers.delete(socket.user._id.toString()); + this.userSockets.delete(socket.id); + }); + + // Handle explicit disconnect + socket.on('disconnect_user', () => { + socket.disconnect(); + }); + + // Handle ping/pong for connection health + socket.on('ping', () => { + socket.emit('pong'); + }); + }); + } + + // Emit to specific user + emitToUser(userId, event, data) { + this.io.to(`user_${userId}`).emit(event, data); + } + + // Emit to all users in a role + emitToRole(role, event, data) { + this.io.to(`role_${role}`).emit(event, data); + } + + // Emit to all users in a country + emitToCountry(country, event, data) { + this.io.to(`country_${country}`).emit(event, data); + } + + // Emit to all connected users + emitToAll(event, data) { + this.io.emit(event, data); + } + + // Get connected users count + getConnectedUsersCount() { + return this.connectedUsers.size; + } + + // Check if user is online + isUserOnline(userId) { + return this.connectedUsers.has(userId.toString()); + } + + // Broadcast submission events + broadcastSubmissionCreated(submission) { + const submissionData = { + id: submission._id, + title: submission.title, + publisher: submission.publisher, + country: submission.country, + category: submission.category, + submitter: { + id: submission.submitter._id, + username: submission.submitter.username, + country: submission.submitter.country + }, + createdAt: submission.createdAt, + message: `📝 New submission: "${submission.title}" from ${submission.publisher}` + }; + + // Notify all verifiers and admins + this.emitToRole('verifier', 'submission:created', submissionData); + this.emitToRole('admin', 'submission:created', submissionData); + + // Notify country-specific verifiers + this.emitToCountry(submission.country, 'submission:created', submissionData); + } + + broadcastSubmissionVerified(submission, verifier) { + const submissionData = { + id: submission._id, + title: submission.title, + publisher: submission.publisher, + country: submission.country, + category: submission.category, + status: submission.status, + credibility: submission.credibility, + verifierNotes: submission.verifierNotes, + verifier: { + id: verifier._id, + username: verifier.username, + country: verifier.country + }, + verifiedAt: submission.verifiedAt, + message: submission.status === 'approved' + ? `✅ Submission verified: "${submission.title}" marked as ${submission.credibility}` + : `❌ Submission rejected: "${submission.title}"` + }; + + // Notify the submitter + this.emitToUser(submission.submitter._id, 'submission:verified', submissionData); + + // Notify admins + this.emitToRole('admin', 'submission:verified', submissionData); + + // Notify country verifiers about the action + this.emitToCountry(submission.country, 'submission:verified', submissionData); + } + + broadcastSubmissionUpdated(submission, updatedBy) { + const submissionData = { + id: submission._id, + title: submission.title, + publisher: submission.publisher, + country: submission.country, + category: submission.category, + status: submission.status, + updatedBy: { + id: updatedBy._id, + username: updatedBy.username + }, + updatedAt: submission.updatedAt, + message: `📝 Submission updated: "${submission.title}" by ${updatedBy.username}` + }; + + // Notify admins about updates + this.emitToRole('admin', 'submission:updated', submissionData); + + // Notify country verifiers + this.emitToCountry(submission.country, 'submission:updated', submissionData); + } + + broadcastSubmissionDeleted(submission, deletedBy) { + const submissionData = { + id: submission._id, + title: submission.title, + publisher: submission.publisher, + country: submission.country, + deletedBy: { + id: deletedBy._id, + username: deletedBy.username + }, + message: `🗑️ Submission deleted: "${submission.title}" by ${deletedBy.username}` + }; + + // Notify admins + this.emitToRole('admin', 'submission:deleted', submissionData); + + // Notify country verifiers + this.emitToCountry(submission.country, 'submission:deleted', submissionData); + } + + // System-wide notifications + broadcastSystemNotification(message, type = 'info') { + const notification = { + id: Date.now(), + message, + type, + timestamp: new Date().toISOString() + }; + + this.emitToAll('system:notification', notification); + } + + // Get statistics for admins + getConnectionStats() { + const stats = { + totalConnections: this.getConnectedUsersCount(), + usersByRole: {}, + usersByCountry: {} + }; + + // Count users by role and country + for (const [userId, socket] of this.connectedUsers) { + const user = socket.user; + + // Count by role + stats.usersByRole[user.role] = (stats.usersByRole[user.role] || 0) + 1; + + // Count by country (for verifiers and admins) + if (user.role === 'verifier' || user.role === 'admin') { + stats.usersByCountry[user.country] = (stats.usersByCountry[user.country] || 0) + 1; + } + } + + return stats; + } +} + +export default WebSocketService; diff --git a/frontend/package.json b/frontend/package.json index 848c347..9249b74 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "class-variance-authority": "^0.7.1", "clsx": "*", "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^0.487.0", @@ -42,7 +43,9 @@ "react-hook-form": "^7.55.0", "react-resizable-panels": "^2.1.7", "react-router-dom": "^7.9.4", + "react-toastify": "^11.0.5", "recharts": "^2.15.2", + "socket.io-client": "^4.8.1", "sonner": "^2.0.3", "tailwind-merge": "*", "vaul": "^1.1.2" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1cc5612..7dedf61 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,13 @@ + import { useEffect } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider } from './lib/auth-context'; -import { Navigation } from './components/Navigation'; +import { Navigation } from './components/Navigation'; // Keep this as named import import { LandingPage, AuthPage, SubmissionForm, AdminDashboard, PublicDirectory, UserProfile } from './pages'; import { Toaster } from './components/ui/sonner'; +import NotificationSystem from './components/NotificationSystem'; // Change to default import import { initializeData } from './lib/mock-data'; +import React from 'react'; function AppContent() { useEffect(() => { @@ -16,6 +19,7 @@ function AppContent() {
+
} /> @@ -27,6 +31,10 @@ function AppContent() { } />
+ + {/* Real-time Notification System */} + + {/* Footer */} diff --git a/frontend/src/components/AdminDashboard.tsx b/frontend/src/components/AdminDashboard.tsx index f542bc1..a62f875 100644 --- a/frontend/src/components/AdminDashboard.tsx +++ b/frontend/src/components/AdminDashboard.tsx @@ -1,4 +1,6 @@ -import { useState, useEffect } from 'react'; + + +import React, { useState, useEffect } from 'react'; import { Button } from './ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; @@ -28,8 +30,10 @@ import { getStatusColor, } from '../lib/mock-data'; import { submissionApi } from '../lib/api'; +import { useWebSocket } from '../hooks/useWebSocket'; import { toast } from 'sonner'; + interface Submission { id: string; url: string; @@ -45,6 +49,10 @@ interface Submission { verifiedAt?: string; createdAt: string; updatedAt: string; + mediaType?: string; + credibility?: 'credible' | 'unreliable'; + submitterName?: string; + submittedDate?: string; } import { CheckCircle, XCircle, Eye, Clock, TrendingUp, Users, FileCheck } from 'lucide-react'; @@ -52,8 +60,10 @@ interface AdminDashboardProps { onNavigate: (page: string) => void; } + export const AdminDashboard: React.FC = ({ onNavigate }) => { const { user } = useAuth(); + const { on, isConnected } = useWebSocket(); const [submissions, setSubmissions] = useState([]); const [selectedSubmission, setSelectedSubmission] = useState(null); const [verificationNotes, setVerificationNotes] = useState(''); @@ -62,10 +72,57 @@ export const AdminDashboard: React.FC = ({ onNavigate }) => const [showDialog, setShowDialog] = useState(false); const [loading, setLoading] = useState(false); + useEffect(() => { loadSubmissions(); }, [user]); + // Real-time WebSocket event handlers + useEffect(() => { + if (!user || (user.role !== 'admin' && user.role !== 'verifier')) return; + + const handleNewSubmission = (data: any) => { + console.log('📝 Real-time: New submission received:', data); + // Add new submission to the list if it matches user's scope + if (user.role === 'admin' || user.country === data.country) { + loadSubmissions(); // Refresh the list + toast.info(data.message, { + duration: 4000, + position: 'bottom-right' + }); + } + }; + + const handleSubmissionVerified = (data: any) => { + console.log('✅ Real-time: Submission verification received:', data); + loadSubmissions(); // Refresh the list + }; + + const handleSubmissionUpdated = (data: any) => { + console.log('📝 Real-time: Submission update received:', data); + loadSubmissions(); // Refresh the list + }; + + const handleSubmissionDeleted = (data: any) => { + console.log('🗑️ Real-time: Submission deletion received:', data); + loadSubmissions(); // Refresh the list + }; + + // Subscribe to real-time events + const unsubscribeCreated = on('submission:created', handleNewSubmission); + const unsubscribeVerified = on('submission:verified', handleSubmissionVerified); + const unsubscribeUpdated = on('submission:updated', handleSubmissionUpdated); + const unsubscribeDeleted = on('submission:deleted', handleSubmissionDeleted); + + // Cleanup subscriptions + return () => { + unsubscribeCreated(); + unsubscribeVerified(); + unsubscribeUpdated(); + unsubscribeDeleted(); + }; + }, [user, on]); + const loadSubmissions = async () => { if (!user) return; diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index 26eee9b..e27de18 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -1,7 +1,9 @@ + import { Link, useLocation, useNavigate } from 'react-router-dom'; import { BookOpen, Search, Upload, User, LogOut, Award, Shield, LogIn } from 'lucide-react'; import { Button } from './ui/button'; import { useAuth } from '../lib/auth-context'; +import NotificationSystem from './NotificationSystem'; import { DropdownMenu, DropdownMenuContent, @@ -11,6 +13,7 @@ import { DropdownMenuTrigger, } from './ui/dropdown-menu'; import { Badge } from './ui/badge'; +import React from 'react'; export const Navigation: React.FC = () => { const { user, logout } = useAuth(); @@ -71,7 +74,15 @@ export const Navigation: React.FC = () => {
+
+ {user && ( + + )} + {user ? ( diff --git a/frontend/src/components/NotificationSystem.tsx b/frontend/src/components/NotificationSystem.tsx new file mode 100644 index 0000000..341b5e9 --- /dev/null +++ b/frontend/src/components/NotificationSystem.tsx @@ -0,0 +1,308 @@ +import React, { useState, useEffect } from 'react'; +import { Button } from './ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; +import { Badge } from './ui/badge'; +import { ScrollArea } from './ui/scroll-area'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './ui/dropdown-menu'; +import { + Bell, + BellRing, + CheckCircle, + AlertCircle, + Info, + AlertTriangle, + X, + Trash2, + Filter +} from 'lucide-react'; +import { useWebSocket } from '../hooks/useWebSocket'; + +import { formatDistanceToNow } from 'date-fns'; + +interface NotificationItem { + id: string; + type: 'submission:created' | 'submission:verified' | 'submission:updated' | 'submission:deleted' | 'system:notification'; + title: string; + message: string; + timestamp: string; + read: boolean; + data?: any; +} + +interface NotificationSystemProps { + onNotificationClick?: (notification: NotificationItem) => void; + showBadge?: boolean; + maxNotifications?: number; +} + +const NotificationSystem: React.FC = ({ + onNotificationClick, + showBadge = true, + maxNotifications = 50 +}) => { + const { notifications, isConnected, clearNotifications } = useWebSocket(); + const [notificationItems, setNotificationItems] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [filter, setFilter] = useState('all'); + const [unreadCount, setUnreadCount] = useState(0); + + // Convert WebSocket notifications to NotificationItem format + useEffect(() => { + const converted: NotificationItem[] = notifications.slice(0, maxNotifications).map((notification, index) => { + const { type, data, timestamp } = notification; + + let title = ''; + let message = data?.message || 'New notification'; + + switch (type) { + case 'submission:created': // Explicitly cast type to match NotificationItem['type'] + title = 'New Submission'; + break; + case 'submission:verified': + title = 'Submission Verified'; + break; + case 'submission:updated': + title = 'Submission Updated'; + break; + case 'submission:deleted': + title = 'Submission Deleted'; + break; + case 'system:notification': + title = 'System Notification'; + break; + default: + title = 'Notification'; + } + + return { + id: `${type}-${timestamp}-${index}`, + type: type as NotificationItem['type'], // Cast type to match NotificationItem['type'] + title, + message, + timestamp, + read: false, + data + }; + }); + + setNotificationItems(converted); + }, [notifications, maxNotifications]); + + // Calculate unread count + useEffect(() => { + const unread = notificationItems.filter(item => !item.read).length; + setUnreadCount(unread); + }, [notificationItems]); + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'submission:created': + return ; + case 'submission:verified': + return ; + case 'submission:updated': + return ; + case 'submission:deleted': + return ; + case 'system:notification': + return ; + default: + return ; + } + }; + + const getNotificationColor = (type: string) => { + switch (type) { + case 'submission:created': + return 'border-l-blue-500'; + case 'submission:verified': + return 'border-l-green-500'; + case 'submission:updated': + return 'border-l-blue-500'; + case 'submission:deleted': + return 'border-l-orange-500'; + case 'system:notification': + return 'border-l-purple-500'; + default: + return 'border-l-gray-500'; + } + }; + + const handleNotificationClick = (notification: NotificationItem) => { + // Mark as read + setNotificationItems(prev => + prev.map(item => + item.id === notification.id + ? { ...item, read: true } + : item + ) + ); + + // Call external handler if provided + if (onNotificationClick) { + onNotificationClick(notification); + } + }; + + const markAllAsRead = () => { + setNotificationItems(prev => + prev.map(item => ({ ...item, read: true })) + ); + }; + + const clearAllNotifications = () => { + clearNotifications(); + }; + + const filteredNotifications = notificationItems.filter(item => { + if (filter === 'all') return true; + if (filter === 'unread') return !item.read; + return item.type === filter; + }); + + const getConnectionStatusIcon = () => { + if (isConnected) { + return ; + } + return ; + }; + + return ( + + + + + +
+
+

Notifications

+ {isConnected ? ( + + Live + + ) : ( + + Offline + + )} +
+
+ + + + + + setFilter('all')}> + All + + setFilter('unread')}> + Unread + + setFilter('submission:created')}> + New Submissions + + setFilter('submission:verified')}> + Verifications + + setFilter('system:notification')}> + System + + + +
+
+ + {notificationItems.length === 0 ? ( +
+ +

No notifications yet

+

You'll see real-time updates here

+
+ ) : ( + <> +
+ + {filteredNotifications.length} notification{filteredNotifications.length !== 1 ? 's' : ''} + +
+ + +
+
+ + +
+ {filteredNotifications.map((notification) => ( + handleNotificationClick(notification)} + > +
+
+ {getNotificationIcon(notification.type)} +
+
+
+

+ {notification.title} +

+ {!notification.read && ( +
+ )} +
+

+ {notification.message} +

+

+ {formatDistanceToNow(new Date(notification.timestamp), { addSuffix: true })} +

+
+
+ + ))} +
+ + + )} + + + ); +}; + +export default NotificationSystem; diff --git a/frontend/src/components/import React, { useState, useEffect } fr.tsx b/frontend/src/components/import React, { useState, useEffect } fr.tsx new file mode 100644 index 0000000..8d6213e --- /dev/null +++ b/frontend/src/components/import React, { useState, useEffect } fr.tsx @@ -0,0 +1,308 @@ +import React, { useState, useEffect } from 'react'; +import { Button } from './ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; +import { Badge } from './ui/badge'; +import { ScrollArea } from './ui/scroll-area'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './ui/dropdown-menu'; +import { + Bell, + BellRing, + CheckCircle, + AlertCircle, + Info, + AlertTriangle, + X, + Trash2, + Filter +} from 'lucide-react'; +import { useWebSocket } from '../hooks/useWebSocket'; + +import { formatDistanceToNow } from 'date-fns'; + +interface NotificationItem { + id: string; + type: 'submission:created' | 'submission:verified' | 'submission:updated' | 'submission:deleted' | 'system:notification'; + title: string; + message: string; + timestamp: string; + read: boolean; + data?: any; +} + +interface NotificationSystemProps { + onNotificationClick?: (notification: NotificationItem) => void; + showBadge?: boolean; + maxNotifications?: number; +} + +const NotificationSystem: React.FC = ({ + onNotificationClick, + showBadge = true, + maxNotifications = 50 +}) => { + const { notifications, isConnected, clearNotifications } = useWebSocket(); + const [notificationItems, setNotificationItems] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [filter, setFilter] = useState('all'); + const [unreadCount, setUnreadCount] = useState(0); + + // Convert WebSocket notifications to NotificationItem format + useEffect(() => { + const converted: NotificationItem[] = notifications.slice(0, maxNotifications).map((notification, index) => { + const { type, data, timestamp } = notification; + + let title = ''; + let message = data?.message || 'New notification'; + + switch (type) { + case 'submission:created': // Explicitly cast type to NotificationItem['type'] + title = 'New Submission'; + break; + case 'submission:verified': + title = 'Submission Verified'; + break; + case 'submission:updated': + title = 'Submission Updated'; + break; + case 'submission:deleted': + title = 'Submission Deleted'; + break; + case 'system:notification': + title = 'System Notification'; + break; + default: + title = 'Notification'; + } + + return { // Explicitly cast type to NotificationItem['type'] + id: `${type}-${timestamp}-${index}`, + type, + title, + message, + timestamp, + read: false, + data + }; + }); + + setNotificationItems(converted); + }, [notifications, maxNotifications]); + + // Calculate unread count + useEffect(() => { + const unread = notificationItems.filter(item => !item.read).length; + setUnreadCount(unread); + }, [notificationItems]); + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'submission:created': + return ; + case 'submission:verified': + return ; + case 'submission:updated': + return ; + case 'submission:deleted': + return ; + case 'system:notification': + return ; + default: + return ; + } + }; + + const getNotificationColor = (type: string) => { + switch (type) { + case 'submission:created': + return 'border-l-blue-500'; + case 'submission:verified': + return 'border-l-green-500'; + case 'submission:updated': + return 'border-l-blue-500'; + case 'submission:deleted': + return 'border-l-orange-500'; + case 'system:notification': + return 'border-l-purple-500'; + default: + return 'border-l-gray-500'; + } + }; + + const handleNotificationClick = (notification: NotificationItem) => { + // Mark as read + setNotificationItems(prev => + prev.map(item => + item.id === notification.id + ? { ...item, read: true } + : item + ) + ); + + // Call external handler if provided + if (onNotificationClick) { + onNotificationClick(notification); + } + }; + + const markAllAsRead = () => { + setNotificationItems(prev => + prev.map(item => ({ ...item, read: true })) + ); + }; + + const clearAllNotifications = () => { + clearNotifications(); + }; + + const filteredNotifications = notificationItems.filter(item => { + if (filter === 'all') return true; + if (filter === 'unread') return !item.read; + return item.type === filter; + }); + + const getConnectionStatusIcon = () => { + if (isConnected) { + return ; + } + return ; + }; + + return ( + + + + + +
+
+

Notifications

+ {isConnected ? ( + + Live + + ) : ( + + Offline + + )} +
+
+ + + + + + setFilter('all')}> + All + + setFilter('unread')}> + Unread + + setFilter('submission:created')}> + New Submissions + + setFilter('submission:verified')}> + Verifications + + setFilter('system:notification')}> + System + + + +
+
+ + {notificationItems.length === 0 ? ( +
+ +

No notifications yet

+

You'll see real-time updates here

+
+ ) : ( + <> +
+ + {filteredNotifications.length} notification{filteredNotifications.length !== 1 ? 's' : ''} + +
+ + +
+
+ + +
+ {filteredNotifications.map((notification) => ( + handleNotificationClick(notification)} + > +
+
+ {getNotificationIcon(notification.type)} +
+
+
+

+ {notification.title} +

+ {!notification.read && ( +
+ )} +
+

+ {notification.message} +

+

+ {formatDistanceToNow(new Date(notification.timestamp), { addSuffix: true })} +

+
+
+ + ))} +
+ + + )} + + + ); +}; + +export default NotificationSystem; diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..b15d441 --- /dev/null +++ b/frontend/src/hooks/useWebSocket.ts @@ -0,0 +1,403 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { io, Socket } from 'socket.io-client'; +import { toast } from 'sonner'; + +interface WebSocketEvent { + type: string; + data: any; + timestamp: string; +} + +interface UseWebSocketReturn { + socket: Socket | null; + isConnected: boolean; + connectionError: string | null; + emit: (event: string, data: any) => void; + on: (event: string, callback: (data: any) => void) => () => void; + off: (event: string, callback?: (data: any) => void) => void; + disconnect: () => void; + notifications: WebSocketEvent[]; + clearNotifications: () => void; +} + + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001/api'; + +export const useWebSocket = (): UseWebSocketReturn => { + const socketRef = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [connectionError, setConnectionError] = useState(null); + const [notifications, setNotifications] = useState([]); + const eventListenersRef = useRef void>>>(new Map()); + + const getAuthToken = useCallback(() => { + return localStorage.getItem('accessToken'); + }, []); + + + const connect = useCallback(() => { + const token = getAuthToken(); + if (!token) { + console.log('🔌 No auth token available for WebSocket connection'); + return undefined; // Add return statement + } + + console.log('🔌 Attempting WebSocket connection...'); + + try { + // Extract base URL from API_URL + const baseURL = API_URL.replace('/api', ''); + + socketRef.current = io(baseURL, { + auth: { + token: token + }, + transports: ['websocket', 'polling'], + upgrade: true, + rememberUpgrade: true, + timeout: 10000, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000 + }); + + const socket = socketRef.current; + + // Connection event handlers + socket.on('connect', () => { + console.log('🔌 WebSocket connected successfully'); + setIsConnected(true); + setConnectionError(null); + + // Show connection success toast + toast.success('🔌 Connected to real-time updates', { + duration: 2000, + position: 'bottom-right' + }); + }); + + socket.on('connected', (data) => { + console.log('🔌 WebSocket connection confirmed:', data); + setIsConnected(true); + setConnectionError(null); + }); + + socket.on('disconnect', (reason) => { + console.log('🔌 WebSocket disconnected:', reason); + setIsConnected(false); + + if (reason === 'io server disconnect') { + // Server disconnected the client, need to reconnect manually + setConnectionError('Server disconnected the connection'); + } + }); + + socket.on('connect_error', (error) => { + console.error('🔌 WebSocket connection error:', error); + setConnectionError(error.message); + setIsConnected(false); + + // Show connection error toast + toast.error('🔌 Connection failed: ' + error.message, { + duration: 4000, + position: 'bottom-right' + }); + }); + + socket.on('reconnect', (attemptNumber) => { + console.log(`🔌 WebSocket reconnected after ${attemptNumber} attempts`); + setIsConnected(true); + setConnectionError(null); + + toast.success('🔌 Reconnected to real-time updates', { + duration: 2000, + position: 'bottom-right' + }); + }); + + socket.on('reconnect_error', (error) => { + console.error('🔌 WebSocket reconnection error:', error); + setConnectionError(error.message); + }); + + socket.on('reconnect_failed', () => { + console.error('🔌 WebSocket reconnection failed'); + setConnectionError('Failed to reconnect to server'); + toast.error('🔌 Failed to reconnect. Please refresh the page.', { + duration: 5000, + position: 'bottom-right' + }); + }); + + // Submission event handlers + socket.on('submission:created', (data) => { + console.log('📝 New submission received:', data); + + const notification: WebSocketEvent = { + type: 'submission:created', + data, + timestamp: new Date().toISOString() + }; + + setNotifications(prev => [notification, ...prev.slice(0, 49)]); // Keep last 50 notifications + + // Show toast notification + toast.success(data.message, { + duration: 5000, + position: 'bottom-right' + }); + + // Trigger event listeners + const listeners = eventListenersRef.current.get('submission:created'); + if (listeners) { + listeners.forEach(callback => callback(data)); + } + }); + + socket.on('submission:verified', (data) => { + console.log('✅ Submission verification received:', data); + + const notification: WebSocketEvent = { + type: 'submission:verified', + data, + timestamp: new Date().toISOString() + }; + + setNotifications(prev => [notification, ...prev.slice(0, 49)]); + + // Show toast notification + toast.success(data.message, { + duration: 5000, + position: 'bottom-right' + }); + + // Trigger event listeners + const listeners = eventListenersRef.current.get('submission:verified'); + if (listeners) { + listeners.forEach(callback => callback(data)); + } + }); + + socket.on('submission:updated', (data) => { + console.log('📝 Submission update received:', data); + + const notification: WebSocketEvent = { + type: 'submission:updated', + data, + timestamp: new Date().toISOString() + }; + + setNotifications(prev => [notification, ...prev.slice(0, 49)]); + + // Show info toast + toast.info(data.message, { + duration: 4000, + position: 'bottom-right' + }); + + // Trigger event listeners + const listeners = eventListenersRef.current.get('submission:updated'); + if (listeners) { + listeners.forEach(callback => callback(data)); + } + }); + + socket.on('submission:deleted', (data) => { + console.log('🗑️ Submission deletion received:', data); + + const notification: WebSocketEvent = { + type: 'submission:deleted', + data, + timestamp: new Date().toISOString() + }; + + setNotifications(prev => [notification, ...prev.slice(0, 49)]); + + // Show warning toast + toast.warning(data.message, { + duration: 4000, + position: 'bottom-right' + }); + + // Trigger event listeners + const listeners = eventListenersRef.current.get('submission:deleted'); + if (listeners) { + listeners.forEach(callback => callback(data)); + } + }); + + socket.on('system:notification', (data) => { + console.log('📢 System notification received:', data); + + const notification: WebSocketEvent = { + type: 'system:notification', + data, + timestamp: new Date().toISOString() + }; + + setNotifications(prev => [notification, ...prev.slice(0, 49)]); + + // Show system notification toast + const toastOptions = { + duration: 6000, + position: 'bottom-right' as const + }; + + switch (data.type) { + case 'error': + toast.error(data.message, toastOptions); + break; + case 'warning': + toast.warning(data.message, toastOptions); + break; + case 'success': + toast.success(data.message, toastOptions); + break; + default: + toast.info(data.message, toastOptions); + } + + // Trigger event listeners + const listeners = eventListenersRef.current.get('system:notification'); + if (listeners) { + listeners.forEach(callback => callback(data)); + } + }); + + // Health check + socket.on('pong', () => { + console.log('🔍 WebSocket health check response received'); + }); + + // Start health check ping + const pingInterval = setInterval(() => { + if (socket.connected) { + socket.emit('ping'); + } else { + clearInterval(pingInterval); + } + }, 30000); // Ping every 30 seconds + + return () => { + clearInterval(pingInterval); + }; + + } catch (error) { + console.error('🔌 Error creating WebSocket connection:', error); + setConnectionError('Failed to create connection'); + } + }, [getAuthToken]); + + const disconnect = useCallback(() => { + if (socketRef.current) { + console.log('🔌 Manually disconnecting WebSocket'); + socketRef.current.disconnect(); + socketRef.current = null; + setIsConnected(false); + } + }, []); + + const emit = useCallback((event: string, data: any) => { + if (socketRef.current && socketRef.current.connected) { + socketRef.current.emit(event, data); + } else { + console.warn('🔌 Cannot emit event: WebSocket not connected'); + } + }, []); + + const on = useCallback((event: string, callback: (data: any) => void) => { + // Store callback reference + if (!eventListenersRef.current.has(event)) { + eventListenersRef.current.set(event, new Set()); + } + eventListenersRef.current.get(event)!.add(callback); + + // Also register with socket if connected + if (socketRef.current && socketRef.current.connected) { + socketRef.current.on(event, callback); + } + + // Return unsubscribe function + return () => { + const listeners = eventListenersRef.current.get(event); + if (listeners) { + listeners.delete(callback); + if (listeners.size === 0) { + eventListenersRef.current.delete(event); + } + } + + if (socketRef.current && socketRef.current.connected) { + socketRef.current.off(event, callback); + } + }; + }, []); + + const off = useCallback((event: string, callback?: (data: any) => void) => { + const listeners = eventListenersRef.current.get(event); + if (listeners && callback) { + listeners.delete(callback); + if (listeners.size === 0) { + eventListenersRef.current.delete(event); + } + } else if (listeners && !callback) { + // Remove all listeners for this event + eventListenersRef.current.delete(event); + } + + if (socketRef.current && socketRef.current.connected) { + if (callback) { + socketRef.current.off(event, callback); + } else { + socketRef.current.off(event); + } + } + }, []); + + const clearNotifications = useCallback(() => { + setNotifications([]); + }, []); + + // Connect on mount if user is authenticated + useEffect(() => { + const token = getAuthToken(); + if (token) { + connect(); + } + + // Cleanup on unmount + return () => { + disconnect(); + }; + }, [connect, disconnect, getAuthToken]); + + // Reconnect when token changes + useEffect(() => { + const handleStorageChange = () => { + const newToken = getAuthToken(); + if (newToken && !isConnected) { + connect(); + } else if (!newToken && isConnected) { + disconnect(); + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); + }, [connect, disconnect, getAuthToken, isConnected]); + + return { + socket: socketRef.current, + isConnected, + connectionError, + emit, + on, + off, + disconnect, + notifications, + clearNotifications + }; +}; + +export default useWebSocket; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ba7ac66..a8d870e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,5 @@ -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000/api'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001/api'; interface ApiResponse { success: boolean; diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx index ec6b08f..3d8d3a3 100644 --- a/frontend/src/lib/auth-context.tsx +++ b/frontend/src/lib/auth-context.tsx @@ -1,6 +1,8 @@ + import React, { createContext, useContext, useState, useEffect } from 'react'; import { authApi, api } from './api'; import { toast } from 'sonner'; +import { useWebSocket } from '../hooks/useWebSocket'; export interface User { id: string; @@ -20,13 +22,19 @@ interface AuthContextType { logout: () => void; register: (username: string, email: string, password: string, country: string) => Promise; updateUser: (updates: Partial) => void; + isConnected: boolean; + connectionError: string | null; } const AuthContext = createContext(undefined); + export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + + // Use WebSocket hook for connection state + const { isConnected, connectionError, on } = useWebSocket(); useEffect(() => { // Check if user is logged in on mount @@ -46,6 +54,32 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children checkAuth(); }, []); + // Handle real-time submission verification events + useEffect(() => { + if (!user) return; + + const handleSubmissionVerified = (data: any) => { + if (data.submitter.id === user.id) { + // This is our submission that got verified + const message = data.status === 'approved' + ? `Your submission "${data.title}" was approved as ${data.credibility}!` + : `Your submission "${data.title}" was rejected.`; + + toast.success(message); + + // Update user points if submission was approved + if (data.status === 'approved') { + const points = data.credibility === 'credible' ? 25 : 10; + updateUser({ points: user.points + points }); + } + } + }; + + const unsubscribe = on('submission:verified', handleSubmissionVerified); + + return unsubscribe; + }, [user, on]); + const login = async (username: string, password: string): Promise => { try { const response = await authApi.login(username, password); @@ -121,7 +155,15 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }; return ( - + {children} ); From 223b9f1b1297a04c0ce079b983594a02618ca34f Mon Sep 17 00:00:00 2001 From: wilfred12-hub Date: Mon, 15 Dec 2025 14:17:21 +0100 Subject: [PATCH 2/2] implemented real time updates --- ...port React, { useState, useEffect } fr.tsx | 308 ------------------ 1 file changed, 308 deletions(-) delete mode 100644 frontend/src/components/import React, { useState, useEffect } fr.tsx diff --git a/frontend/src/components/import React, { useState, useEffect } fr.tsx b/frontend/src/components/import React, { useState, useEffect } fr.tsx deleted file mode 100644 index 8d6213e..0000000 --- a/frontend/src/components/import React, { useState, useEffect } fr.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Button } from './ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; -import { Badge } from './ui/badge'; -import { ScrollArea } from './ui/scroll-area'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from './ui/dropdown-menu'; -import { - Bell, - BellRing, - CheckCircle, - AlertCircle, - Info, - AlertTriangle, - X, - Trash2, - Filter -} from 'lucide-react'; -import { useWebSocket } from '../hooks/useWebSocket'; - -import { formatDistanceToNow } from 'date-fns'; - -interface NotificationItem { - id: string; - type: 'submission:created' | 'submission:verified' | 'submission:updated' | 'submission:deleted' | 'system:notification'; - title: string; - message: string; - timestamp: string; - read: boolean; - data?: any; -} - -interface NotificationSystemProps { - onNotificationClick?: (notification: NotificationItem) => void; - showBadge?: boolean; - maxNotifications?: number; -} - -const NotificationSystem: React.FC = ({ - onNotificationClick, - showBadge = true, - maxNotifications = 50 -}) => { - const { notifications, isConnected, clearNotifications } = useWebSocket(); - const [notificationItems, setNotificationItems] = useState([]); - const [isOpen, setIsOpen] = useState(false); - const [filter, setFilter] = useState('all'); - const [unreadCount, setUnreadCount] = useState(0); - - // Convert WebSocket notifications to NotificationItem format - useEffect(() => { - const converted: NotificationItem[] = notifications.slice(0, maxNotifications).map((notification, index) => { - const { type, data, timestamp } = notification; - - let title = ''; - let message = data?.message || 'New notification'; - - switch (type) { - case 'submission:created': // Explicitly cast type to NotificationItem['type'] - title = 'New Submission'; - break; - case 'submission:verified': - title = 'Submission Verified'; - break; - case 'submission:updated': - title = 'Submission Updated'; - break; - case 'submission:deleted': - title = 'Submission Deleted'; - break; - case 'system:notification': - title = 'System Notification'; - break; - default: - title = 'Notification'; - } - - return { // Explicitly cast type to NotificationItem['type'] - id: `${type}-${timestamp}-${index}`, - type, - title, - message, - timestamp, - read: false, - data - }; - }); - - setNotificationItems(converted); - }, [notifications, maxNotifications]); - - // Calculate unread count - useEffect(() => { - const unread = notificationItems.filter(item => !item.read).length; - setUnreadCount(unread); - }, [notificationItems]); - - const getNotificationIcon = (type: string) => { - switch (type) { - case 'submission:created': - return ; - case 'submission:verified': - return ; - case 'submission:updated': - return ; - case 'submission:deleted': - return ; - case 'system:notification': - return ; - default: - return ; - } - }; - - const getNotificationColor = (type: string) => { - switch (type) { - case 'submission:created': - return 'border-l-blue-500'; - case 'submission:verified': - return 'border-l-green-500'; - case 'submission:updated': - return 'border-l-blue-500'; - case 'submission:deleted': - return 'border-l-orange-500'; - case 'system:notification': - return 'border-l-purple-500'; - default: - return 'border-l-gray-500'; - } - }; - - const handleNotificationClick = (notification: NotificationItem) => { - // Mark as read - setNotificationItems(prev => - prev.map(item => - item.id === notification.id - ? { ...item, read: true } - : item - ) - ); - - // Call external handler if provided - if (onNotificationClick) { - onNotificationClick(notification); - } - }; - - const markAllAsRead = () => { - setNotificationItems(prev => - prev.map(item => ({ ...item, read: true })) - ); - }; - - const clearAllNotifications = () => { - clearNotifications(); - }; - - const filteredNotifications = notificationItems.filter(item => { - if (filter === 'all') return true; - if (filter === 'unread') return !item.read; - return item.type === filter; - }); - - const getConnectionStatusIcon = () => { - if (isConnected) { - return ; - } - return ; - }; - - return ( - - - - - -
-
-

Notifications

- {isConnected ? ( - - Live - - ) : ( - - Offline - - )} -
-
- - - - - - setFilter('all')}> - All - - setFilter('unread')}> - Unread - - setFilter('submission:created')}> - New Submissions - - setFilter('submission:verified')}> - Verifications - - setFilter('system:notification')}> - System - - - -
-
- - {notificationItems.length === 0 ? ( -
- -

No notifications yet

-

You'll see real-time updates here

-
- ) : ( - <> -
- - {filteredNotifications.length} notification{filteredNotifications.length !== 1 ? 's' : ''} - -
- - -
-
- - -
- {filteredNotifications.map((notification) => ( - handleNotificationClick(notification)} - > -
-
- {getNotificationIcon(notification.type)} -
-
-
-

- {notification.title} -

- {!notification.read && ( -
- )} -
-

- {notification.message} -

-

- {formatDistanceToNow(new Date(notification.timestamp), { addSuffix: true })} -

-
-
- - ))} -
- - - )} - - - ); -}; - -export default NotificationSystem;