From 857b92d95655d2a11173af4874788a911dc425e7 Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Tue, 23 Jun 2026 22:40:41 +0700 Subject: [PATCH] feat: implementasi slug di module task --- prisma/seed.js | 39 ++++++-- .../task/controllers/task.controller.js | 22 ++++- .../task/repositories/task.repository.js | 96 +++++++++++++++++++ src/modules/task/services/task.service.js | 93 +++++++++++++----- 4 files changed, 218 insertions(+), 32 deletions(-) diff --git a/prisma/seed.js b/prisma/seed.js index c6a691f..4640562 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -151,13 +151,13 @@ async function main() { // 10. Project const proj1 = await prisma.project.create({ - data: { id_admin: admin1.id, project_icon: 'code', project_name: 'Internify Platform Dev', slug: 'internify-platform-dev', description: 'Upgrading the core Internify platform backend and web client.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), status: 'active' } + data: { id_admin: admin1.id, project_icon: 'code', project_name: 'Internify Platform Dev', slug: slugify('Internify Platform Dev', 'Project 1 slug'), description: 'Upgrading the core Internify platform backend and web client.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), status: 'active' } }); const proj2 = await prisma.project.create({ - data: { id_admin: admin2.id, project_icon: 'shield', project_name: 'Auth Gateway Module', slug: 'auth-gateway-module', description: 'Securing API gateway with unified OAuth2 authentication.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), status: 'active' } + data: { id_admin: admin2.id, project_icon: 'shield', project_name: 'Auth Gateway Module', slug: slugify('Auth Gateway Module', 'Project 2 slug'), description: 'Securing API gateway with unified OAuth2 authentication.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), status: 'active' } }); const proj3 = await prisma.project.create({ - data: { id_admin: admin1.id, project_icon: 'chart', project_name: 'Recruitment Analytics Dashboard', slug: 'recruitment-analytics-dashboard', description: 'Developing analytical charts for recruitment metrics.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), status: 'active' } + data: { id_admin: admin1.id, project_icon: 'chart', project_name: 'Recruitment Analytics Dashboard', slug: slugify('Recruitment Analytics Dashboard', 'Project 3 slug'), description: 'Developing analytical charts for recruitment metrics.', start_date: new Date('2026-07-15'), end_date: new Date('2026-10-15'), status: 'active' } }); // 11. LamaranMagang @@ -173,13 +173,13 @@ async function main() { // 12. Task const task1 = await prisma.task.create({ - data: { id_project: proj1.id, title: 'Database Setup', slug: 'database-setup', description: 'Setup database schema and write initial seed script.', deadline_at: new Date('2026-08-01'), submission_type: 'url_link' } + data: { id_project: proj1.id, title: 'Database Setup', slug: slugify('Database Setup', 'Task 1 slug'), description: 'Setup database schema and write initial seed script.', deadline_at: new Date('2026-08-01'), submission_type: 'url_link' } }); const task2 = await prisma.task.create({ - data: { id_project: proj1.id, title: 'API Specification', slug: 'api-specification', description: 'Draft comprehensive swagger API document.', deadline_at: new Date('2026-08-15'), submission_type: 'file_upload' } + data: { id_project: proj1.id, title: 'API Specification', slug: slugify('API Specification', 'Task 2 slug'), description: 'Draft comprehensive swagger API document.', deadline_at: new Date('2026-08-15'), submission_type: 'file_upload' } }); const task3 = await prisma.task.create({ - data: { id_project: proj2.id, title: 'Implement JWT Validation', slug: 'implement-jwt-validation', description: 'Setup middleware and keys for JWT validation.', deadline_at: new Date('2026-08-10'), submission_type: 'url_link' } + data: { id_project: proj2.id, title: 'Implement JWT Validation', slug: slugify('Implement JWT Validation', 'Task 3 slug'), description: 'Setup middleware and keys for JWT validation.', deadline_at: new Date('2026-08-10'), submission_type: 'url_link' } }); // 13. User @@ -212,6 +212,33 @@ async function main() { }); } +function slugify(value, fallback = 'item') { + if (!value || typeof value !== 'string') { + return fallback; + } + + let slug = value + .toString() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + if (!slug) { + slug = fallback; + } + + if (/^\d+$/.test(slug)) { + slug = `${fallback}-${slug}`; + } + + return slug; +} + main() .catch((e) => { console.error('Error seeding data:', e); diff --git a/src/modules/task/controllers/task.controller.js b/src/modules/task/controllers/task.controller.js index 58e04a8..4e14bda 100644 --- a/src/modules/task/controllers/task.controller.js +++ b/src/modules/task/controllers/task.controller.js @@ -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) { diff --git a/src/modules/task/repositories/task.repository.js b/src/modules/task/repositories/task.repository.js index 28e5d24..db22cfb 100644 --- a/src/modules/task/repositories/task.repository.js +++ b/src/modules/task/repositories/task.repository.js @@ -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(); diff --git a/src/modules/task/services/task.service.js b/src/modules/task/services/task.service.js index 257dc86..f650c95 100644 --- a/src/modules/task/services/task.service.js +++ b/src/modules/task/services/task.service.js @@ -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();