Merge pull request #29 from internship-humic/feat/complete-project
feat/complete project
This commit is contained in:
@@ -163,6 +163,29 @@ class ProjectController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async completeProject(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const actor = getActor(req);
|
||||||
|
|
||||||
|
const result = await projectService.completeProject(id, actor);
|
||||||
|
|
||||||
|
if (result.err) {
|
||||||
|
return response(res, 'fail', result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, 'success', result, 'Project completed successfully', SUCCESS.OK);
|
||||||
|
} catch (err) {
|
||||||
|
return response(
|
||||||
|
res,
|
||||||
|
'fail',
|
||||||
|
error(new InternalServerError(err.message)),
|
||||||
|
'Unexpected error occurred',
|
||||||
|
ERROR.INTERNAL_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getMyProjects(req, res) {
|
async getMyProjects(req, res) {
|
||||||
try {
|
try {
|
||||||
const actor = getActor(req);
|
const actor = getActor(req);
|
||||||
|
|||||||
@@ -80,11 +80,6 @@ const updateProjectModel = joi.object().keys({
|
|||||||
end_date: joi.date().optional().messages({
|
end_date: joi.date().optional().messages({
|
||||||
'date.base': 'End date must be a valid date.',
|
'date.base': 'End date must be a valid date.',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
status: joi.string().valid(...PROJECT_STATUS_VALUES).optional().messages({
|
|
||||||
'string.base': 'Project status must be a string.',
|
|
||||||
'any.only': `Project status must be one of: ${PROJECT_STATUS_VALUES.join(', ')}.`,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getAllProjectsModel = joi.object().keys({
|
const getAllProjectsModel = joi.object().keys({
|
||||||
|
|||||||
@@ -151,6 +151,18 @@ class ProjectRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async completeById(id) {
|
||||||
|
return prisma.project.update({
|
||||||
|
where: {
|
||||||
|
id: parseInt(id),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
include: this.projectDetailInclude(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async checkActiveMember(idProject, idUser) {
|
async checkActiveMember(idProject, idUser) {
|
||||||
return prisma.projectMember.findFirst({
|
return prisma.projectMember.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -259,6 +259,57 @@ class ProjectService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async completeProject(id, actor = {}) {
|
||||||
|
try {
|
||||||
|
if (!actor?.id) {
|
||||||
|
return error(new BadRequestError("Admin ID is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor.role !== "admin" && actor.role !== "mentor") {
|
||||||
|
return error(
|
||||||
|
new ForbiddenError("Access denied: only admin or mentor can complete project"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingProject = await projectRepository.findByIdOrSlug(id);
|
||||||
|
|
||||||
|
if (!existingProject) {
|
||||||
|
return error(new NotFoundError("Project not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessError = await this.validateProjectAccess(existingProject.id, actor);
|
||||||
|
if (accessError) {
|
||||||
|
return error(accessError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingProject.status === "completed") {
|
||||||
|
return data(this.mapProjectDetail(existingProject));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingProject.status === "archived") {
|
||||||
|
return error(new ConflictError("Archived project cannot be marked as completed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingProject.status !== "active") {
|
||||||
|
return error(new ConflictError("Only active project can be marked as completed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedProject = await projectRepository.completeById(existingProject.id);
|
||||||
|
|
||||||
|
await notificationService.createNotificationForProjectInterns(
|
||||||
|
completedProject.id,
|
||||||
|
"Proyek Selesai",
|
||||||
|
`Proyek '${completedProject.project_name}' telah ditandai selesai.`
|
||||||
|
).catch((err) =>
|
||||||
|
console.error("Failed to send project completion notifications:", err)
|
||||||
|
);
|
||||||
|
|
||||||
|
return data(this.mapProjectDetail(completedProject));
|
||||||
|
} catch (err) {
|
||||||
|
return error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async validateProjectAccess(idProject, actor = {}) {
|
async validateProjectAccess(idProject, actor = {}) {
|
||||||
if (!actor?.id || !actor?.role) {
|
if (!actor?.id || !actor?.role) {
|
||||||
return new ForbiddenError(
|
return new ForbiddenError(
|
||||||
@@ -689,6 +740,9 @@ class ProjectService {
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
return error(new NotFoundError("Project not found"));
|
return error(new NotFoundError("Project not found"));
|
||||||
}
|
}
|
||||||
|
if (project.status !== "active") {
|
||||||
|
return error(new ConflictError("Cannot remove member from a non-active project"));
|
||||||
|
}
|
||||||
|
|
||||||
const user = await projectRepository.findUserByIdAndActive(id_user);
|
const user = await projectRepository.findUserByIdAndActive(id_user);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class TaskRepository {
|
|||||||
slug: true,
|
slug: true,
|
||||||
project_name: true,
|
project_name: true,
|
||||||
project_icon: true,
|
project_icon: true,
|
||||||
|
status: true,
|
||||||
members: {
|
members: {
|
||||||
where: {
|
where: {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -159,6 +160,7 @@ class TaskRepository {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
project_name: true,
|
project_name: true,
|
||||||
|
status: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -236,6 +238,7 @@ class TaskRepository {
|
|||||||
slug: true,
|
slug: true,
|
||||||
project_name: true,
|
project_name: true,
|
||||||
project_icon: true,
|
project_icon: true,
|
||||||
|
status: true,
|
||||||
members: {
|
members: {
|
||||||
where: {
|
where: {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ class TaskService {
|
|||||||
return error(accessError);
|
return error(accessError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockError = this.validateProjectIsActive(project, 'create task');
|
||||||
|
if (lockError) {
|
||||||
|
return error(lockError);
|
||||||
|
}
|
||||||
|
|
||||||
const taskSlug = await this.generateUniqueTaskSlug(project.id, payload.title);
|
const taskSlug = await this.generateUniqueTaskSlug(project.id, payload.title);
|
||||||
const deadlineAt = this.buildDeadlineAt(payload.deadline_date, payload.specific_time);
|
const deadlineAt = this.buildDeadlineAt(payload.deadline_date, payload.specific_time);
|
||||||
|
|
||||||
@@ -159,11 +164,15 @@ class TaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accessError = await this.validateProjectAccess(existingTask.id_project, actor);
|
const accessError = await this.validateProjectAccess(existingTask.id_project, actor);
|
||||||
|
|
||||||
if (accessError) {
|
if (accessError) {
|
||||||
return error(accessError);
|
return error(accessError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockError = this.validateProjectIsActive(existingTask.project, 'update task');
|
||||||
|
if (lockError) {
|
||||||
|
return error(lockError);
|
||||||
|
}
|
||||||
|
|
||||||
const submissionCount = await taskRepository.countSubmissionsByTask(existingTask.id);
|
const submissionCount = await taskRepository.countSubmissionsByTask(existingTask.id);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -225,11 +234,15 @@ class TaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accessError = await this.validateProjectAccess(task.id_project, actor);
|
const accessError = await this.validateProjectAccess(task.id_project, actor);
|
||||||
|
|
||||||
if (accessError) {
|
if (accessError) {
|
||||||
return error(accessError);
|
return error(accessError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockError = this.validateProjectIsActive(task.project, 'delete task');
|
||||||
|
if (lockError) {
|
||||||
|
return error(lockError);
|
||||||
|
}
|
||||||
|
|
||||||
const submissionCount = await taskRepository.countSubmissionsByTask(task.id);
|
const submissionCount = await taskRepository.countSubmissionsByTask(task.id);
|
||||||
|
|
||||||
if (submissionCount > 0) {
|
if (submissionCount > 0) {
|
||||||
@@ -255,11 +268,15 @@ class TaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const task = await this.resolveTask(idTask, projectIdentifier);
|
const task = await this.resolveTask(idTask, projectIdentifier);
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
return error(new NotFoundError('Task not found'));
|
return error(new NotFoundError('Task not found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockError = this.validateProjectIsActive(task.project, 'submit task');
|
||||||
|
if (lockError) {
|
||||||
|
return error(lockError);
|
||||||
|
}
|
||||||
|
|
||||||
const membership = await taskRepository.checkActiveProjectMember(task.id_project, actor.id);
|
const membership = await taskRepository.checkActiveProjectMember(task.id_project, actor.id);
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
@@ -334,17 +351,14 @@ class TaskService {
|
|||||||
if (!this.isPositiveInteger(idSubmission)) {
|
if (!this.isPositiveInteger(idSubmission)) {
|
||||||
return error(new BadRequestError('Submission ID must be a valid number'));
|
return error(new BadRequestError('Submission ID must be a valid number'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!actor?.id || actor.role !== 'intern') {
|
if (!actor?.id || actor.role !== 'intern') {
|
||||||
return error(new ForbiddenError('Access denied: only intern can update submission'));
|
return error(new ForbiddenError('Access denied: only intern can update submission'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const submission = await taskRepository.findSubmissionById(idSubmission);
|
const submission = await taskRepository.findSubmissionById(idSubmission);
|
||||||
|
|
||||||
if (!submission) {
|
if (!submission) {
|
||||||
return error(new NotFoundError('Submission not found'));
|
return error(new NotFoundError('Submission not found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submission.id_user !== parseInt(actor.id)) {
|
if (submission.id_user !== parseInt(actor.id)) {
|
||||||
return error(new ForbiddenError('Access denied: you can only update your own submission'));
|
return error(new ForbiddenError('Access denied: you can only update your own submission'));
|
||||||
}
|
}
|
||||||
@@ -353,11 +367,18 @@ class TaskService {
|
|||||||
submission.task.id_project,
|
submission.task.id_project,
|
||||||
actor.id
|
actor.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
return error(new ForbiddenError('Access denied: you are not a member of this project'));
|
return error(new ForbiddenError('Access denied: you are not a member of this project'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockError = this.validateProjectIsActive(
|
||||||
|
submission.task.project,
|
||||||
|
'update submission'
|
||||||
|
);
|
||||||
|
if (lockError) {
|
||||||
|
return error(lockError);
|
||||||
|
}
|
||||||
|
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
|
|
||||||
if (submission.task.submission_type === 'file_upload') {
|
if (submission.task.submission_type === 'file_upload') {
|
||||||
@@ -404,17 +425,14 @@ class TaskService {
|
|||||||
if (!this.isPositiveInteger(idSubmission)) {
|
if (!this.isPositiveInteger(idSubmission)) {
|
||||||
return error(new BadRequestError('Submission ID must be a valid number'));
|
return error(new BadRequestError('Submission ID must be a valid number'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!actor?.id || actor.role !== 'intern') {
|
if (!actor?.id || actor.role !== 'intern') {
|
||||||
return error(new ForbiddenError('Access denied: only intern can delete submission'));
|
return error(new ForbiddenError('Access denied: only intern can delete submission'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const submission = await taskRepository.findSubmissionById(idSubmission);
|
const submission = await taskRepository.findSubmissionById(idSubmission);
|
||||||
|
|
||||||
if (!submission) {
|
if (!submission) {
|
||||||
return error(new NotFoundError('Submission not found'));
|
return error(new NotFoundError('Submission not found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submission.id_user !== parseInt(actor.id)) {
|
if (submission.id_user !== parseInt(actor.id)) {
|
||||||
return error(new ForbiddenError('Access denied: you can only delete your own submission'));
|
return error(new ForbiddenError('Access denied: you can only delete your own submission'));
|
||||||
}
|
}
|
||||||
@@ -423,11 +441,18 @@ class TaskService {
|
|||||||
submission.task.id_project,
|
submission.task.id_project,
|
||||||
actor.id
|
actor.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
return error(new ForbiddenError('Access denied: you are not a member of this project'));
|
return error(new ForbiddenError('Access denied: you are not a member of this project'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockError = this.validateProjectIsActive(
|
||||||
|
submission.task.project,
|
||||||
|
'delete submission'
|
||||||
|
);
|
||||||
|
if (lockError) {
|
||||||
|
return error(lockError);
|
||||||
|
}
|
||||||
|
|
||||||
await taskRepository.deleteSubmission(idSubmission);
|
await taskRepository.deleteSubmission(idSubmission);
|
||||||
|
|
||||||
this.deleteLocalSubmissionFile(submission.file_path);
|
this.deleteLocalSubmissionFile(submission.file_path);
|
||||||
@@ -470,6 +495,20 @@ class TaskService {
|
|||||||
return new ForbiddenError('Access denied: invalid role');
|
return new ForbiddenError('Access denied: invalid role');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateProjectIsActive(project, action = 'modify this project') {
|
||||||
|
if (!project) {
|
||||||
|
return new NotFoundError('Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.status !== 'active') {
|
||||||
|
return new ConflictError(
|
||||||
|
`Cannot ${action} because this project is already ${project.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
isMentorOrAdmin(actor) {
|
isMentorOrAdmin(actor) {
|
||||||
return actor?.role === 'admin' || actor?.role === 'mentor';
|
return actor?.role === 'admin' || actor?.role === 'mentor';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,10 +101,6 @@ const router = express.Router();
|
|||||||
* type: string
|
* type: string
|
||||||
* format: date
|
* format: date
|
||||||
* example: 2026-08-20
|
* example: 2026-08-20
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* enum: [active, completed, archived]
|
|
||||||
* example: completed
|
|
||||||
* ProjectAdminSummary:
|
* ProjectAdminSummary:
|
||||||
* type: object
|
* type: object
|
||||||
* properties:
|
* properties:
|
||||||
@@ -730,6 +726,45 @@ router.patch('/update/:id', verifyJWT, isMentorOrAdmin, projectController.update
|
|||||||
*/
|
*/
|
||||||
router.delete('/delete/:id', verifyJWT, isMentorOrAdmin, projectController.archiveProject);
|
router.delete('/delete/:id', verifyJWT, isMentorOrAdmin, projectController.archiveProject);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /project-api/complete/{id}:
|
||||||
|
* patch:
|
||||||
|
* summary: Mark project as completed
|
||||||
|
* description: Mark an active project as completed. Completed projects are locked, so tasks and submissions can no longer be modified. The path parameter supports project ID or slug.
|
||||||
|
* tags: [Project]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* schema:
|
||||||
|
* oneOf:
|
||||||
|
* - type: integer
|
||||||
|
* - type: string
|
||||||
|
* required: true
|
||||||
|
* description: Project ID or slug
|
||||||
|
* example: internify-platform-dev
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Project completed successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ProjectDetailResponse'
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
* 403:
|
||||||
|
* description: Forbidden
|
||||||
|
* 404:
|
||||||
|
* description: Project not found
|
||||||
|
* 409:
|
||||||
|
* description: Project cannot be completed
|
||||||
|
* 500:
|
||||||
|
* description: Internal server error
|
||||||
|
*/
|
||||||
|
router.patch('/complete/:id', verifyJWT, isMentorOrAdmin, projectController.completeProject);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /project-api/my-projects:
|
* /project-api/my-projects:
|
||||||
|
|||||||
Reference in New Issue
Block a user