Merge branch 'main' into feat/admin-project-lms
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `certificate` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`id_project` INTEGER NOT NULL,
|
||||
`id_user` INTEGER NOT NULL,
|
||||
`certificate_no` VARCHAR(255) NOT NULL,
|
||||
`issued_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `certificate_certificate_no_key`(`certificate_no`),
|
||||
INDEX `certificate_id_project_foreign`(`id_project`),
|
||||
INDEX `certificate_id_user_foreign`(`id_user`),
|
||||
UNIQUE INDEX `certificate_id_project_id_user_key`(`id_project`, `id_user`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `certificate` ADD CONSTRAINT `certificate_id_project_foreign` FOREIGN KEY (`id_project`) REFERENCES `project`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `certificate` ADD CONSTRAINT `certificate_id_user_foreign` FOREIGN KEY (`id_user`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
+22
-3
@@ -194,6 +194,7 @@ model User {
|
||||
lamaran_magang LamaranMagang @relation(fields: [id_lamaran_magang], references: [id], map: "user_id_lamaran_magang_foreign")
|
||||
project_members ProjectMember[]
|
||||
task_submissions TaskSubmission[]
|
||||
certificates Certificate[]
|
||||
|
||||
@@index([email])
|
||||
@@map("user")
|
||||
@@ -211,9 +212,10 @@ model Project {
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
admin Admin @relation(fields: [id_admin], references: [id], map: "project_id_admin_foreign")
|
||||
members ProjectMember[]
|
||||
tasks Task[]
|
||||
admin Admin @relation(fields: [id_admin], references: [id], map: "project_id_admin_foreign")
|
||||
members ProjectMember[]
|
||||
tasks Task[]
|
||||
certificates Certificate[]
|
||||
|
||||
@@index([id_admin], map: "project_id_admin_foreign")
|
||||
@@index([status])
|
||||
@@ -328,3 +330,20 @@ enum SubmissionType {
|
||||
file_upload
|
||||
url_link
|
||||
}
|
||||
|
||||
model Certificate {
|
||||
id Int @id @default(autoincrement())
|
||||
uuid String @unique @default(uuid()) @db.VarChar(36)
|
||||
id_project Int
|
||||
id_user Int
|
||||
certificate_no String @unique @db.VarChar(255)
|
||||
issued_at DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [id_project], references: [id], map: "certificate_id_project_foreign")
|
||||
user User @relation(fields: [id_user], references: [id], map: "certificate_id_user_foreign")
|
||||
|
||||
@@unique([id_project, id_user])
|
||||
@@index([id_project], map: "certificate_id_project_foreign")
|
||||
@@index([id_user], map: "certificate_id_user_foreign")
|
||||
@@map("certificate")
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ const swaggerOptions = {
|
||||
{
|
||||
name: "Project",
|
||||
description: "Internify LMS project management endpoints"
|
||||
},
|
||||
{
|
||||
name: "Task",
|
||||
description: "Managing project tasks and intern submissions"
|
||||
}
|
||||
],
|
||||
components: {
|
||||
|
||||
+4
-1
@@ -13,6 +13,8 @@ const faqRoutes = require('./routes/faq.routes');
|
||||
const feedbackRoutes = require('./routes/feedback.routes');
|
||||
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 setupSwaggerDocs = require('./docs/swagger');
|
||||
|
||||
const PORT = process.env.PORT;
|
||||
@@ -56,8 +58,9 @@ app.use("/faq-api", faqRoutes);
|
||||
app.use("/feedback-api", feedbackRoutes);
|
||||
app.use("/batch-api", batchRoutes);
|
||||
app.use("/project-api", projectRoutes);
|
||||
app.use("/task-api", taskRoutes);
|
||||
app.use("/certificate-api", certificateRoutes);
|
||||
// app.use("/project-member-api", projectMemberRoutes);
|
||||
// app.use("/task-api", taskRoutes);
|
||||
|
||||
|
||||
setupSwaggerDocs(app);
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
const taskController = require('./controllers/task.controller');
|
||||
|
||||
module.exports = {
|
||||
taskController
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,369 @@
|
||||
const express = require('express');
|
||||
const { certificateController } = require('../modules/certificate');
|
||||
const { verifyJWT } = require('../middleware/verifyJWT');
|
||||
const isAdmin = require('../middleware/isAdmin');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* Certificate:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* example: 1
|
||||
* id_project:
|
||||
* type: integer
|
||||
* example: 2
|
||||
* id_user:
|
||||
* type: integer
|
||||
* example: 5
|
||||
* certificate_no:
|
||||
* type: string
|
||||
* example: "CERT/20260609/PRJ2/USR5"
|
||||
* issued_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* example: "2026-06-09T14:00:00.000Z"
|
||||
* user:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* example: 5
|
||||
* full_name:
|
||||
* type: string
|
||||
* example: "Rafi Athallah"
|
||||
* email:
|
||||
* type: string
|
||||
* example: "rafi@student.com"
|
||||
* project:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* example: 2
|
||||
* project_name:
|
||||
* type: string
|
||||
* example: "Internify Platform Dev"
|
||||
* description:
|
||||
* type: string
|
||||
* example: "Project to develop features of the Internify web app"
|
||||
* CertificateClaimRequest:
|
||||
* type: object
|
||||
* required:
|
||||
* - id_project
|
||||
* properties:
|
||||
* id_project:
|
||||
* type: integer
|
||||
* example: 2
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /certificate-api/claim:
|
||||
* post:
|
||||
* summary: Claim certificate for a completed project
|
||||
* description: Interns can claim a certificate for a project once they have submitted all assigned tasks.
|
||||
* tags: [Certificate]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/CertificateClaimRequest'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Certificate claimed successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Certificate'
|
||||
* message:
|
||||
* type: string
|
||||
* example: "Certificate claimed successfully"
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 201
|
||||
* 400:
|
||||
* description: Bad request (no tasks assigned or not all tasks submitted)
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden (only interns can claim, or not a member of project)
|
||||
* 404:
|
||||
* description: Project not found
|
||||
* 409:
|
||||
* description: Conflict (certificate already claimed)
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.post('/claim', verifyJWT, certificateController.claimCertificate);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /certificate-api/my-certificates:
|
||||
* get:
|
||||
* summary: Get logged-in intern's certificates
|
||||
* description: Retrieve all certificates earned by the currently logged-in intern.
|
||||
* tags: [Certificate]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificates retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Certificate'
|
||||
* message:
|
||||
* type: string
|
||||
* example: "Certificates retrieved successfully"
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 200
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden (only interns can access this)
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.get('/my-certificates', verifyJWT, certificateController.getMyCertificates);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /certificate-api/all:
|
||||
* get:
|
||||
* summary: Get all issued certificates (Admin only)
|
||||
* description: Admin/Mentor can retrieve all certificates issued across the system.
|
||||
* tags: [Certificate]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: All certificates retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Certificate'
|
||||
* message:
|
||||
* type: string
|
||||
* example: "All certificates retrieved successfully"
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 200
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden (Admin only)
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.get('/all', verifyJWT, isAdmin, certificateController.getAllCertificates);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /certificate-api/detail/{id}:
|
||||
* get:
|
||||
* summary: Get certificate detail
|
||||
* description: Fetch detailed information for a certificate by its ID. Interns can only access their own. Admins can access any.
|
||||
* tags: [Certificate]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* schema:
|
||||
* type: integer
|
||||
* required: true
|
||||
* description: Certificate ID
|
||||
* example: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate details retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Certificate'
|
||||
* message:
|
||||
* type: string
|
||||
* example: "Certificate details retrieved successfully"
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 200
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden (Accessing another user's certificate)
|
||||
* 404:
|
||||
* description: Certificate not found
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.get('/detail/:id', verifyJWT, certificateController.getCertificateDetail);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /certificate-api/project/{id_project}:
|
||||
* get:
|
||||
* summary: Get certificates issued for a specific project (Admin only)
|
||||
* description: Admin/Mentor can retrieve all certificates issued to interns for a specific project.
|
||||
* tags: [Certificate]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id_project
|
||||
* schema:
|
||||
* type: integer
|
||||
* required: true
|
||||
* description: Project ID
|
||||
* example: 2
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Project certificates retrieved successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden (Admin only)
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.get('/project/:id_project', verifyJWT, isAdmin, certificateController.getProjectCertificates);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /certificate-api/verify:
|
||||
* get:
|
||||
* summary: Verify a certificate (Public)
|
||||
* description: Verify the validity of a certificate number publicly without authentication.
|
||||
* tags: [Certificate]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: certificate_no
|
||||
* schema:
|
||||
* type: string
|
||||
* required: true
|
||||
* description: The certificate number to verify
|
||||
* example: "CERT/20260609/PRJ2/USR5"
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate is valid
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Certificate'
|
||||
* message:
|
||||
* type: string
|
||||
* example: "Certificate validated successfully"
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 200
|
||||
* 400:
|
||||
* description: Bad request (missing certificate number)
|
||||
* 404:
|
||||
* description: Certificate not found or invalid
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.get('/verify', certificateController.verifyCertificate);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /certificate-api/verify-uuid/{uuid}:
|
||||
* get:
|
||||
* summary: Verify a certificate by UUID (Public)
|
||||
* description: Verify the validity of a certificate by its unique UUID for the frontend. Returns intern's name, creation date, and UUID.
|
||||
* tags: [Certificate]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: uuid
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* required: true
|
||||
* description: The certificate UUID to verify
|
||||
* example: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate is valid
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* uuid:
|
||||
* type: string
|
||||
* example: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
|
||||
* intern_name:
|
||||
* type: string
|
||||
* example: "Rafi Athallah"
|
||||
* project_name:
|
||||
* type: string
|
||||
* example: "Internify Platform Dev"
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* example: "2026-06-09T14:00:00.000Z"
|
||||
* message:
|
||||
* type: string
|
||||
* example: "Certificate validated successfully"
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 200
|
||||
* 400:
|
||||
* description: Bad request (missing UUID)
|
||||
* 404:
|
||||
* description: Certificate not found or invalid
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.get('/verify-uuid/:uuid', certificateController.verifyCertificateByUuid);
|
||||
|
||||
module.exports = router;
|
||||
@@ -72,6 +72,7 @@ const router = express.Router();
|
||||
* example: 2026-08-10
|
||||
* member_emails:
|
||||
* type: array
|
||||
* description: Optional intern emails to invite. Each intern must already have an active user account and must not be assigned to another active project.
|
||||
* items:
|
||||
* type: string
|
||||
* format: email
|
||||
@@ -454,7 +455,7 @@ const router = express.Router();
|
||||
* /project-api/add:
|
||||
* post:
|
||||
* summary: Create a new project
|
||||
* description: Create a new Internify LMS project. Admin can optionally invite interns by email.
|
||||
* description: Create a new Internify LMS project. Admin can optionally invite interns by email. Invited interns must not be active members of another project.
|
||||
* tags: [Project]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
@@ -467,6 +468,10 @@ const router = express.Router();
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Project created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ProjectDetailResponse'
|
||||
* 400:
|
||||
* description: Bad request
|
||||
* 401:
|
||||
@@ -503,6 +508,10 @@ router.post('/add', verifyJWT, isMentorOrAdmin, projectController.createProject)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Projects retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ProjectListResponse'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 417:
|
||||
@@ -532,6 +541,10 @@ router.get('/get', verifyJWT, projectController.getAllProjects);
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Project detail retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ProjectDetailResponse'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
@@ -566,9 +579,20 @@ router.get('/get/:id', verifyJWT, projectController.getProjectById);
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ProjectUpdateRequest'
|
||||
* example:
|
||||
* project_icon: cloud
|
||||
* project_name: Internify LMS Updated
|
||||
* description: Updated project description
|
||||
* start_date: 2026-06-15
|
||||
* end_date: 2026-08-20
|
||||
* status: completed
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Project updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ProjectDetailResponse'
|
||||
* 400:
|
||||
* description: Bad request
|
||||
* 401:
|
||||
@@ -604,6 +628,10 @@ router.patch('/update/:id', verifyJWT, isMentorOrAdmin, projectController.update
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Project archived successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ProjectDetailResponse'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
@@ -627,6 +655,10 @@ router.delete('/delete/:id', verifyJWT, isMentorOrAdmin, projectController.archi
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Intern projects retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ProjectListResponse'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
@@ -648,6 +680,10 @@ router.get('/my-projects', verifyJWT, projectController.getMyProjects);
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Intern tasks retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/MyTaskListResponse'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
@@ -669,6 +705,10 @@ router.get('/my-tasks', verifyJWT, projectController.getMyTasks);
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Mentor projects retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ProjectListResponse'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
@@ -765,6 +805,10 @@ router.get('/interns', verifyJWT, isMentorOrAdmin, projectController.getInterns)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Member assigned successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AssignMemberResponse'
|
||||
* 400:
|
||||
* description: Bad request
|
||||
* 401:
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
const express = require('express');
|
||||
const { taskController } = require('../modules/task');
|
||||
const { verifyJWT } = require('../middleware/verifyJWT');
|
||||
const isAdmin = require('../middleware/isAdmin');
|
||||
const {
|
||||
taskUpload,
|
||||
taskUploadErrorHandler,
|
||||
} = require('../modules/task/helpers/taskUpload.helper');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* Task:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* example: 1
|
||||
* id_project:
|
||||
* type: integer
|
||||
* example: 1
|
||||
* title:
|
||||
* type: string
|
||||
* example: Laporan Tugas 2
|
||||
* description:
|
||||
* type: string
|
||||
* example: Berisi design UI/UX dan user flow dari aplikasi Internify.
|
||||
* deadline_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* example: 2026-05-28T23:59:00.000Z
|
||||
* submission_type:
|
||||
* type: string
|
||||
* enum: [file_upload, url_link]
|
||||
* example: file_upload
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* example: 2026-06-09T10:00:00.000Z
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* example: 2026-06-09T10:00:00.000Z
|
||||
*
|
||||
* TaskCreateRequest:
|
||||
* type: object
|
||||
* required:
|
||||
* - title
|
||||
* - description
|
||||
* - deadline_date
|
||||
* - specific_time
|
||||
* - submission_type
|
||||
* properties:
|
||||
* title:
|
||||
* type: string
|
||||
* example: Laporan Tugas 2
|
||||
* description:
|
||||
* type: string
|
||||
* example: Berisi design UI/UX dan user flow dari aplikasi Internify.
|
||||
* deadline_date:
|
||||
* type: string
|
||||
* format: date
|
||||
* example: 2026-05-28
|
||||
* specific_time:
|
||||
* type: string
|
||||
* example: "23:59"
|
||||
* submission_type:
|
||||
* type: string
|
||||
* enum: [file_upload, url_link]
|
||||
* example: file_upload
|
||||
*
|
||||
* TaskUpdateRequest:
|
||||
* type: object
|
||||
* properties:
|
||||
* title:
|
||||
* type: string
|
||||
* example: Laporan Tugas 2 Updated
|
||||
* description:
|
||||
* type: string
|
||||
* example: Updated task description.
|
||||
* deadline_date:
|
||||
* type: string
|
||||
* format: date
|
||||
* example: 2026-05-30
|
||||
* specific_time:
|
||||
* type: string
|
||||
* example: "23:59"
|
||||
* submission_type:
|
||||
* type: string
|
||||
* enum: [file_upload, url_link]
|
||||
* example: url_link
|
||||
*
|
||||
* TaskSubmission:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* example: 1
|
||||
* id_task:
|
||||
* type: integer
|
||||
* example: 1
|
||||
* id_user:
|
||||
* type: integer
|
||||
* example: 2
|
||||
* file_path:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* example: /uploads/task-submissions/report.pdf
|
||||
* url_link:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* example: https://drive.google.com/file/example
|
||||
* submitted_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* example: 2026-06-09T10:00:00.000Z
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* example: 2026-06-09T10:00:00.000Z
|
||||
*
|
||||
* InternTaskItem:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/Task'
|
||||
* - type: object
|
||||
* properties:
|
||||
* display_status:
|
||||
* type: string
|
||||
* enum: [pending, done, overdue]
|
||||
* example: pending
|
||||
* my_submission:
|
||||
* nullable: true
|
||||
* $ref: '#/components/schemas/TaskSubmission'
|
||||
*
|
||||
* AdminTaskItem:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/Task'
|
||||
* - type: object
|
||||
* properties:
|
||||
* total_members:
|
||||
* type: integer
|
||||
* example: 8
|
||||
* total_submissions:
|
||||
* type: integer
|
||||
* example: 5
|
||||
*
|
||||
* AdminTaskSubmissionItem:
|
||||
* type: object
|
||||
* properties:
|
||||
* id_user:
|
||||
* type: integer
|
||||
* example: 1
|
||||
* full_name:
|
||||
* type: string
|
||||
* example: Alex Rivera
|
||||
* email:
|
||||
* type: string
|
||||
* example: alex.rivera@example.com
|
||||
* profile_picture:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* example: null
|
||||
* professional_bio:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* example: Backend developer intern
|
||||
* submitted_at:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* format: date-time
|
||||
* example: 2026-05-25T10:00:00.000Z
|
||||
* display_status:
|
||||
* type: string
|
||||
* enum: [pending, done, overdue]
|
||||
* example: done
|
||||
* submission:
|
||||
* nullable: true
|
||||
* $ref: '#/components/schemas/TaskSubmission'
|
||||
*
|
||||
* AdminTaskDetail:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/Task'
|
||||
* - type: object
|
||||
* properties:
|
||||
* project:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* example: 1
|
||||
* project_name:
|
||||
* type: string
|
||||
* example: Internify Project
|
||||
* project_icon:
|
||||
* type: string
|
||||
* example: code
|
||||
* submission_summary:
|
||||
* type: object
|
||||
* properties:
|
||||
* total_members:
|
||||
* type: integer
|
||||
* example: 8
|
||||
* total_submitted:
|
||||
* type: integer
|
||||
* example: 5
|
||||
* total_done:
|
||||
* type: integer
|
||||
* example: 4
|
||||
* total_pending:
|
||||
* type: integer
|
||||
* example: 3
|
||||
* total_overdue:
|
||||
* type: integer
|
||||
* example: 1
|
||||
* submissions:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AdminTaskSubmissionItem'
|
||||
*
|
||||
* InternTaskDetail:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/Task'
|
||||
* - type: object
|
||||
* properties:
|
||||
* project:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* example: 1
|
||||
* project_name:
|
||||
* type: string
|
||||
* example: Internify Project
|
||||
* project_icon:
|
||||
* type: string
|
||||
* example: code
|
||||
* display_status:
|
||||
* type: string
|
||||
* enum: [pending, done, overdue]
|
||||
* example: pending
|
||||
* my_submission:
|
||||
* nullable: true
|
||||
* $ref: '#/components/schemas/TaskSubmission'
|
||||
*
|
||||
* TaskResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Task'
|
||||
* message:
|
||||
* type: string
|
||||
* example: Task created successfully
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 201
|
||||
*
|
||||
* TaskListResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Task'
|
||||
* message:
|
||||
* type: string
|
||||
* example: Tasks retrieved successfully
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 200
|
||||
*
|
||||
* AdminTaskDetailResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* $ref: '#/components/schemas/AdminTaskDetail'
|
||||
* message:
|
||||
* type: string
|
||||
* example: Task detail retrieved successfully
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 200
|
||||
*
|
||||
* InternTaskDetailResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* $ref: '#/components/schemas/InternTaskDetail'
|
||||
* message:
|
||||
* type: string
|
||||
* example: Task detail retrieved successfully
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 200
|
||||
*
|
||||
* TaskSubmissionResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* $ref: '#/components/schemas/TaskSubmission'
|
||||
* message:
|
||||
* type: string
|
||||
* example: Task submitted successfully
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 200
|
||||
*
|
||||
* ErrorResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* data:
|
||||
* nullable: true
|
||||
* example: null
|
||||
* message:
|
||||
* type: string
|
||||
* example: Validation error
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 417
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /task-api/projects/{id_project}/tasks:
|
||||
* post:
|
||||
* summary: Create task in project
|
||||
* description: Create a new task inside a project. Only admin can access this endpoint.
|
||||
* tags: [Task]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id_project
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Project ID
|
||||
* example: 1
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/TaskCreateRequest'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Task created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/TaskResponse'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden
|
||||
* 404:
|
||||
* description: Project not found
|
||||
* 417:
|
||||
* description: Validation error
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.post('/projects/:id_project/tasks', verifyJWT, isAdmin, taskController.createTask);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /task-api/projects/{id_project}/tasks:
|
||||
* get:
|
||||
* summary: Get project tasks
|
||||
* description: Get all tasks from a project. Admin can access any project task list. Intern can only access tasks from their active project.
|
||||
* tags: [Task]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id_project
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Project ID
|
||||
* example: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tasks retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/TaskListResponse'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden
|
||||
* 404:
|
||||
* description: Project not found
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.get('/projects/:id_project/tasks', verifyJWT, taskController.getTasksByProject);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /task-api/tasks/{id}:
|
||||
* get:
|
||||
* summary: Get task detail
|
||||
* description: Get task detail. Admin receives all intern submissions. Intern receives only their own submission.
|
||||
* tags: [Task]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Task ID
|
||||
* example: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Task detail retrieved successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden
|
||||
* 404:
|
||||
* description: Task not found
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.get('/tasks/:id', verifyJWT, taskController.getTaskById);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /task-api/tasks/{id}:
|
||||
* patch:
|
||||
* summary: Update task
|
||||
* description: Update task information. Submission type cannot be changed if the task already has submissions. Only admin can access this endpoint.
|
||||
* tags: [Task]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Task ID
|
||||
* example: 1
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/TaskUpdateRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Task updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/TaskResponse'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden
|
||||
* 404:
|
||||
* description: Task not found
|
||||
* 409:
|
||||
* description: Submission type cannot be changed because this task already has submissions
|
||||
* 417:
|
||||
* description: Validation error
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.patch('/tasks/:id', verifyJWT, isAdmin, taskController.updateTask);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /task-api/tasks/{id}:
|
||||
* delete:
|
||||
* summary: Delete task
|
||||
* description: Delete task only if it does not have submissions. Only admin can access this endpoint.
|
||||
* tags: [Task]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Task ID
|
||||
* example: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Task deleted successfully
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden
|
||||
* 404:
|
||||
* description: Task not found
|
||||
* 409:
|
||||
* description: Task already has submissions and cannot be deleted
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.delete('/tasks/:id', verifyJWT, isAdmin, taskController.deleteTask);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /task-api/tasks/{id}/submissions:
|
||||
* post:
|
||||
* summary: Submit task
|
||||
* description: Submit a task as an intern. For file_upload tasks, send submission_file. For url_link tasks, send url_link.
|
||||
* tags: [Task]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Task ID
|
||||
* example: 1
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* submission_file:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Required when task submission_type is file_upload. Allowed file types are PDF. Max size is 10MB.
|
||||
* url_link:
|
||||
* type: string
|
||||
* format: uri
|
||||
* description: Required when task submission_type is url_link.
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* url_link:
|
||||
* type: string
|
||||
* format: uri
|
||||
* example: https://drive.google.com/file/example
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Task submitted successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/TaskSubmissionResponse'
|
||||
* 400:
|
||||
* description: Bad request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden
|
||||
* 404:
|
||||
* description: Task not found
|
||||
* 417:
|
||||
* description: Validation error
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.post(
|
||||
'/tasks/:id/submissions',
|
||||
verifyJWT,
|
||||
taskUpload.single('submission_file'),
|
||||
taskUploadErrorHandler,
|
||||
taskController.submitTask
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /task-api/submissions/{id}:
|
||||
* patch:
|
||||
* summary: Update submission
|
||||
* description: Update an intern submission. Only the owner intern can update their own submission. For file_upload tasks, send submission_file. For url_link tasks, send url_link.
|
||||
* tags: [Task]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Submission ID
|
||||
* example: 1
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* submission_file:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Required when related task submission_type is file_upload. Allowed file types are PDF, DOCX, and ZIP. Max size is 10MB.
|
||||
* url_link:
|
||||
* type: string
|
||||
* format: uri
|
||||
* description: Required when related task submission_type is url_link.
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* url_link:
|
||||
* type: string
|
||||
* format: uri
|
||||
* example: https://drive.google.com/file/example-updated
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Submission updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/TaskSubmissionResponse'
|
||||
* 400:
|
||||
* description: Bad request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden
|
||||
* 404:
|
||||
* description: Submission not found
|
||||
* 417:
|
||||
* description: Validation error
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.patch(
|
||||
'/submissions/:id',
|
||||
verifyJWT,
|
||||
taskUpload.single('submission_file'),
|
||||
taskUploadErrorHandler,
|
||||
taskController.updateSubmission
|
||||
);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /task-api/submissions/{id}:
|
||||
* delete:
|
||||
* summary: Delete submission
|
||||
* description: Delete an intern submission. Only the owner intern can delete their own submission.
|
||||
* tags: [Task]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Submission ID
|
||||
* example: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Submission deleted successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* nullable: true
|
||||
* example: null
|
||||
* message:
|
||||
* type: string
|
||||
* example: Submission deleted successfully
|
||||
* code:
|
||||
* type: integer
|
||||
* example: 200
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden
|
||||
* 404:
|
||||
* description: Submission not found
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.delete(
|
||||
'/submissions/:id',
|
||||
verifyJWT,
|
||||
taskController.deleteSubmission
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user