feat: implementasi slug di module task

This commit is contained in:
Muhammad Zhafran Ilham
2026-06-23 22:40:41 +07:00
parent b630ebbe3c
commit 857b92d956
4 changed files with 218 additions and 32 deletions
@@ -85,7 +85,11 @@ class TaskController {
async getTaskById(req, res) {
try {
const actor = getActor(req);
const result = await taskService.getTaskById(req.params.id, actor);
const result = await taskService.getTaskById(
req.params.id,
actor,
req.query.project || req.query.id_project
);
if (result.err) {
return response(res, 'fail', result);
@@ -118,7 +122,12 @@ class TaskController {
}
const actor = getActor(req);
const result = await taskService.updateTask(req.params.id, validatePayload.data, actor);
const result = await taskService.updateTask(
req.params.id,
validatePayload.data,
actor,
req.query.project || req.query.id_project
);
if (result.err) {
return response(res, 'fail', result);
@@ -139,7 +148,11 @@ class TaskController {
async deleteTask(req, res) {
try {
const actor = getActor(req);
const result = await taskService.deleteTask(req.params.id, actor);
const result = await taskService.deleteTask(
req.params.id,
actor,
req.query.project || req.query.id_project
);
if (result.err) {
return response(res, 'fail', result);
@@ -178,7 +191,8 @@ class TaskController {
req.params.id,
validatePayload.data,
req.file,
actor
actor,
req.query.project || req.query.id_project
);
if (result.err) {
@@ -59,6 +59,8 @@ class TaskRepository {
project: {
select: {
id: true,
id_admin: true,
slug: true,
project_name: true,
project_icon: true,
members: {
@@ -194,6 +196,100 @@ class TaskRepository {
},
});
}
async findProjectByIdOrSlug(identifier) {
if (/^\d+$/.test(String(identifier))) {
return this.findProjectById(identifier);
}
return prisma.project.findUnique({
where: {
slug: identifier,
},
include: {
members: {
where: {
status: 'active',
},
select: {
id: true,
id_user: true,
},
},
},
});
}
async findTaskBySlug(idProject, slug) {
return prisma.task.findUnique({
where: {
id_project_slug: {
id_project: parseInt(idProject),
slug,
},
},
include: {
project: {
select: {
id: true,
id_admin: true,
slug: 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 findTaskSlugExists(idProject, slug, excludeTaskId = null) {
const where = {
id_project: parseInt(idProject),
slug,
};
if (excludeTaskId) {
where.id = {
not: parseInt(excludeTaskId),
};
}
const task = await prisma.task.findFirst({
where,
select: {
id: true,
},
});
return Boolean(task);
}
}
module.exports = new TaskRepository();
+71 -22
View File
@@ -11,6 +11,7 @@ const {
ForbiddenError,
ConflictError,
} = require('../../../helpers/error');
const { slugify } = require('../../../helpers/utils/slug');
class TaskService {
async createTask(idProject, payload, actor = {}) {
@@ -23,12 +24,14 @@ class TaskService {
return error(new ForbiddenError('Access denied: only admin or mentor can create task'));
}
const project = await taskRepository.findProjectById(idProject);
const project = await taskRepository.findProjectByIdOrSlug(idProject);
if (!project) {
return error(new NotFoundError('Project not found'));
}
const taskSlug = await this.generateUniqueTaskSlug(project.id, payload.title);
const accessError = await this.validateProjectAccess(idProject, actor);
if (accessError) {
@@ -39,6 +42,7 @@ class TaskService {
const task = await taskRepository.createTask({
id_project: parseInt(idProject),
slug: taskSlug,
title: payload.title,
description: payload.description,
deadline_at: deadlineAt,
@@ -46,7 +50,7 @@ class TaskService {
});
await notificationService.createNotificationForProjectInterns(
idProject,
project.id,
"Tugas Baru Ditambahkan",
`Mentor telah membuat tugas baru: '${task.title}' di proyek '${project.project_name}'.`
).catch(err => console.error("Failed to send task creation notifications:", err));
@@ -63,19 +67,19 @@ class TaskService {
return error(new BadRequestError('Project ID must be a valid number'));
}
const project = await taskRepository.findProjectById(idProject);
const project = await taskRepository.findProjectByIdOrSlug(idProject);
if (!project) {
return error(new NotFoundError('Project not found'));
}
const accessError = await this.validateProjectAccess(idProject, actor);
const accessError = await this.validateProjectAccess(project.id, actor);
if (accessError) {
return error(accessError);
}
const tasks = await taskRepository.findTasksByProject(idProject);
const tasks = await taskRepository.findTasksByProject(project.id);
const totalMembers = project.members?.length || 0;
const mappedTasks = tasks.map((task) => {
@@ -104,13 +108,13 @@ class TaskService {
}
}
async getTaskById(idTask, actor = {}) {
async getTaskById(idTask, actor = {}, projectIdentifier = null) {
try {
if (!this.isPositiveInteger(idTask)) {
return error(new BadRequestError('Task ID must be a valid number'));
if (!idTask) {
return error(new BadRequestError('Task ID or slug is required'));
}
const task = await taskRepository.findTaskById(idTask);
const task = await this.resolveTask(idTask, projectIdentifier);
if (!task) {
return error(new NotFoundError('Task not found'));
@@ -136,7 +140,7 @@ class TaskService {
}
}
async updateTask(idTask, payload, actor = {}) {
async updateTask(idTask, payload, actor = {}, projectIdentifier = null) {
try {
if (!this.isPositiveInteger(idTask)) {
return error(new BadRequestError('Task ID must be a valid number'));
@@ -150,7 +154,7 @@ class TaskService {
return error(new BadRequestError('At least one field is required to update task'));
}
const existingTask = await taskRepository.findTaskById(idTask);
const existingTask = await this.resolveTask(idTask, projectIdentifier);
if (!existingTask) {
return error(new NotFoundError('Task not found'));
@@ -162,7 +166,7 @@ class TaskService {
return error(accessError);
}
const submissionCount = await taskRepository.countSubmissionsByTask(idTask);
const submissionCount = await taskRepository.countSubmissionsByTask(existingTask.id);
if (
payload.submission_type &&
@@ -176,7 +180,17 @@ class TaskService {
const updateData = {};
if (payload.title !== undefined) updateData.title = payload.title;
if (payload.title !== undefined) {
updateData.title = payload.title;
if (payload.title !== existingTask.title) {
updateData.slug = await this.generateUniqueTaskSlug(
existingTask.id_project,
payload.title,
existingTask.id
);
}
}
if (payload.description !== undefined) updateData.description = payload.description;
if (payload.submission_type !== undefined) updateData.submission_type = payload.submission_type;
@@ -188,7 +202,7 @@ class TaskService {
);
}
const updatedTask = await taskRepository.updateTask(idTask, updateData);
const updatedTask = await taskRepository.updateTask(existingTask.id, updateData);
return data(this.mapTask(updatedTask));
} catch (err) {
@@ -196,7 +210,7 @@ class TaskService {
}
}
async deleteTask(idTask, actor = {}) {
async deleteTask(idTask, actor = {}, projectIdentifier = null) {
try {
if (!this.isPositiveInteger(idTask)) {
return error(new BadRequestError('Task ID must be a valid number'));
@@ -206,7 +220,7 @@ class TaskService {
return error(new ForbiddenError('Access denied: only admin or mentor can delete task'));
}
const task = await taskRepository.findTaskById(idTask);
const task = await this.resolveTask(idTask, projectIdentifier);
if (!task) {
return error(new NotFoundError('Task not found'));
@@ -218,13 +232,13 @@ class TaskService {
return error(accessError);
}
const submissionCount = await taskRepository.countSubmissionsByTask(idTask);
const submissionCount = await taskRepository.countSubmissionsByTask(task.id);
if (submissionCount > 0) {
return error(new ConflictError('Task already has submissions and cannot be deleted'));
}
await taskRepository.deleteTask(idTask);
await taskRepository.deleteTask(task.id);
return data(null);
} catch (err) {
@@ -232,7 +246,7 @@ class TaskService {
}
}
async submitTask(idTask, payload, file, actor = {}) {
async submitTask(idTask, payload, file, actor = {}, projectIdentifier = null) {
try {
if (!this.isPositiveInteger(idTask)) {
return error(new BadRequestError('Task ID must be a valid number'));
@@ -242,7 +256,7 @@ class TaskService {
return error(new ForbiddenError('Access denied: only intern can submit task'));
}
const task = await taskRepository.findTaskById(idTask);
const task = await this.resolveTask(idTask, projectIdentifier);
if (!task) {
return error(new NotFoundError('Task not found'));
@@ -255,7 +269,7 @@ class TaskService {
}
const submissionData = {
id_task: parseInt(idTask),
id_task: parseInt(task.id),
id_user: parseInt(actor.id),
};
@@ -285,7 +299,7 @@ class TaskService {
submissionData.url_link = payload.url_link;
}
const existingSubmission = await taskRepository.findSubmissionByTaskAndUser(idTask, actor.id);
const existingSubmission = await taskRepository.findSubmissionByTaskAndUser(task.id, actor.id);
let submission;
@@ -518,6 +532,7 @@ class TaskService {
return {
id: task.id,
id_project: task.id_project,
slug: task.slug,
title: task.title,
description: task.description,
deadline_at: task.deadline_at,
@@ -546,6 +561,7 @@ class TaskService {
...this.mapTask(task),
project: {
id: task.project?.id,
slug: task.project?.slug,
project_name: task.project?.project_name,
project_icon: task.project?.project_icon,
},
@@ -586,6 +602,7 @@ class TaskService {
...this.mapTask(task),
project: {
id: task.project?.id,
slug: task.project?.slug,
project_name: task.project?.project_name,
project_icon: task.project?.project_icon,
},
@@ -625,6 +642,38 @@ class TaskService {
const number = Number(value);
return Number.isInteger(number) && number > 0;
}
async generateUniqueTaskSlug(idProject, title, excludeTaskId = null) {
const baseSlug = slugify(title, 'task');
let slug = baseSlug;
let counter = 2;
while (await taskRepository.findTaskSlugExists(idProject, slug, excludeTaskId)) {
slug = `${baseSlug}-${counter}`;
counter += 1;
}
return slug;
}
async resolveTask(identifier, projectIdentifier = null) {
if (/^\d+$/.test(String(identifier))) {
return taskRepository.findTaskById(identifier);
}
if (!projectIdentifier) {
return null;
}
const project = await taskRepository.findProjectByIdOrSlug(projectIdentifier);
if (!project) {
return null;
}
return taskRepository.findTaskBySlug(project.id, identifier);
}
}
module.exports = new TaskService();