Merge branch 'main' into feat/task

This commit is contained in:
Muhammad Zhafran Ilham
2026-06-10 03:30:32 +07:00
committed by GitHub
10 changed files with 951 additions and 8 deletions
+2
View File
@@ -14,6 +14,7 @@ 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;
@@ -58,6 +59,7 @@ 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);
@@ -0,0 +1,177 @@
const certificateService = require('../services/certificate.service');
const { response, error } = require('../../../helpers/utils/wrapper');
const { SUCCESS, ERROR } = require('../../../helpers/http-status/status_code');
const { InternalServerError } = require('../../../helpers/error');
const { isValidPayload } = require('../../../helpers/utils/validator');
const { claimCertificateModel } = require('../models/certificate.model');
const getActor = (req) => ({
id: req.id,
role: req.role,
email: req.email,
});
class CertificateController {
async claimCertificate(req, res) {
try {
const validatePayload = isValidPayload(req.body, claimCertificateModel);
if (validatePayload.err) {
return response(
res,
'fail',
{ err: validatePayload.err, data: null },
'Data claim certificate tidak valid',
ERROR.EXPECTATION_FAILED
);
}
const actor = getActor(req);
const result = await certificateService.claimCertificate(validatePayload.data, actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Certificate claimed successfully', SUCCESS.CREATED);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async getMyCertificates(req, res) {
try {
const actor = getActor(req);
const result = await certificateService.getMyCertificates(actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Certificates retrieved successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async getCertificateDetail(req, res) {
try {
const { id } = req.params;
const actor = getActor(req);
const result = await certificateService.getCertificateDetail(id, actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Certificate details retrieved successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async getProjectCertificates(req, res) {
try {
const { id_project } = req.params;
const actor = getActor(req);
const result = await certificateService.getProjectCertificates(id_project, actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Project certificates retrieved successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async getAllCertificates(req, res) {
try {
const actor = getActor(req);
const result = await certificateService.getAllCertificates(actor);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'All certificates retrieved successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async verifyCertificate(req, res) {
try {
const certificate_no = req.query.certificate_no || req.params.certificate_no;
const result = await certificateService.verifyCertificate(certificate_no);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Certificate validated successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
async verifyCertificateByUuid(req, res) {
try {
const { uuid } = req.params;
const result = await certificateService.verifyCertificateByUuid(uuid);
if (result.err) {
return response(res, 'fail', result);
}
return response(res, 'success', result, 'Certificate validated successfully', SUCCESS.OK);
} catch (err) {
return response(
res,
'fail',
error(new InternalServerError(err.message)),
'Unexpected error occurred',
ERROR.INTERNAL_ERROR
);
}
}
}
module.exports = new CertificateController();
+5
View File
@@ -0,0 +1,5 @@
const certificateController = require('./controllers/certificate.controller');
module.exports = {
certificateController,
};
@@ -0,0 +1,13 @@
const joi = require('joi');
const claimCertificateModel = joi.object().keys({
id_project: joi.number().integer().required().messages({
'number.base': 'Project ID harus berupa angka.',
'number.integer': 'Project ID harus berupa bilangan bulat.',
'any.required': 'Project ID wajib diisi.',
}),
});
module.exports = {
claimCertificateModel,
};
@@ -0,0 +1,118 @@
const prisma = require('../../../helpers/db/db_connection');
class CertificateRepository {
async create(certificateData) {
return prisma.certificate.create({
data: certificateData,
include: this.certificateDetailInclude(),
});
}
async findById(id) {
return prisma.certificate.findUnique({
where: { id: parseInt(id) },
include: this.certificateDetailInclude(),
});
}
async findByUserAndProject(id_user, id_project) {
return prisma.certificate.findUnique({
where: {
id_project_id_user: {
id_project: parseInt(id_project),
id_user: parseInt(id_user),
},
},
include: this.certificateDetailInclude(),
});
}
async findUserCertificates(id_user) {
return prisma.certificate.findMany({
where: { id_user: parseInt(id_user) },
include: this.certificateDetailInclude(),
orderBy: { issued_at: 'desc' },
});
}
async findProjectCertificates(id_project) {
return prisma.certificate.findMany({
where: { id_project: parseInt(id_project) },
include: this.certificateDetailInclude(),
orderBy: { issued_at: 'desc' },
});
}
async findAll() {
return prisma.certificate.findMany({
include: this.certificateDetailInclude(),
orderBy: { issued_at: 'desc' },
});
}
async findByCertificateNo(certificate_no) {
return prisma.certificate.findUnique({
where: { certificate_no },
include: this.certificateDetailInclude(),
});
}
async findByUuid(uuid) {
return prisma.certificate.findUnique({
where: { uuid },
include: this.certificateDetailInclude(),
});
}
async getProjectTasksAndSubmissions(id_project, id_user) {
return prisma.project.findUnique({
where: { id: parseInt(id_project) },
select: {
id: true,
project_name: true,
tasks: {
select: {
id: true,
submissions: {
where: { id_user: parseInt(id_user) },
select: {
id: true,
submitted_at: true,
},
},
},
},
members: {
where: {
id_user: parseInt(id_user),
status: 'active',
},
select: {
id: true,
},
},
},
});
}
certificateDetailInclude() {
return {
user: {
select: {
id: true,
full_name: true,
email: true,
},
},
project: {
select: {
id: true,
project_name: true,
description: true,
},
},
};
}
}
module.exports = new CertificateRepository();
@@ -0,0 +1,225 @@
const certificateRepository = require('../repositories/certificate.repository');
const { data, error } = require('../../../helpers/utils/wrapper');
const {
BadRequestError,
NotFoundError,
ForbiddenError,
ConflictError,
} = require('../../../helpers/error');
class CertificateService {
async claimCertificate(payload, actor) {
try {
if (!actor?.id) {
return error(new BadRequestError('User ID is required'));
}
if (actor.role !== 'intern') {
return error(
new ForbiddenError('Access denied: only interns can claim certificates'),
);
}
const { id_project } = payload;
const existingCert = await certificateRepository.findByUserAndProject(actor.id, id_project);
if (existingCert) {
return error(new ConflictError('Certificate already claimed for this project'));
}
const projectInfo = await certificateRepository.getProjectTasksAndSubmissions(id_project, actor.id);
if (!projectInfo) {
return error(new NotFoundError('Project not found'));
}
if (!projectInfo.members || projectInfo.members.length === 0) {
return error(
new ForbiddenError('Access denied: you are not an active member of this project'),
);
}
if (!projectInfo.tasks || projectInfo.tasks.length === 0) {
return error(
new BadRequestError('Cannot claim certificate: this project does not have any tasks assigned yet'),
);
}
const completedTasks = projectInfo.tasks.filter(
(task) => task.submissions && task.submissions.length > 0
);
if (completedTasks.length < projectInfo.tasks.length) {
return error(
new BadRequestError(
`Cannot claim certificate: you have completed ${completedTasks.length} out of ${projectInfo.tasks.length} tasks`
),
);
}
// Generate certificate number berdsarkan format ini: CERT/YYYYMMDD/PRJ{id_project}/USR{id_user} [Rafi 09/06/2026 20:45]
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const date = String(now.getDate()).padStart(2, '0');
const dateString = `${year}${month}${date}`;
const certificateNo = `CERT/${dateString}/PRJ${id_project}/USR${actor.id}`;
const certificateData = {
id_project: parseInt(id_project),
id_user: parseInt(actor.id),
certificate_no: certificateNo,
issued_at: now,
};
const certificate = await certificateRepository.create(certificateData);
return data(this.mapCertificate(certificate));
} catch (err) {
return error(err);
}
}
async getMyCertificates(actor) {
try {
if (!actor?.id) {
return error(new BadRequestError('User ID is required'));
}
if (actor.role !== 'intern') {
return error(
new ForbiddenError('Access denied: only interns can view their certificates'),
);
}
const certificates = await certificateRepository.findUserCertificates(actor.id);
return data(certificates.map((cert) => this.mapCertificate(cert)));
} catch (err) {
return error(err);
}
}
async getCertificateDetail(id, actor) {
try {
if (!actor?.id) {
return error(new BadRequestError('User ID is required'));
}
const certificate = await certificateRepository.findById(id);
if (!certificate) {
return error(new NotFoundError('Certificate not found'));
}
if (actor.role === 'intern' && certificate.id_user !== actor.id) {
return error(
new ForbiddenError("Access denied: you cannot view other intern's certificates"),
);
}
return data(this.mapCertificate(certificate));
} catch (err) {
return error(err);
}
}
async getProjectCertificates(id_project, actor) {
try {
if (!actor?.id) {
return error(new BadRequestError('Admin ID is required'));
}
if (actor.role !== 'admin') {
return error(
new ForbiddenError('Access denied: only admins/mentors can view project certificates'),
);
}
const certificates = await certificateRepository.findProjectCertificates(id_project);
return data(certificates.map((cert) => this.mapCertificate(cert)));
} catch (err) {
return error(err);
}
}
async getAllCertificates(actor) {
try {
if (!actor?.id) {
return error(new BadRequestError('Admin ID is required'));
}
if (actor.role !== 'admin') {
return error(
new ForbiddenError('Access denied: only admins/mentors can view all certificates'),
);
}
const certificates = await certificateRepository.findAll();
return data(certificates.map((cert) => this.mapCertificate(cert)));
} catch (err) {
return error(err);
}
}
async verifyCertificate(certificate_no) {
try {
if (!certificate_no) {
return error(new BadRequestError('Certificate number is required'));
}
const certificate = await certificateRepository.findByCertificateNo(certificate_no);
if (!certificate) {
return error(new NotFoundError('Certificate not found or invalid'));
}
return data(this.mapCertificate(certificate));
} catch (err) {
return error(err);
}
}
async verifyCertificateByUuid(uuid) {
try {
if (!uuid) {
return error(new BadRequestError('Certificate UUID is required'));
}
const certificate = await certificateRepository.findByUuid(uuid);
if (!certificate) {
return error(new NotFoundError('Certificate not found or invalid'));
}
return data({
uuid: certificate.uuid,
intern_name: certificate.user ? certificate.user.full_name : null,
project_name: certificate.project ? certificate.project.project_name : null,
created_at: certificate.issued_at,
});
} catch (err) {
return error(err);
}
}
mapCertificate(cert) {
if (!cert) return null;
return {
id: cert.id,
uuid: cert.uuid,
id_project: cert.id_project,
id_user: cert.id_user,
certificate_no: cert.certificate_no,
issued_at: cert.issued_at,
user: cert.user ? {
id: cert.user.id,
full_name: cert.user.full_name,
email: cert.user.email,
} : null,
project: cert.project ? {
id: cert.project.id,
project_name: cert.project.project_name,
description: cert.project.description,
} : null,
};
}
}
module.exports = new CertificateService();
@@ -497,19 +497,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;
@@ -517,10 +514,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);
}
+369
View File
@@ -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;