Merge pull request #8 from internship-humic/feat/certificate-api
Buat Certificate system
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")
|
lamaran_magang LamaranMagang @relation(fields: [id_lamaran_magang], references: [id], map: "user_id_lamaran_magang_foreign")
|
||||||
project_members ProjectMember[]
|
project_members ProjectMember[]
|
||||||
task_submissions TaskSubmission[]
|
task_submissions TaskSubmission[]
|
||||||
|
certificates Certificate[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
@@ -212,9 +213,10 @@ model Project {
|
|||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
admin Admin @relation(fields: [id_admin], references: [id], map: "project_id_admin_foreign")
|
admin Admin @relation(fields: [id_admin], references: [id], map: "project_id_admin_foreign")
|
||||||
members ProjectMember[]
|
members ProjectMember[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
certificates Certificate[]
|
||||||
|
|
||||||
@@index([id_admin], map: "project_id_admin_foreign")
|
@@index([id_admin], map: "project_id_admin_foreign")
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -328,3 +330,20 @@ enum SubmissionType {
|
|||||||
file_upload
|
file_upload
|
||||||
url_link
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const faqRoutes = require('./routes/faq.routes');
|
|||||||
const feedbackRoutes = require('./routes/feedback.routes');
|
const feedbackRoutes = require('./routes/feedback.routes');
|
||||||
const batchRoutes = require('./routes/batch.routes');
|
const batchRoutes = require('./routes/batch.routes');
|
||||||
const projectRoutes = require('./routes/project.routes');
|
const projectRoutes = require('./routes/project.routes');
|
||||||
|
const certificateRoutes = require('./routes/certificate.routes');
|
||||||
const setupSwaggerDocs = require('./docs/swagger');
|
const setupSwaggerDocs = require('./docs/swagger');
|
||||||
|
|
||||||
const PORT = process.env.PORT;
|
const PORT = process.env.PORT;
|
||||||
@@ -56,6 +57,7 @@ app.use("/faq-api", faqRoutes);
|
|||||||
app.use("/feedback-api", feedbackRoutes);
|
app.use("/feedback-api", feedbackRoutes);
|
||||||
app.use("/batch-api", batchRoutes);
|
app.use("/batch-api", batchRoutes);
|
||||||
app.use("/project-api", projectRoutes);
|
app.use("/project-api", projectRoutes);
|
||||||
|
app.use("/certificate-api", certificateRoutes);
|
||||||
// app.use("/project-member-api", projectMemberRoutes);
|
// app.use("/project-member-api", projectMemberRoutes);
|
||||||
// app.use("/task-api", taskRoutes);
|
// app.use("/task-api", taskRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -497,19 +497,16 @@ class ProjectService {
|
|||||||
return error(new BadRequestError("Project ID and User ID are required"));
|
return error(new BadRequestError("Project ID and User ID are required"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if project exists
|
|
||||||
const project = await projectRepository.findById(id_project);
|
const project = await projectRepository.findById(id_project);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return error(new NotFoundError("Project not found"));
|
return error(new NotFoundError("Project not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists and is active
|
|
||||||
const user = await projectRepository.findUserByIdAndActive(id_user);
|
const user = await projectRepository.findUserByIdAndActive(id_user);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return error(new NotFoundError("User not found or inactive"));
|
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);
|
const existingMembership = await projectRepository.findMembership(id_project, id_user);
|
||||||
|
|
||||||
let membership;
|
let membership;
|
||||||
@@ -517,10 +514,8 @@ class ProjectService {
|
|||||||
if (existingMembership.status === 'active') {
|
if (existingMembership.status === 'active') {
|
||||||
return error(new ConflictError("User is already an active member of this project"));
|
return error(new ConflictError("User is already an active member of this project"));
|
||||||
}
|
}
|
||||||
// Reactivate membership
|
|
||||||
membership = await projectRepository.updateMembershipStatus(existingMembership.id, 'active');
|
membership = await projectRepository.updateMembershipStatus(existingMembership.id, 'active');
|
||||||
} else {
|
} else {
|
||||||
// Create new membership
|
|
||||||
membership = await projectRepository.assignMember(id_project, id_user);
|
membership = await projectRepository.assignMember(id_project, id_user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user