From 0e9498a2d025cdb739741d3caca1cab8841de4ab Mon Sep 17 00:00:00 2001 From: Rafi Athallah <65345768+rafiathallah3@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:51:31 +0700 Subject: [PATCH] feat: Nambahin feature notifikasi --- package-lock.json | 178 +++++++++++++- package.json | 2 + prisma/schema.prisma | 19 ++ src/helpers/socket/socket_connection.js | 62 +++++ src/index.js | 9 +- .../controllers/notification.controller.js | 77 ++++++ src/modules/notification/index.js | 5 + .../repositories/notification.repository.js | 58 +++++ .../services/notification.service.js | 141 +++++++++++ .../project/services/project.service.js | 15 ++ src/modules/task/services/task.service.js | 37 ++- src/routes/notification.routes.js | 157 ++++++++++++ test/test_mentor_tasks.js | 209 ++++++++++++++++ test/test_notifications.js | 227 ++++++++++++++++++ 14 files changed, 1185 insertions(+), 11 deletions(-) create mode 100644 src/helpers/socket/socket_connection.js create mode 100644 src/modules/notification/controllers/notification.controller.js create mode 100644 src/modules/notification/index.js create mode 100644 src/modules/notification/repositories/notification.repository.js create mode 100644 src/modules/notification/services/notification.service.js create mode 100644 src/routes/notification.routes.js create mode 100644 test/test_mentor_tasks.js create mode 100644 test/test_notifications.js diff --git a/package-lock.json b/package-lock.json index e7dfb6b..6ffc4ef 100755 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,8 @@ "nodemailer": "^7.0.3", "nodemon": "^3.1.10", "qs": "^6.14.0", + "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0" @@ -286,6 +288,12 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "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/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -311,6 +319,15 @@ "@types/node": "*" } }, + "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/express": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", @@ -410,6 +427,15 @@ "@types/serve-static": "*" } }, + "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/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -626,6 +652,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -1228,9 +1263,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1427,6 +1462,58 @@ "once": "^1.4.0" } }, + "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-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3395,6 +3482,62 @@ "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-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.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/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -3806,12 +3949,41 @@ "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 + } + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index bc1b35b..752aeac 100755 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "nodemailer": "^7.0.3", "nodemon": "^3.1.10", "qs": "^6.14.0", + "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 847fa95..c0d5fcb 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model Admin { professional_bio String? @db.Text projects Project[] + notifications Notification[] @@map("admin") } @@ -195,6 +196,7 @@ model User { project_members ProjectMember[] task_submissions TaskSubmission[] certificates Certificate[] + notifications Notification[] @@index([email]) @@map("user") @@ -347,3 +349,20 @@ model Certificate { @@index([id_user], map: "certificate_id_user_foreign") @@map("certificate") } + +model Notification { + id Int @id @default(autoincrement()) + id_user Int? // For interns + id_admin Int? // For mentors/admins + title String @db.VarChar(255) + message String @db.Text + is_read Boolean @default(false) + created_at DateTime @default(now()) + + user User? @relation(fields: [id_user], references: [id], onDelete: Cascade) + admin Admin? @relation(fields: [id_admin], references: [id], onDelete: Cascade) + + @@index([id_user]) + @@index([id_admin]) + @@map("notification") +} diff --git a/src/helpers/socket/socket_connection.js b/src/helpers/socket/socket_connection.js new file mode 100644 index 0000000..7052bcb --- /dev/null +++ b/src/helpers/socket/socket_connection.js @@ -0,0 +1,62 @@ +const { Server } = require('socket.io'); +const jwt = require('jsonwebtoken'); +const { config } = require('../infra/global_config'); + +let io = null; + +function initSocket(server) { + io = new Server(server, { + cors: { + origin: "*", + methods: ["GET", "POST", "PATCH", "PUT", "DELETE"] + } + }); + + io.use((socket, next) => { + const token = socket.handshake.auth?.token || socket.handshake.query?.token; + + if (!token) { + return next(new Error('Authentication token required')); + } + + const publicKey = config.jwtPublicKey ? config.jwtPublicKey.replace(/\\n/g, '\n') : null; + if (!publicKey) { + return next(new Error('JWT public key not configured')); + } + + jwt.verify(token, publicKey, { algorithms: ['RS256'] }, (err, decoded) => { + if (err) { + return next(new Error('Authentication failed: Invalid or expired token')); + } + + socket.user = { + id: decoded.id, + role: decoded.role, + email: decoded.email + }; + next(); + }); + }); + + io.on('connection', (socket) => { + const { id, role } = socket.user; + + if (role === 'admin' || role === 'mentor') { + socket.join(`admin:${id}`); + } else if (role === 'intern') { + socket.join(`user:${id}`); + } + + socket.on('disconnect', () => { + // Otomatis keluar [Rafi 14:47 23/06/2026] + }); + }); + + return io; +} + +function getIO() { + return io; +} + +module.exports = { initSocket, getIO }; diff --git a/src/index.js b/src/index.js index 0dfeedd..d0006e8 100755 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,7 @@ require('dotenv').config(); const express = require('express'); +const http = require('http'); +const { initSocket } = require('./helpers/socket/socket_connection'); const cors = require('cors'); const path = require('path'); const adminRoutes = require('./routes/admin.routes'); @@ -15,6 +17,7 @@ const batchRoutes = require('./routes/batch.routes'); const projectRoutes = require('./routes/project.routes'); const taskRoutes = require('./routes/task.routes'); const certificateRoutes = require('./routes/certificate.routes'); +const notificationRoutes = require('./routes/notification.routes'); const setupSwaggerDocs = require('./docs/swagger'); const PORT = process.env.PORT; @@ -60,6 +63,7 @@ app.use("/batch-api", batchRoutes); app.use("/project-api", projectRoutes); app.use("/task-api", taskRoutes); app.use("/certificate-api", certificateRoutes); +app.use("/notification-api", notificationRoutes); // app.use("/project-member-api", projectMemberRoutes); @@ -73,8 +77,11 @@ app.use((req, res) => { }); }); +const server = http.createServer(app); +initSocket(server); + if (process.env.NODE_ENV !== 'production') { - app.listen(PORT, () => { + server.listen(PORT, () => { console.log(`listening at http://localhost:${PORT}`); }); } diff --git a/src/modules/notification/controllers/notification.controller.js b/src/modules/notification/controllers/notification.controller.js new file mode 100644 index 0000000..567d915 --- /dev/null +++ b/src/modules/notification/controllers/notification.controller.js @@ -0,0 +1,77 @@ +const notificationService = require('../services/notification.service'); +const { response, error } = require('../../../helpers/utils/wrapper'); +const { SUCCESS, ERROR } = require('../../../helpers/http-status/status_code'); +const { InternalServerError } = require('../../../helpers/error'); + +const getActor = (req) => ({ + id: req.id, + role: req.role, + email: req.email, +}); + +class NotificationController { + async getNotifications(req, res) { + try { + const actor = getActor(req); + const result = await notificationService.getNotifications(actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Notifications retrieved successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async markAsRead(req, res) { + try { + const actor = getActor(req); + const result = await notificationService.markAsRead(req.params.id, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Notification marked as read successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } + + async markAllAsRead(req, res) { + try { + const actor = getActor(req); + const result = await notificationService.markAllAsRead(actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'All notifications marked as read', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } +} + +module.exports = new NotificationController(); diff --git a/src/modules/notification/index.js b/src/modules/notification/index.js new file mode 100644 index 0000000..a849b5d --- /dev/null +++ b/src/modules/notification/index.js @@ -0,0 +1,5 @@ +const notificationController = require('./controllers/notification.controller'); + +module.exports = { + notificationController +}; diff --git a/src/modules/notification/repositories/notification.repository.js b/src/modules/notification/repositories/notification.repository.js new file mode 100644 index 0000000..d277457 --- /dev/null +++ b/src/modules/notification/repositories/notification.repository.js @@ -0,0 +1,58 @@ +const prisma = require('../../../helpers/db/db_connection'); + +class NotificationRepository { + async create(data) { + return prisma.notification.create({ + data + }); + } + + async findManyByUser(userId) { + return prisma.notification.findMany({ + where: { id_user: parseInt(userId) }, + orderBy: { created_at: 'desc' } + }); + } + + async findManyByAdmin(adminId) { + return prisma.notification.findMany({ + where: { id_admin: parseInt(adminId) }, + orderBy: { created_at: 'desc' } + }); + } + + async findById(id) { + return prisma.notification.findUnique({ + where: { id: parseInt(id) } + }); + } + + async updateReadStatus(id, isRead) { + return prisma.notification.update({ + where: { id: parseInt(id) }, + data: { is_read: isRead } + }); + } + + async markAllAsReadForUser(userId) { + return prisma.notification.updateMany({ + where: { + id_user: parseInt(userId), + is_read: false + }, + data: { is_read: true } + }); + } + + async markAllAsReadForAdmin(adminId) { + return prisma.notification.updateMany({ + where: { + id_admin: parseInt(adminId), + is_read: false + }, + data: { is_read: true } + }); + } +} + +module.exports = new NotificationRepository(); diff --git a/src/modules/notification/services/notification.service.js b/src/modules/notification/services/notification.service.js new file mode 100644 index 0000000..003bee7 --- /dev/null +++ b/src/modules/notification/services/notification.service.js @@ -0,0 +1,141 @@ +const notificationRepository = require('../repositories/notification.repository'); +const taskRepository = require('../../task/repositories/task.repository'); +const { getIO } = require('../../../helpers/socket/socket_connection'); +const { data, error } = require('../../../helpers/utils/wrapper'); +const { NotFoundError, ForbiddenError, BadRequestError } = require('../../../helpers/error'); + +class NotificationService { + async createNotificationForIntern(userId, title, message) { + try { + const notification = await notificationRepository.create({ + id_user: parseInt(userId), + id_admin: null, + title, + message, + is_read: false + }); + + const io = getIO(); + if (io) { + io.to(`user:${userId}`).emit('notification', notification); + } + + return data(notification); + } catch (err) { + return error(err); + } + } + + async createNotificationForMentor(adminId, title, message) { + try { + const notification = await notificationRepository.create({ + id_user: null, + id_admin: parseInt(adminId), + title, + message, + is_read: false + }); + + const io = getIO(); + if (io) { + io.to(`admin:${adminId}`).emit('notification', notification); + } + + return data(notification); + } catch (err) { + return error(err); + } + } + + async createNotificationForProjectInterns(projectId, title, message) { + try { + const project = await taskRepository.findProjectById(projectId); + if (!project || !project.members) { + return data([]); + } + + const results = []; + for (const member of project.members) { + const res = await this.createNotificationForIntern(member.id_user, title, message); + if (res.data) { + results.push(res.data); + } + } + + return data(results); + } catch (err) { + return error(err); + } + } + + async getNotifications(actor = {}) { + try { + if (!actor?.id || !actor?.role) { + return error(new BadRequestError('Authentication data is missing')); + } + + let notifications = []; + if (actor.role === 'admin' || actor.role === 'mentor') { + notifications = await notificationRepository.findManyByAdmin(actor.id); + } else if (actor.role === 'intern') { + notifications = await notificationRepository.findManyByUser(actor.id); + } + + return data(notifications); + } catch (err) { + return error(err); + } + } + + async markAsRead(idNotification, actor = {}) { + try { + if (!actor?.id || !actor?.role) { + return error(new BadRequestError('Authentication data is missing')); + } + + const notification = await notificationRepository.findById(idNotification); + if (!notification) { + return error(new NotFoundError('Notification not found')); + } + + if (actor.role === 'intern') { + if (notification.id_user !== parseInt(actor.id)) { + return error(new ForbiddenError('Access denied: not your notification')); + } + } else if (actor.role === 'admin' || actor.role === 'mentor') { + if (notification.id_admin !== parseInt(actor.id)) { + return error(new ForbiddenError('Access denied: not your notification')); + } + } else { + return error(new ForbiddenError('Access denied: invalid role')); + } + + const updated = await notificationRepository.updateReadStatus(idNotification, true); + return data(updated); + } catch (err) { + return error(err); + } + } + + async markAllAsRead(actor = {}) { + try { + if (!actor?.id || !actor?.role) { + return error(new BadRequestError('Authentication data is missing')); + } + + if (actor.role === 'admin' || actor.role === 'mentor') { + await notificationRepository.markAllAsReadForAdmin(actor.id); + } else if (actor.role === 'intern') { + await notificationRepository.markAllAsReadForUser(actor.id); + } else { + return error(new ForbiddenError('Access denied: invalid role')); + } + + return data({ success: true }); + } catch (err) { + return error(err); + } + } +} + +module.exports = new NotificationService(); diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index 0fca0ac..9f431d3 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -1,4 +1,5 @@ const projectRepository = require("../repositories/project.repository"); +const notificationService = require("../../notification/services/notification.service"); const { data, error } = require("../../../helpers/utils/wrapper"); const { BadRequestError, @@ -86,6 +87,14 @@ class ProjectService { userIds, ); + for (const idUser of userIds) { + await notificationService.createNotificationForIntern( + idUser, + "Diterima ke Proyek", + `Anda telah bergabung ke proyek '${project.project_name}'.` + ).catch(err => console.error("Failed to send project assignment notification:", err)); + } + return data(this.mapProjectDetail(project)); } catch (err) { return error(err); @@ -516,6 +525,12 @@ class ProjectService { membership = await projectRepository.assignMember(id_project, id_user); } + await notificationService.createNotificationForIntern( + id_user, + "Diterima ke Proyek", + `Anda telah bergabung ke proyek '${project.project_name}'.` + ).catch(err => console.error("Failed to send project assignment notification:", err)); + return data(membership); } catch (err) { return error(err); diff --git a/src/modules/task/services/task.service.js b/src/modules/task/services/task.service.js index 1dcf9ba..257dc86 100644 --- a/src/modules/task/services/task.service.js +++ b/src/modules/task/services/task.service.js @@ -1,6 +1,8 @@ const fs = require('fs'); const path = require('path'); const taskRepository = require('../repositories/task.repository'); +const prisma = require('../../../helpers/db/db_connection'); +const notificationService = require('../../notification/services/notification.service'); const { taskUploadDir } = require('../helpers/taskUpload.helper'); const { data, error } = require('../../../helpers/utils/wrapper'); const { @@ -43,6 +45,12 @@ class TaskService { submission_type: payload.submission_type, }); + await notificationService.createNotificationForProjectInterns( + idProject, + "Tugas Baru Ditambahkan", + `Mentor telah membuat tugas baru: '${task.title}' di proyek '${project.project_name}'.` + ).catch(err => console.error("Failed to send task creation notifications:", err)); + return data(this.mapTask(task)); } catch (err) { return error(err); @@ -114,7 +122,7 @@ class TaskService { return error(accessError); } - if (actor.role === 'admin') { + if (this.isMentorOrAdmin(actor)) { return data(this.mapAdminTaskDetail(task)); } @@ -288,6 +296,21 @@ class TaskService { submission = await taskRepository.createSubmission(submissionData); } + const projectDetails = await taskRepository.findProjectById(task.id_project); + if (projectDetails) { + const user = await prisma.user.findUnique({ + where: { id: parseInt(actor.id) }, + select: { full_name: true } + }); + const internName = user?.full_name || actor.email; + + await notificationService.createNotificationForMentor( + projectDetails.id_admin, + "Pengiriman Tugas Baru", + `Intern '${internName}' telah mengirimkan jawaban untuk tugas '${task.title}'.` + ).catch(err => console.error("Failed to send task submission notification:", err)); + } + return data(this.mapSubmission(submission)); } catch (err) { return error(err); @@ -413,15 +436,15 @@ class TaskService { } if (actor.role === 'mentor') { - const project = await taskRepository.findProjectById(idProject); + const project = await taskRepository.findProjectById(idProject); - if (!project || project.id_admin !== parseInt(actor.id)) { - return new ForbiddenError('Access denied: you are not the mentor of this project'); - } - - return null; + if (!project || project.id_admin !== parseInt(actor.id)) { + return new ForbiddenError('Access denied: you are not the mentor of this project'); } + return null; + } + if (actor.role === 'intern') { const membership = await taskRepository.checkActiveProjectMember(idProject, actor.id); diff --git a/src/routes/notification.routes.js b/src/routes/notification.routes.js new file mode 100644 index 0000000..2e18500 --- /dev/null +++ b/src/routes/notification.routes.js @@ -0,0 +1,157 @@ +const express = require('express'); +const { notificationController } = require('../modules/notification'); +const { verifyJWT } = require('../middleware/verifyJWT'); + +const router = express.Router(); + +/** + * @swagger + * components: + * schemas: + * Notification: + * type: object + * properties: + * id: + * type: integer + * example: 1 + * id_user: + * type: integer + * nullable: true + * example: 1 + * id_admin: + * type: integer + * nullable: true + * example: null + * title: + * type: string + * example: "Tugas Baru" + * message: + * type: string + * example: "Mentor telah menambahkan tugas baru." + * is_read: + * type: boolean + * example: false + * created_at: + * type: string + * format: date-time + * example: "2026-06-09T14:00:00.000Z" + */ + +/** + * @swagger + * /notification-api: + * get: + * summary: Get notifications for logged-in user + * description: Retrieve all notifications for the currently authenticated user (intern, mentor, or admin). + * tags: [Notification] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Notifications retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * type: array + * items: + * $ref: '#/components/schemas/Notification' + * message: + * type: string + * example: "Notifications retrieved successfully" + * code: + * type: integer + * example: 200 + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.get('/', verifyJWT, notificationController.getNotifications); + +/** + * @swagger + * /notification-api/read-all: + * patch: + * summary: Mark all notifications as read + * description: Mark all notifications for the currently authenticated user as read. + * tags: [Notification] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: All notifications marked as read + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * message: + * type: string + * example: "All notifications marked as read" + * code: + * type: integer + * example: 200 + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.patch('/read-all', verifyJWT, notificationController.markAllAsRead); + +/** + * @swagger + * /notification-api/{id}/read: + * patch: + * summary: Mark a single notification as read + * description: Mark a specific notification as read by its ID. Users can only read their own notifications. + * tags: [Notification] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Notification ID + * example: 1 + * responses: + * 200: + * description: Notification marked as read + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * $ref: '#/components/schemas/Notification' + * message: + * type: string + * example: "Notification marked as read" + * code: + * type: integer + * example: 200 + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Accessing another user's notification) + * 404: + * description: Notification not found + * 500: + * description: Internal server error + */ +router.patch('/:id/read', verifyJWT, notificationController.markAsRead); + +module.exports = router; diff --git a/test/test_mentor_tasks.js b/test/test_mentor_tasks.js new file mode 100644 index 0000000..23fd97f --- /dev/null +++ b/test/test_mentor_tasks.js @@ -0,0 +1,209 @@ +const axios = require('axios'); + +const BASE_URL = 'http://localhost:9000'; + +async function runTests() { + console.log('=== STARTING INTEGRATION TESTS FOR MENTOR TASK MANAGEMENT ===\n'); + + let adminToken = ''; + let mentorToken = ''; + let otherMentorToken = ''; + let createdProjectId = null; + let otherProjectId = null; + let taskId = null; + + const mentorEmail = `mentor_task_${Date.now()}@internify.com`; + const otherMentorEmail = `other_mentor_task_${Date.now()}@internify.com`; + const mentorPassword = 'Password123'; + + // Helper config generator + const getHeader = (token) => ({ + headers: { Authorization: `Bearer ${token}` } + }); + + try { + // 1. Login as Admin + console.log('1. Logging in as Admin (admin1@internify.com)...'); + const loginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: 'admin1@internify.com', + password: 'password123' + }); + adminToken = loginRes.data.data.token; + console.log(' Admin logged in successfully!\n'); + + // 2. Create Mentor Accounts via Admin API + console.log(`2. Creating mentor accounts...`); + await axios.post(`${BASE_URL}/admin-api/add`, { + nama_depan: 'John', + nama_belakang: 'MentorTask', + email: mentorEmail, + password: mentorPassword, + role: 'mentor' + }, getHeader(adminToken)); + + await axios.post(`${BASE_URL}/admin-api/add`, { + nama_depan: 'Jane', + nama_belakang: 'OtherMentorTask', + email: otherMentorEmail, + password: mentorPassword, + role: 'mentor' + }, getHeader(adminToken)); + console.log(' Mentor accounts created successfully!\n'); + + // 3. Login as Mentors + console.log('3. Logging in as mentors...'); + const mentorLoginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: mentorEmail, + password: mentorPassword + }); + mentorToken = mentorLoginRes.data.data.token; + + const otherMentorLoginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: otherMentorEmail, + password: mentorPassword + }); + otherMentorToken = otherMentorLoginRes.data.data.token; + console.log(' Mentors logged in successfully!\n'); + + // 4. Mentors create projects + console.log('4. Creating projects for mentors...'); + const createProjectRes = await axios.post(`${BASE_URL}/project-api/add`, { + project_icon: 'code', + project_name: `Mentor's Project - ${Date.now()}`, + description: 'Project to test mentor task permissions.', + start_date: '2026-07-01', + end_date: '2026-10-01', + member_emails: [] + }, getHeader(mentorToken)); + createdProjectId = createProjectRes.data.data.id; + + const otherProjectRes = await axios.post(`${BASE_URL}/project-api/add`, { + project_icon: 'shield', + project_name: `Other Mentor's Project - ${Date.now()}`, + description: 'Project owned by another mentor.', + start_date: '2026-07-01', + end_date: '2026-10-01', + member_emails: [] + }, getHeader(otherMentorToken)); + otherProjectId = otherProjectRes.data.data.id; + console.log(` Projects created: Mentor Project ID = ${createdProjectId}, Other Project ID = ${otherProjectId}\n`); + + // 5. Mentor creates a task in their project + console.log(`5. Mentor creating task in project ID ${createdProjectId}...`); + const createTaskRes = await axios.post(`${BASE_URL}/task-api/projects/${createdProjectId}/tasks`, { + title: 'Task 1', + description: 'First task created by mentor.', + deadline_date: '2026-08-01', + specific_time: '23:59', + submission_type: 'url_link' + }, getHeader(mentorToken)); + taskId = createTaskRes.data.data.id; + console.log(` Task created successfully! ID: ${taskId}\n`); + + // 6. Mentor tries to create task in project they do not own + console.log(`6. Testing isolation: Mentor trying to create task in project ID ${otherProjectId} (owned by other mentor)...`); + try { + await axios.post(`${BASE_URL}/task-api/projects/${otherProjectId}/tasks`, { + title: 'Task in other project', + description: 'Should fail.', + deadline_date: '2026-08-01', + specific_time: '23:59', + submission_type: 'url_link' + }, getHeader(mentorToken)); + throw new Error('TEST FAILED: Mentor created a task in a project they do not own!'); + } catch (err) { + if (err.response && err.response.status === 403) { + console.log(' PASSED: Access denied with 403 Forbidden as expected.\n'); + } else { + throw err; + } + } + + // 7. Mentor retrieves task list of their project + console.log(`7. Mentor retrieving task list for project ID ${createdProjectId}...`); + const getTasksRes = await axios.get(`${BASE_URL}/task-api/projects/${createdProjectId}/tasks`, getHeader(mentorToken)); + const tasks = getTasksRes.data.data; + console.log(` Tasks found: ${tasks.length}`); + if (tasks.length !== 1 || tasks[0].id !== taskId) { + throw new Error('TEST FAILED: Task list not retrieved correctly by mentor!'); + } + console.log(' PASSED: Task list retrieved successfully.\n'); + + // 8. Mentor retrieves details of their task + console.log(`8. Mentor retrieving details of task ID ${taskId}...`); + const getTaskDetailRes = await axios.get(`${BASE_URL}/task-api/tasks/${taskId}`, getHeader(mentorToken)); + const taskDetail = getTaskDetailRes.data.data; + + // Mentor should get admin-like details including the submission_summary and submissions list. + // Let's verify if submission_summary exists in response. + console.log(' Retrieved detail data fields:', Object.keys(taskDetail)); + if (!taskDetail.submission_summary) { + throw new Error('TEST FAILED: Mentor retrieved detail as an intern (missing submission_summary)!'); + } + console.log(' PASSED: Mentor successfully retrieved detailed view of the task with submission summary.\n'); + + // 9. Mentor updates their task + console.log(`9. Mentor updating task ID ${taskId}...`); + await axios.patch(`${BASE_URL}/task-api/tasks/${taskId}`, { + title: 'Task 1 Updated', + description: 'Updated description by mentor.' + }, getHeader(mentorToken)); + console.log(' PASSED: Task updated successfully.\n'); + + // 10. Mentor tries to update task they do not own + // Let's create a task in the other project first + const otherTaskRes = await axios.post(`${BASE_URL}/task-api/projects/${otherProjectId}/tasks`, { + title: 'Other Task', + description: 'Task by other mentor.', + deadline_date: '2026-08-01', + specific_time: '23:59', + submission_type: 'url_link' + }, getHeader(otherMentorToken)); + const otherTaskId = otherTaskRes.data.data.id; + + console.log(`10. Testing isolation: Mentor trying to update task ID ${otherTaskId}...`); + try { + await axios.patch(`${BASE_URL}/task-api/tasks/${otherTaskId}`, { + title: 'Unauthorized Update' + }, getHeader(mentorToken)); + throw new Error('TEST FAILED: Mentor updated a task they do not own!'); + } catch (err) { + if (err.response && err.response.status === 403) { + console.log(' PASSED: Access denied with 403 Forbidden as expected.\n'); + } else { + throw err; + } + } + + // 11. Mentor deletes their task + console.log(`11. Mentor deleting task ID ${taskId}...`); + await axios.delete(`${BASE_URL}/task-api/tasks/${taskId}`, getHeader(mentorToken)); + console.log(' PASSED: Task deleted successfully.\n'); + + // 12. Mentor tries to delete task they do not own + console.log(`12. Testing isolation: Mentor trying to delete task ID ${otherTaskId}...`); + try { + await axios.delete(`${BASE_URL}/task-api/tasks/${otherTaskId}`, getHeader(mentorToken)); + throw new Error('TEST FAILED: Mentor deleted a task they do not own!'); + } catch (err) { + if (err.response && err.response.status === 403) { + console.log(' PASSED: Access denied with 403 Forbidden as expected.\n'); + } else { + throw err; + } + } + + console.log('=== ALL INTEGRATION TESTS PASSED SUCCESSFULLY! ==='); + } catch (err) { + console.error('=== TEST EXECUTION ENCOUNTERED AN ERROR ==='); + if (err.response) { + console.error(`Status: ${err.response.status}`); + console.error('Response Data:', JSON.stringify(err.response.data, null, 2)); + } else { + console.error(err); + } + process.exit(1); + } +} + +runTests(); diff --git a/test/test_notifications.js b/test/test_notifications.js new file mode 100644 index 0000000..c3cb002 --- /dev/null +++ b/test/test_notifications.js @@ -0,0 +1,227 @@ +const axios = require('axios'); +const ioClient = require('socket.io-client'); +const prisma = require('../src/helpers/db/db_connection'); + +const BASE_URL = 'http://localhost:9000'; + +async function runTests() { + console.log('=== STARTING INTEGRATION TESTS FOR NOTIFICATION SYSTEM ===\n'); + + // 0. Clean up existing memberships for rafi@student.com (user ID 1) to allow project assignment + console.log('0. Cleaning up existing project memberships for rafi@student.com...'); + await prisma.projectMember.deleteMany({ + where: { id_user: 1 } + }); + console.log(' Cleanup successful!\n'); + + let adminToken = ''; + let mentorToken = ''; + let internToken = ''; + let mentorId = null; + let internId = null; + let createdProjectId = null; + let taskId = null; + + const mentorEmail = `mentor_notif_${Date.now()}@internify.com`; + const mentorPassword = 'Password123'; + const internEmail = 'rafi@student.com'; + const internPassword = 'password123'; + + // Helper config generator + const getHeader = (token) => ({ + headers: { Authorization: `Bearer ${token}` } + }); + + let internSocket = null; + let mentorSocket = null; + + const internReceivedNotifs = []; + const mentorReceivedNotifs = []; + + try { + // 1. Login as Admin + console.log('1. Logging in as Admin (admin1@internify.com)...'); + const loginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: 'admin1@internify.com', + password: 'password123' + }); + adminToken = loginRes.data.data.token; + console.log(' Admin logged in successfully!\n'); + + // 2. Create Mentor Account via Admin API + console.log(`2. Creating a new Mentor account (${mentorEmail})...`); + const createMentorRes = await axios.post(`${BASE_URL}/admin-api/add`, { + nama_depan: 'Jane', + nama_belakang: 'MentorNotif', + email: mentorEmail, + password: mentorPassword, + role: 'mentor' + }, getHeader(adminToken)); + console.log(' Mentor account created successfully!\n'); + + // 3. Login as the new Mentor + console.log('3. Logging in as the new Mentor...'); + const mentorLoginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: mentorEmail, + password: mentorPassword + }); + mentorToken = mentorLoginRes.data.data.token; + mentorId = mentorLoginRes.data.data.user.id; + console.log(` Mentor logged in successfully! ID: ${mentorId}\n`); + + // 4. Login as Intern + console.log(`4. Logging in as Intern (${internEmail})...`); + const internLoginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: internEmail, + password: internPassword + }); + internToken = internLoginRes.data.data.token; + internId = internLoginRes.data.data.user.id; + console.log(` Intern logged in successfully! ID: ${internId}\n`); + + // 5. Establish WebSocket Connections for Intern and Mentor + console.log('5. Connecting to WebSocket (Socket.io) server...'); + + internSocket = ioClient(BASE_URL, { + auth: { token: internToken }, + transports: ['websocket'] + }); + + mentorSocket = ioClient(BASE_URL, { + auth: { token: mentorToken }, + transports: ['websocket'] + }); + + // Setup socket listeners + internSocket.on('notification', (notif) => { + console.log(` [WS Event] Intern received real-time notification: "${notif.title}" - ${notif.message}`); + internReceivedNotifs.push(notif); + }); + + mentorSocket.on('notification', (notif) => { + console.log(` [WS Event] Mentor received real-time notification: "${notif.title}" - ${notif.message}`); + mentorReceivedNotifs.push(notif); + }); + + // Wait a brief moment to ensure sockets are connected + await new Promise(resolve => setTimeout(resolve, 1000)); + console.log(' Sockets connected!\n'); + + // 6. Mentor creates project and assigns Intern + console.log(`6. Mentor creating project and assigning intern (ID: ${internId})...`); + const createProjectRes = await axios.post(`${BASE_URL}/project-api/add`, { + project_icon: 'code', + project_name: `Notification Test Project - ${Date.now()}`, + description: 'Project to test WebSocket notifications.', + start_date: '2026-07-01', + end_date: '2026-10-01', + member_emails: [internEmail] + }, getHeader(mentorToken)); + createdProjectId = createProjectRes.data.data.id; + console.log(` Project created successfully! ID: ${createdProjectId}`); + + // Wait for the WS notification to arrive + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Check if intern received "Diterima ke Proyek" notification + const assignmentNotif = internReceivedNotifs.find(n => n.title === 'Diterima ke Proyek'); + if (!assignmentNotif) { + throw new Error('TEST FAILED: Intern did not receive real-time project assignment notification!'); + } + console.log(' PASSED: Intern received project assignment notification over WebSocket.\n'); + + // 7. Mentor creates a task in the project + console.log(`7. Mentor creating a task in project ID ${createdProjectId}...`); + const createTaskRes = await axios.post(`${BASE_URL}/task-api/projects/${createdProjectId}/tasks`, { + title: 'Real-time Task', + description: 'Submit this task to test mentor notifications.', + deadline_date: '2026-08-01', + specific_time: '23:59', + submission_type: 'url_link' + }, getHeader(mentorToken)); + taskId = createTaskRes.data.data.id; + console.log(` Task created successfully! ID: ${taskId}`); + + // Wait for the WS notification to arrive + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Check if intern received "Tugas Baru Ditambahkan" notification + const taskNotif = internReceivedNotifs.find(n => n.title === 'Tugas Baru Ditambahkan'); + if (!taskNotif) { + throw new Error('TEST FAILED: Intern did not receive real-time task creation notification!'); + } + console.log(' PASSED: Intern received task creation notification over WebSocket.\n'); + + // 8. Intern submits the task + console.log(`8. Intern submitting task ID ${taskId}...`); + await axios.post(`${BASE_URL}/task-api/tasks/${taskId}/submissions`, { + url_link: 'https://github.com/rafiathallah/notification-system' + }, getHeader(internToken)); + console.log(' Task submitted successfully!'); + + // Wait for the WS notification to arrive + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Check if mentor received "Pengiriman Tugas Baru" notification + const submissionNotif = mentorReceivedNotifs.find(n => n.title === 'Pengiriman Tugas Baru'); + if (!submissionNotif) { + throw new Error('TEST FAILED: Mentor did not receive real-time task submission notification!'); + } + console.log(' PASSED: Mentor received task submission notification over WebSocket.\n'); + + // 9. Intern retrieves their notifications via HTTP + console.log('9. Intern fetching notifications via GET /notification-api...'); + const getNotifsRes = await axios.get(`${BASE_URL}/notification-api`, getHeader(internToken)); + const dbNotifications = getNotifsRes.data.data; + console.log(` Notifications found: ${dbNotifications.length}`); + dbNotifications.forEach(n => console.log(` - [ID: ${n.id}] [Read: ${n.is_read}] ${n.title}: ${n.message}`)); + + if (dbNotifications.length < 2) { + throw new Error('TEST FAILED: Intern notifications list in database is incomplete!'); + } + const hasUnread = dbNotifications.every(n => n.is_read === false); + if (!hasUnread) { + throw new Error('TEST FAILED: Notifications should be unread by default!'); + } + console.log(' PASSED: HTTP notification retrieval correct.\n'); + + // 10. Intern marks a single notification as read + const targetNotifId = dbNotifications[0].id; + console.log(`10. Intern marking notification ID ${targetNotifId} as read...`); + const markReadRes = await axios.patch(`${BASE_URL}/notification-api/${targetNotifId}/read`, {}, getHeader(internToken)); + + if (!markReadRes.data.data.is_read) { + throw new Error('TEST FAILED: Notification status did not update to read!'); + } + console.log(' PASSED: Single notification marked as read.\n'); + + // 11. Intern marks all notifications as read + console.log('11. Intern marking all remaining notifications as read...'); + await axios.patch(`${BASE_URL}/notification-api/read-all`, {}, getHeader(internToken)); + + const recheckNotifsRes = await axios.get(`${BASE_URL}/notification-api`, getHeader(internToken)); + const updatedNotifications = recheckNotifsRes.data.data; + const allRead = updatedNotifications.every(n => n.is_read === true); + if (!allRead) { + throw new Error('TEST FAILED: Not all notifications were marked as read!'); + } + console.log(' PASSED: All notifications marked as read successfully.\n'); + + console.log('=== ALL INTEGRATION TESTS PASSED SUCCESSFULLY! ==='); + } catch (err) { + console.error('=== TEST EXECUTION ENCOUNTERED AN ERROR ==='); + if (err.response) { + console.error(`Status: ${err.response.status}`); + console.error('Response Data:', JSON.stringify(err.response.data, null, 2)); + } else { + console.error(err); + } + process.exit(1); + } finally { + // Cleanup WebSocket connections + if (internSocket) internSocket.close(); + if (mentorSocket) mentorSocket.close(); + } +} + +runTests();