Merge branch 'main' into feat/admin-project-lms

This commit is contained in:
Rafi Athallah
2026-06-14 20:28:48 +07:00
committed by GitHub
20 changed files with 2992 additions and 12 deletions
@@ -0,0 +1,177 @@
const certificateService = require('../services/certificate.service');
const { response, error } = require('../../../helpers/utils/wrapper');
const { SUCCESS, ERROR } = require('../../../helpers/http-status/status_code');
const { InternalServerError } = require('../../../helpers/error');
const { isValidPayload } = require('../../../helpers/utils/validator');
const { claimCertificateModel } = require('../models/certificate.model');
const getActor = (req) => ({
id: req.id,
role: req.role,
email: req.email,
});
class CertificateController {
async claimCertificate(req, res) {
try {
const validatePayload = isValidPayload(req.body, claimCertificateModel);
if (validatePayload.err) {
return response(
res,
'fail',
{ err: validatePayload.err, data: null },
'Data claim certificate tidak valid',
ERROR.EXPECTATION_FAILED
);
}
const actor = getActor(req);
const result = await certificateService.claimCertificate(validatePayload.data, actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Certificate claimed successfully', SUCCESS.CREATED);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async getMyCertificates(req, res) {
try {
const actor = getActor(req);
const result = await certificateService.getMyCertificates(actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Certificates retrieved successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async getCertificateDetail(req, res) {
try {
const { id } = req.params;
const actor = getActor(req);
const result = await certificateService.getCertificateDetail(id, actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Certificate details retrieved successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async getProjectCertificates(req, res) {
try {
const { id_project } = req.params;
const actor = getActor(req);
const result = await certificateService.getProjectCertificates(id_project, actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Project certificates retrieved successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async getAllCertificates(req, res) {
try {
const actor = getActor(req);
const result = await certificateService.getAllCertificates(actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'All certificates retrieved successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async verifyCertificate(req, res) {
try {
const certificate_no = req.query.certificate_no || req.params.certificate_no;
const result = await certificateService.verifyCertificate(certificate_no);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Certificate validated successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async verifyCertificateByUuid(req, res) {
try {
const { uuid } = req.params;
const result = await certificateService.verifyCertificateByUuid(uuid);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Certificate validated successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
}
module.exports = new CertificateController();
+5
View File
@@ -0,0 +1,5 @@
const certificateController = require('./controllers/certificate.controller');
module.exports = {
certificateController,
};
@@ -0,0 +1,13 @@
const joi = require('joi');
const claimCertificateModel = joi.object().keys({
id_project: joi.number().integer().required().messages({
'number.base': 'Project ID harus berupa angka.',
'number.integer': 'Project ID harus berupa bilangan bulat.',
'any.required': 'Project ID wajib diisi.',
}),
});
module.exports = {
claimCertificateModel,
};
@@ -0,0 +1,118 @@
const prisma = require('../../../helpers/db/db_connection');
class CertificateRepository {
async create(certificateData) {
return prisma.certificate.create({
data: certificateData,
include: this.certificateDetailInclude(),
});
}
async findById(id) {
return prisma.certificate.findUnique({
where: { id: parseInt(id) },
include: this.certificateDetailInclude(),
});
}
async findByUserAndProject(id_user, id_project) {
return prisma.certificate.findUnique({
where: {
id_project_id_user: {
id_project: parseInt(id_project),
id_user: parseInt(id_user),
},
},
include: this.certificateDetailInclude(),
});
}
async findUserCertificates(id_user) {
return prisma.certificate.findMany({
where: { id_user: parseInt(id_user) },
include: this.certificateDetailInclude(),
orderBy: { issued_at: 'desc' },
});
}
async findProjectCertificates(id_project) {
return prisma.certificate.findMany({
where: { id_project: parseInt(id_project) },
include: this.certificateDetailInclude(),
orderBy: { issued_at: 'desc' },
});
}
async findAll() {
return prisma.certificate.findMany({
include: this.certificateDetailInclude(),
orderBy: { issued_at: 'desc' },
});
}
async findByCertificateNo(certificate_no) {
return prisma.certificate.findUnique({
where: { certificate_no },
include: this.certificateDetailInclude(),
});
}
async findByUuid(uuid) {
return prisma.certificate.findUnique({
where: { uuid },
include: this.certificateDetailInclude(),
});
}
async getProjectTasksAndSubmissions(id_project, id_user) {
return prisma.project.findUnique({
where: { id: parseInt(id_project) },
select: {
id: true,
project_name: true,
tasks: {
select: {
id: true,
submissions: {
where: { id_user: parseInt(id_user) },
select: {
id: true,
submitted_at: true,
},
},
},
},
members: {
where: {
id_user: parseInt(id_user),
status: 'active',
},
select: {
id: true,
},
},
},
});
}
certificateDetailInclude() {
return {
user: {
select: {
id: true,
full_name: true,
email: true,
},
},
project: {
select: {
id: true,
project_name: true,
description: true,
},
},
};
}
}
module.exports = new CertificateRepository();
@@ -0,0 +1,225 @@
const certificateRepository = require('../repositories/certificate.repository');
const { data, error } = require('../../../helpers/utils/wrapper');
const {
BadRequestError,
NotFoundError,
ForbiddenError,
ConflictError,
} = require('../../../helpers/error');
class CertificateService {
async claimCertificate(payload, actor) {
try {
if (!actor?.id) {
return error(new BadRequestError('User ID is required'));
}
if (actor.role !== 'intern') {
return error(
new ForbiddenError('Access denied: only interns can claim certificates'),
);
}
const { id_project } = payload;
const existingCert = await certificateRepository.findByUserAndProject(actor.id, id_project);
if (existingCert) {
return error(new ConflictError('Certificate already claimed for this project'));
}
const projectInfo = await certificateRepository.getProjectTasksAndSubmissions(id_project, actor.id);
if (!projectInfo) {
return error(new NotFoundError('Project not found'));
}
if (!projectInfo.members || projectInfo.members.length === 0) {
return error(
new ForbiddenError('Access denied: you are not an active member of this project'),
);
}
if (!projectInfo.tasks || projectInfo.tasks.length === 0) {
return error(
new BadRequestError('Cannot claim certificate: this project does not have any tasks assigned yet'),
);
}
const completedTasks = projectInfo.tasks.filter(
(task) => task.submissions && task.submissions.length > 0
);
if (completedTasks.length < projectInfo.tasks.length) {
return error(
new BadRequestError(
`Cannot claim certificate: you have completed ${completedTasks.length} out of ${projectInfo.tasks.length} tasks`
),
);
}
// Generate certificate number berdsarkan format ini: CERT/YYYYMMDD/PRJ{id_project}/USR{id_user} [Rafi 09/06/2026 20:45]
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const date = String(now.getDate()).padStart(2, '0');
const dateString = `${year}${month}${date}`;
const certificateNo = `CERT/${dateString}/PRJ${id_project}/USR${actor.id}`;
const certificateData = {
id_project: parseInt(id_project),
id_user: parseInt(actor.id),
certificate_no: certificateNo,
issued_at: now,
};
const certificate = await certificateRepository.create(certificateData);
return data(this.mapCertificate(certificate));
} catch (err) {
return error(err);
}
}
async getMyCertificates(actor) {
try {
if (!actor?.id) {
return error(new BadRequestError('User ID is required'));
}
if (actor.role !== 'intern') {
return error(
new ForbiddenError('Access denied: only interns can view their certificates'),
);
}
const certificates = await certificateRepository.findUserCertificates(actor.id);
return data(certificates.map((cert) => this.mapCertificate(cert)));
} catch (err) {
return error(err);
}
}
async getCertificateDetail(id, actor) {
try {
if (!actor?.id) {
return error(new BadRequestError('User ID is required'));
}
const certificate = await certificateRepository.findById(id);
if (!certificate) {
return error(new NotFoundError('Certificate not found'));
}
if (actor.role === 'intern' && certificate.id_user !== actor.id) {
return error(
new ForbiddenError("Access denied: you cannot view other intern's certificates"),
);
}
return data(this.mapCertificate(certificate));
} catch (err) {
return error(err);
}
}
async getProjectCertificates(id_project, actor) {
try {
if (!actor?.id) {
return error(new BadRequestError('Admin ID is required'));
}
if (actor.role !== 'admin') {
return error(
new ForbiddenError('Access denied: only admins/mentors can view project certificates'),
);
}
const certificates = await certificateRepository.findProjectCertificates(id_project);
return data(certificates.map((cert) => this.mapCertificate(cert)));
} catch (err) {
return error(err);
}
}
async getAllCertificates(actor) {
try {
if (!actor?.id) {
return error(new BadRequestError('Admin ID is required'));
}
if (actor.role !== 'admin') {
return error(
new ForbiddenError('Access denied: only admins/mentors can view all certificates'),
);
}
const certificates = await certificateRepository.findAll();
return data(certificates.map((cert) => this.mapCertificate(cert)));
} catch (err) {
return error(err);
}
}
async verifyCertificate(certificate_no) {
try {
if (!certificate_no) {
return error(new BadRequestError('Certificate number is required'));
}
const certificate = await certificateRepository.findByCertificateNo(certificate_no);
if (!certificate) {
return error(new NotFoundError('Certificate not found or invalid'));
}
return data(this.mapCertificate(certificate));
} catch (err) {
return error(err);
}
}
async verifyCertificateByUuid(uuid) {
try {
if (!uuid) {
return error(new BadRequestError('Certificate UUID is required'));
}
const certificate = await certificateRepository.findByUuid(uuid);
if (!certificate) {
return error(new NotFoundError('Certificate not found or invalid'));
}
return data({
uuid: certificate.uuid,
intern_name: certificate.user ? certificate.user.full_name : null,
project_name: certificate.project ? certificate.project.project_name : null,
created_at: certificate.issued_at,
});
} catch (err) {
return error(err);
}
}
mapCertificate(cert) {
if (!cert) return null;
return {
id: cert.id,
uuid: cert.uuid,
id_project: cert.id_project,
id_user: cert.id_user,
certificate_no: cert.certificate_no,
issued_at: cert.issued_at,
user: cert.user ? {
id: cert.user.id,
full_name: cert.user.full_name,
email: cert.user.email,
} : null,
project: cert.project ? {
id: cert.project.id,
project_name: cert.project.project_name,
description: cert.project.description,
} : null,
};
}
}
module.exports = new CertificateService();
@@ -394,6 +394,32 @@ class ProjectRepository {
data: { status },
});
}
async findActiveMembershipsByUserIds(userIds) {
return prisma.projectMember.findMany({
where: {
id_user: {
in: userIds.map((id) => parseInt(id)),
},
status: "active",
},
include: {
user: {
select: {
id: true,
full_name: true,
email: true,
},
},
project: {
select: {
id: true,
project_name: true,
},
},
},
});
}
}
module.exports = new ProjectRepository();
@@ -58,6 +58,27 @@ class ProjectService {
}
}
const userIds = users.map((user) => user.id);
if (userIds.length > 0) {
const activeMemberships = await projectRepository.findActiveMembershipsByUserIds(userIds);
if (activeMemberships.length > 0) {
const assignedInterns = activeMemberships.map((membership) => {
const email = membership.user?.email || "unknown email";
const projectName = membership.project?.project_name || "Unknown Project";
return `${email} already assigned to ${projectName}`;
});
return error(
new ConflictError(
`One or more interns already have an active project: ${assignedInterns.join(", ")}`
)
);
}
}
const newProjectData = {
id_admin: parseInt(actor.id),
project_icon: projectData.project_icon,
@@ -68,7 +89,6 @@ class ProjectService {
status: "active",
};
const userIds = users.map((user) => user.id);
const project = await projectRepository.createWithMembers(
newProjectData,
userIds,
@@ -446,7 +466,7 @@ class ProjectService {
const activeMembership = intern.project_members?.[0] || null;
const projectName = activeMembership?.project?.project_name || "Unassigned";
const role = intern.lamaran_magang?.lowongan_magang?.posisi || "-";
return {
id: intern.id,
name: intern.full_name,
@@ -482,19 +502,16 @@ class ProjectService {
return error(new BadRequestError("Project ID and User ID are required"));
}
// Check if project exists
const project = await projectRepository.findById(id_project);
if (!project) {
return error(new NotFoundError("Project not found"));
}
// Check if user exists and is active
const user = await projectRepository.findUserByIdAndActive(id_user);
if (!user) {
return error(new NotFoundError("User not found or inactive"));
}
// Check if already a member of this project
const existingMembership = await projectRepository.findMembership(id_project, id_user);
let membership;
@@ -502,10 +519,8 @@ class ProjectService {
if (existingMembership.status === 'active') {
return error(new ConflictError("User is already an active member of this project"));
}
// Reactivate membership
membership = await projectRepository.updateMembershipStatus(existingMembership.id, 'active');
} else {
// Create new membership
membership = await projectRepository.assignMember(id_project, id_user);
}
@@ -0,0 +1,268 @@
const fs = require('fs');
const taskService = require('../services/task.service');
const { response, error } = require('../../../helpers/utils/wrapper');
const { SUCCESS, ERROR } = require('../../../helpers/http-status/status_code');
const { InternalServerError } = require('../../../helpers/error');
const { isValidPayload } = require('../../../helpers/utils/validator');
const {
createTaskModel,
updateTaskModel,
submitTaskModel,
updateSubmissionModel,
} = require('../models/task.model');
const getActor = (req) => ({
id: req.id,
role: req.role,
email: req.email,
});
const cleanupUploadedFile = (file) => {
if (file?.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
};
class TaskController {
async createTask(req, res) {
try {
const validatePayload = isValidPayload(req.body, createTaskModel);
if (validatePayload.err) {
return response(
res,
'fail',
{ err: validatePayload.err, data: null },
'Invalid task data',
ERROR.EXPECTATION_FAILED
);
}
const actor = getActor(req);
const result = await taskService.createTask(
req.params.id_project,
validatePayload.data,
actor
);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Task created successfully', SUCCESS.CREATED);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async getTasksByProject(req, res) {
try {
const actor = getActor(req);
const result = await taskService.getTasksByProject(req.params.id_project, actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Tasks retrieved successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async getTaskById(req, res) {
try {
const actor = getActor(req);
const result = await taskService.getTaskById(req.params.id, actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Task detail retrieved successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async updateTask(req, res) {
try {
const validatePayload = isValidPayload(req.body, updateTaskModel);
if (validatePayload.err) {
return response(
res,
'fail',
{ err: validatePayload.err, data: null },
'Invalid task data',
ERROR.EXPECTATION_FAILED
);
}
const actor = getActor(req);
const result = await taskService.updateTask(req.params.id, validatePayload.data, actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Task updated successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async deleteTask(req, res) {
try {
const actor = getActor(req);
const result = await taskService.deleteTask(req.params.id, actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Task deleted successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async submitTask(req, res) {
try {
const validatePayload = isValidPayload(req.body, submitTaskModel);
if (validatePayload.err) {
cleanupUploadedFile(req.file);
return response(
res,
'fail',
{ err: validatePayload.err, data: null },
'Invalid submission data',
ERROR.EXPECTATION_FAILED
);
}
const actor = getActor(req);
const result = await taskService.submitTask(
req.params.id,
validatePayload.data,
req.file,
actor
);
if (result.err) {
cleanupUploadedFile(req.file);
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Task submitted successfully', SUCCESS.OK);
} catch (err) {
cleanupUploadedFile(req.file);
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async updateSubmission(req, res) {
try {
const validatePayload = isValidPayload(req.body, updateSubmissionModel);
if (validatePayload.err) {
cleanupUploadedFile(req.file);
return response(
res,
'fail',
{ err: validatePayload.err, data: null },
'Invalid submission data',
ERROR.EXPECTATION_FAILED
);
}
const actor = getActor(req);
const result = await taskService.updateSubmission(
req.params.id,
validatePayload.data,
req.file,
actor
);
if (result.err) {
cleanupUploadedFile(req.file);
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Submission updated successfully', SUCCESS.OK);
} catch (err) {
cleanupUploadedFile(req.file);
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async deleteSubmission(req, res) {
try {
const actor = getActor(req);
const result = await taskService.deleteSubmission(req.params.id, actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Submission deleted successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
}
module.exports = new TaskController();
@@ -0,0 +1,96 @@
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const baseUploadDir = process.env.NODE_ENV === 'production'
? '/tmp/uploads'
: path.join(__dirname, '../../../uploads');
const taskUploadDir = path.join(baseUploadDir, 'task-submissions');
if (!fs.existsSync(taskUploadDir)) {
fs.mkdirSync(taskUploadDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, taskUploadDir),
filename: (_req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
},
});
const allowedMimeTypes = [
'application/pdf',
// 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
// 'application/zip',
// 'application/x-zip-compressed',
];
const fileFilter = (req, file, cb) => {
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true);
return;
}
// const errMsg = 'Only PDF, DOCX, or ZIP files are allowed';
const errMsg = 'Only PDF files are allowed';
req.fileValidationError = errMsg;
cb(new Error(errMsg), false);
};
const taskUpload = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024, //10MB
},
});
const taskUploadErrorHandler = (err, _req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({
status: false,
data: null,
message: 'File size is too large. Maximum size is 10MB',
code: 413,
});
}
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
return res.status(400).json({
status: false,
data: null,
message: {
[err.field]: `Field '${err.field}' cannot have more than 1 file.`,
},
code: 400,
});
}
return res.status(400).json({
status: false,
data: null,
message: err.message,
code: 400,
});
}
if (err) {
return res.status(400).json({
status: false,
data: null,
message: err.message || 'Error while uploading file',
code: 400,
});
}
next();
};
module.exports = {
taskUpload,
taskUploadErrorHandler,
taskUploadDir
};
+5
View File
@@ -0,0 +1,5 @@
const taskController = require('./controllers/task.controller');
module.exports = {
taskController
};
+82
View File
@@ -0,0 +1,82 @@
const joi = require('joi');
const SUBMISSION_TYPE_VALUES = ['file_upload', 'url_link'];
const createTaskModel = joi.object().keys({
title: joi.string().max(255).required().messages({
'string.base': 'Task title must be a string.',
'string.empty': 'Task title cannot be empty.',
'string.max': 'Task title must not exceed {#limit} characters.',
'any.required': 'Task title is required.',
}),
description: joi.string().required().messages({
'string.base': 'Task description must be a string.',
'string.empty': 'Task description cannot be empty.',
'any.required': 'Task description is required.',
}),
deadline_date: joi.date().required().messages({
'date.base': 'Deadline date must be a valid date.',
'any.required': 'Deadline date is required.',
}),
specific_time: joi.string().pattern(/^([01]\d|2[0-3]):[0-5]\d$/).required().messages({
'string.base': 'Specific time must be a string.',
'string.empty': 'Specific time cannot be empty.',
'string.pattern.base': 'Specific time must use HH:mm format.',
'any.required': 'Specific time is required.',
}),
submission_type: joi.string().valid(...SUBMISSION_TYPE_VALUES).required().messages({
'string.base': 'Submission type must be a string.',
'any.only': `Submission type must be one of: ${SUBMISSION_TYPE_VALUES.join(', ')}.`,
'any.required': 'Submission type is required.',
}),
});
const updateTaskModel = joi.object().keys({
title: joi.string().max(255).optional().messages({
'string.base': 'Task title must be a string.',
'string.max': 'Task title must not exceed {#limit} characters.',
}),
description: joi.string().optional().messages({
'string.base': 'Task description must be a string.',
}),
deadline_date: joi.date().optional().messages({
'date.base': 'Deadline date must be a valid date.',
}),
specific_time: joi.string().pattern(/^([01]\d|2[0-3]):[0-5]\d$/).optional().messages({
'string.base': 'Specific time must be a string.',
'string.pattern.base': 'Specific time must use HH:mm format.',
}),
submission_type: joi.string().valid(...SUBMISSION_TYPE_VALUES).optional().messages({
'string.base': 'Submission type must be a string.',
'any.only': `Submission type must be one of: ${SUBMISSION_TYPE_VALUES.join(', ')}.`,
}),
});
const submitTaskModel = joi.object().keys({
url_link: joi.string().uri().optional().messages({
'string.base': 'URL link must be a string.',
'string.uri': 'URL link must be a valid URL.',
}),
});
const updateSubmissionModel = joi.object().keys({
url_link: joi.string().uri().optional().messages({
'string.base': 'URL link must be a string.',
'string.uri': 'URL link must be a valid URL.',
}),
});
module.exports = {
createTaskModel,
updateTaskModel,
submitTaskModel,
updateSubmissionModel,
};
@@ -0,0 +1,199 @@
const prisma = require('../../../helpers/db/db_connection');
class TaskRepository {
async findProjectById(idProject) {
return prisma.project.findUnique({
where: {
id: parseInt(idProject),
},
include: {
members: {
where: {
status: 'active',
},
select: {
id: true,
id_user: true,
},
},
},
});
}
async createTask(taskData) {
return prisma.task.create({
data: taskData,
});
}
async findTasksByProject(idProject) {
return prisma.task.findMany({
where: {
id_project: parseInt(idProject),
},
orderBy: {
deadline_at: 'asc',
},
include: {
submissions: {
select: {
id: true,
id_task: true,
id_user: true,
file_path: true,
url_link: true,
submitted_at: true,
updated_at: true,
},
},
},
});
}
async findTaskById(idTask) {
return prisma.task.findUnique({
where: {
id: parseInt(idTask),
},
include: {
project: {
select: {
id: true,
project_name: true,
project_icon: true,
members: {
where: {
status: 'active',
},
include: {
user: {
select: {
id: true,
full_name: true,
email: true,
professional_bio: true,
},
},
},
},
},
},
submissions: {
include: {
user: {
select: {
id: true,
full_name: true,
email: true,
professional_bio: true,
},
},
},
},
},
});
}
async updateTask(idTask, taskData) {
return prisma.task.update({
where: {
id: parseInt(idTask),
},
data: taskData,
});
}
async deleteTask(idTask) {
return prisma.task.delete({
where: {
id: parseInt(idTask),
},
});
}
async countSubmissionsByTask(idTask) {
return prisma.taskSubmission.count({
where: {
id_task: parseInt(idTask),
},
});
}
async checkActiveProjectMember(idProject, idUser) {
return prisma.projectMember.findFirst({
where: {
id_project: parseInt(idProject),
id_user: parseInt(idUser),
status: 'active',
},
});
}
async findSubmissionByTaskAndUser(idTask, idUser) {
return prisma.taskSubmission.findUnique({
where: {
id_task_id_user: {
id_task: parseInt(idTask),
id_user: parseInt(idUser),
},
},
});
}
async findSubmissionById(idSubmission) {
return prisma.taskSubmission.findUnique({
where: {
id: parseInt(idSubmission),
},
include: {
task: {
select: {
id: true,
id_project: true,
title: true,
submission_type: true,
deadline_at: true,
project: {
select: {
id: true,
project_name: true,
},
},
},
},
user: {
select: {
id: true,
full_name: true,
email: true,
},
},
},
});
}
async createSubmission(submissionData) {
return prisma.taskSubmission.create({
data: submissionData,
});
}
async updateSubmission(idSubmission, submissionData) {
return prisma.taskSubmission.update({
where: {
id: parseInt(idSubmission),
},
data: submissionData,
});
}
async deleteSubmission(idSubmission) {
return prisma.taskSubmission.delete({
where: {
id: parseInt(idSubmission),
},
});
}
}
module.exports = new TaskRepository();
+575
View File
@@ -0,0 +1,575 @@
const fs = require('fs');
const path = require('path');
const taskRepository = require('../repositories/task.repository');
const { taskUploadDir } = require('../helpers/taskUpload.helper');
const { data, error } = require('../../../helpers/utils/wrapper');
const {
BadRequestError,
NotFoundError,
ForbiddenError,
ConflictError,
} = require('../../../helpers/error');
class TaskService {
async createTask(idProject, payload, actor = {}) {
try {
if (!this.isPositiveInteger(idProject)) {
return error(new BadRequestError('Project ID must be a valid number'));
}
if (!actor?.id || actor.role !== 'admin') {
return error(new ForbiddenError('Access denied: only admin can create task'));
}
const project = await taskRepository.findProjectById(idProject);
if (!project) {
return error(new NotFoundError('Project not found'));
}
const deadlineAt = this.buildDeadlineAt(payload.deadline_date, payload.specific_time);
const task = await taskRepository.createTask({
id_project: parseInt(idProject),
title: payload.title,
description: payload.description,
deadline_at: deadlineAt,
submission_type: payload.submission_type,
});
return data(this.mapTask(task));
} catch (err) {
return error(err);
}
}
async getTasksByProject(idProject, actor = {}) {
try {
if (!this.isPositiveInteger(idProject)) {
return error(new BadRequestError('Project ID must be a valid number'));
}
const project = await taskRepository.findProjectById(idProject);
if (!project) {
return error(new NotFoundError('Project not found'));
}
const accessError = await this.validateProjectAccess(idProject, actor);
if (accessError) {
return error(accessError);
}
const tasks = await taskRepository.findTasksByProject(idProject);
const totalMembers = project.members?.length || 0;
const mappedTasks = tasks.map((task) => {
if (actor.role === 'intern') {
const mySubmission = task.submissions.find(
(submission) => submission.id_user === parseInt(actor.id)
) || null;
return {
...this.mapTask(task),
display_status: this.getDisplayStatus(task.deadline_at, mySubmission),
my_submission: mySubmission ? this.mapSubmission(mySubmission) : null,
};
}
return {
...this.mapTask(task),
total_members: totalMembers,
total_submissions: task.submissions?.length || 0,
};
});
return data(mappedTasks);
} catch (err) {
return error(err);
}
}
async getTaskById(idTask, actor = {}) {
try {
if (!this.isPositiveInteger(idTask)) {
return error(new BadRequestError('Task ID must be a valid number'));
}
const task = await taskRepository.findTaskById(idTask);
if (!task) {
return error(new NotFoundError('Task not found'));
}
const accessError = await this.validateProjectAccess(task.id_project, actor);
if (accessError) {
return error(accessError);
}
if (actor.role === 'admin') {
return data(this.mapAdminTaskDetail(task));
}
const mySubmission = task.submissions.find(
(submission) => submission.id_user === parseInt(actor.id)
) || null;
return data(this.mapInternTaskDetail(task, mySubmission));
} catch (err) {
return error(err);
}
}
async updateTask(idTask, payload, actor = {}) {
try {
if (!this.isPositiveInteger(idTask)) {
return error(new BadRequestError('Task ID must be a valid number'));
}
if (!actor?.id || actor.role !== 'admin') {
return error(new ForbiddenError('Access denied: only admin can update task'));
}
if (Object.keys(payload).length === 0) {
return error(new BadRequestError('At least one field is required to update task'));
}
const existingTask = await taskRepository.findTaskById(idTask);
if (!existingTask) {
return error(new NotFoundError('Task not found'));
}
const submissionCount = await taskRepository.countSubmissionsByTask(idTask);
if (
payload.submission_type &&
payload.submission_type !== existingTask.submission_type &&
submissionCount > 0
) {
return error(
new ConflictError('Submission type cannot be changed because this task already has submissions')
);
}
const updateData = {};
if (payload.title !== undefined) updateData.title = payload.title;
if (payload.description !== undefined) updateData.description = payload.description;
if (payload.submission_type !== undefined) updateData.submission_type = payload.submission_type;
if (payload.deadline_date !== undefined || payload.specific_time !== undefined) {
updateData.deadline_at = this.buildDeadlineAtFromExisting(
existingTask.deadline_at,
payload.deadline_date,
payload.specific_time
);
}
const updatedTask = await taskRepository.updateTask(idTask, updateData);
return data(this.mapTask(updatedTask));
} catch (err) {
return error(err);
}
}
async deleteTask(idTask, actor = {}) {
try {
if (!this.isPositiveInteger(idTask)) {
return error(new BadRequestError('Task ID must be a valid number'));
}
if (!actor?.id || actor.role !== 'admin') {
return error(new ForbiddenError('Access denied: only admin can delete task'));
}
const task = await taskRepository.findTaskById(idTask);
if (!task) {
return error(new NotFoundError('Task not found'));
}
const submissionCount = await taskRepository.countSubmissionsByTask(idTask);
if (submissionCount > 0) {
return error(new ConflictError('Task already has submissions and cannot be deleted'));
}
await taskRepository.deleteTask(idTask);
return data(null);
} catch (err) {
return error(err);
}
}
async submitTask(idTask, payload, file, actor = {}) {
try {
if (!this.isPositiveInteger(idTask)) {
return error(new BadRequestError('Task ID must be a valid number'));
}
if (!actor?.id || actor.role !== 'intern') {
return error(new ForbiddenError('Access denied: only intern can submit task'));
}
const task = await taskRepository.findTaskById(idTask);
if (!task) {
return error(new NotFoundError('Task not found'));
}
const membership = await taskRepository.checkActiveProjectMember(task.id_project, actor.id);
if (!membership) {
return error(new ForbiddenError('Access denied: you are not a member of this project'));
}
const submissionData = {
id_task: parseInt(idTask),
id_user: parseInt(actor.id),
};
if (task.submission_type === 'file_upload') {
if (!file) {
return error(new BadRequestError('Submission file is required for this task'));
}
if (payload.url_link) {
return error(new BadRequestError('URL link is not allowed for file upload task'));
}
submissionData.file_path = `/uploads/task-submissions/${file.filename}`;
submissionData.url_link = null;
}
if (task.submission_type === 'url_link') {
if (file) {
return error(new BadRequestError('File upload is not allowed for URL link task'));
}
if (!payload.url_link) {
return error(new BadRequestError('URL link is required for this task'));
}
submissionData.file_path = null;
submissionData.url_link = payload.url_link;
}
const existingSubmission = await taskRepository.findSubmissionByTaskAndUser(idTask, actor.id);
let submission;
if (existingSubmission) {
this.deleteLocalSubmissionFile(existingSubmission.file_path);
submission = await taskRepository.updateSubmission(existingSubmission.id, submissionData);
} else {
submission = await taskRepository.createSubmission(submissionData);
}
return data(this.mapSubmission(submission));
} catch (err) {
return error(err);
}
}
async updateSubmission(idSubmission, payload, file, actor = {}) {
try {
if (!this.isPositiveInteger(idSubmission)) {
return error(new BadRequestError('Submission ID must be a valid number'));
}
if (!actor?.id || actor.role !== 'intern') {
return error(new ForbiddenError('Access denied: only intern can update submission'));
}
const submission = await taskRepository.findSubmissionById(idSubmission);
if (!submission) {
return error(new NotFoundError('Submission not found'));
}
if (submission.id_user !== parseInt(actor.id)) {
return error(new ForbiddenError('Access denied: you can only update your own submission'));
}
const membership = await taskRepository.checkActiveProjectMember(
submission.task.id_project,
actor.id
);
if (!membership) {
return error(new ForbiddenError('Access denied: you are not a member of this project'));
}
const updateData = {};
if (submission.task.submission_type === 'file_upload') {
if (!file) {
return error(new BadRequestError('Submission file is required for this task'));
}
if (payload.url_link) {
return error(new BadRequestError('URL link is not allowed for file upload task'));
}
updateData.file_path = `/uploads/task-submissions/${file.filename}`;
updateData.url_link = null;
}
if (submission.task.submission_type === 'url_link') {
if (file) {
return error(new BadRequestError('File upload is not allowed for URL link task'));
}
if (!payload.url_link) {
return error(new BadRequestError('URL link is required for this task'));
}
updateData.file_path = null;
updateData.url_link = payload.url_link;
}
this.deleteLocalSubmissionFile(submission.file_path);
const updatedSubmission = await taskRepository.updateSubmission(
idSubmission,
updateData
);
return data(this.mapSubmission(updatedSubmission));
} catch (err) {
return error(err);
}
}
async deleteSubmission(idSubmission, actor = {}) {
try {
if (!this.isPositiveInteger(idSubmission)) {
return error(new BadRequestError('Submission ID must be a valid number'));
}
if (!actor?.id || actor.role !== 'intern') {
return error(new ForbiddenError('Access denied: only intern can delete submission'));
}
const submission = await taskRepository.findSubmissionById(idSubmission);
if (!submission) {
return error(new NotFoundError('Submission not found'));
}
if (submission.id_user !== parseInt(actor.id)) {
return error(new ForbiddenError('Access denied: you can only delete your own submission'));
}
const membership = await taskRepository.checkActiveProjectMember(
submission.task.id_project,
actor.id
);
if (!membership) {
return error(new ForbiddenError('Access denied: you are not a member of this project'));
}
await taskRepository.deleteSubmission(idSubmission);
this.deleteLocalSubmissionFile(submission.file_path);
return data(null);
} catch (err) {
return error(err);
}
}
async validateProjectAccess(idProject, actor = {}) {
if (!actor?.id || !actor?.role) {
return new ForbiddenError('Access denied: authentication data is missing');
}
if (actor.role === 'admin') {
return null;
}
if (actor.role === 'intern') {
const membership = await taskRepository.checkActiveProjectMember(idProject, actor.id);
if (!membership) {
return new ForbiddenError('Access denied: you are not a member of this project');
}
return null;
}
return new ForbiddenError('Access denied: invalid role');
}
buildDeadlineAt(deadlineDate, specificTime) {
const dateOnly = this.formatDateOnly(new Date(deadlineDate));
return new Date(`${dateOnly}T${specificTime}:00`);
}
buildDeadlineAtFromExisting(existingDeadlineAt, deadlineDate, specificTime) {
const currentDate = this.formatDateOnly(new Date(existingDeadlineAt));
const currentTime = this.formatTimeOnly(new Date(existingDeadlineAt));
const nextDate = deadlineDate !== undefined
? this.formatDateOnly(new Date(deadlineDate))
: currentDate;
const nextTime = specificTime !== undefined
? specificTime
: currentTime;
return new Date(`${nextDate}T${nextTime}:00`);
}
formatDateOnly(date) {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}
formatTimeOnly(date) {
const hours = `${date.getHours()}`.padStart(2, '0');
const minutes = `${date.getMinutes()}`.padStart(2, '0');
return `${hours}:${minutes}`;
}
getDisplayStatus(deadlineAt, submission) {
const now = new Date();
const deadline = new Date(deadlineAt);
if (deadline < now) {
return 'overdue';
}
if (submission) {
return 'done';
}
return 'pending';
}
mapTask(task) {
if (!task) return null;
return {
id: task.id,
id_project: task.id_project,
title: task.title,
description: task.description,
deadline_at: task.deadline_at,
submission_type: task.submission_type,
created_at: task.created_at,
updated_at: task.updated_at,
};
}
mapSubmission(submission) {
if (!submission) return null;
return {
id: submission.id,
id_task: submission.id_task,
id_user: submission.id_user,
file_path: submission.file_path,
url_link: submission.url_link,
submitted_at: submission.submitted_at,
updated_at: submission.updated_at,
};
}
mapInternTaskDetail(task, submission) {
return {
...this.mapTask(task),
project: {
id: task.project?.id,
project_name: task.project?.project_name,
project_icon: task.project?.project_icon,
},
display_status: this.getDisplayStatus(task.deadline_at, submission),
my_submission: submission ? this.mapSubmission(submission) : null,
};
}
mapAdminTaskDetail(task) {
const members = task.project?.members || [];
const submissions = task.submissions || [];
const mappedSubmissions = members.map((member) => {
const user = member.user;
const submission = submissions.find(
(item) => item.id_user === user.id
) || null;
const displayStatus = this.getDisplayStatus(task.deadline_at, submission);
return {
id_user: user.id,
full_name: user.full_name,
email: user.email,
profile_picture: null,
professional_bio: user.professional_bio || null,
submitted_at: submission?.submitted_at || null,
display_status: displayStatus,
submission: submission ? this.mapSubmission(submission) : null,
};
});
const totalDone = mappedSubmissions.filter((item) => item.display_status === 'done').length;
const totalPending = mappedSubmissions.filter((item) => item.display_status === 'pending').length;
const totalOverdue = mappedSubmissions.filter((item) => item.display_status === 'overdue').length;
return {
...this.mapTask(task),
project: {
id: task.project?.id,
project_name: task.project?.project_name,
project_icon: task.project?.project_icon,
},
submission_summary: {
total_members: members.length,
total_submitted: submissions.length,
total_done: totalDone,
total_pending: totalPending,
total_overdue: totalOverdue,
},
submissions: mappedSubmissions,
};
}
deleteLocalSubmissionFile(filePath) {
if (!filePath || typeof filePath !== 'string') {
return;
}
if (!filePath.startsWith('/uploads/task-submissions/')) {
return;
}
if (!taskUploadDir || typeof taskUploadDir !== 'string') {
return;
}
const filename = path.basename(filePath);
const absolutePath = path.join(taskUploadDir, filename);
if (fs.existsSync(absolutePath)) {
fs.unlinkSync(absolutePath);
}
}
isPositiveInteger(value) {
const number = Number(value);
return Number.isInteger(number) && number > 0;
}
}
module.exports = new TaskService();