feat: Nambahin feature notifikasi
This commit is contained in:
@@ -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 };
|
||||
+8
-1
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,5 @@
|
||||
const notificationController = require('./controllers/notification.controller');
|
||||
|
||||
module.exports = {
|
||||
notificationController
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user