From b630ebbe3c92f3a1e4bcc991b4ce4dd1e725a71d Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Tue, 23 Jun 2026 22:07:21 +0700 Subject: [PATCH 1/5] feat: menambahkan kolom slug dan implement slug di module project --- .../migration.sql | 23 ++++++++++ prisma/schema.prisma | 4 ++ prisma/seed.js | 12 +++--- src/helpers/utils/slug.js | 30 +++++++++++++ .../repositories/project.repository.js | 38 +++++++++++++++++ .../project/services/project.service.js | 42 ++++++++++++++++--- 6 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20260623144620_add_project_task_slug/migration.sql create mode 100644 src/helpers/utils/slug.js diff --git a/prisma/migrations/20260623144620_add_project_task_slug/migration.sql b/prisma/migrations/20260623144620_add_project_task_slug/migration.sql new file mode 100644 index 0000000..92813b1 --- /dev/null +++ b/prisma/migrations/20260623144620_add_project_task_slug/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - A unique constraint covering the columns `[slug]` on the table `project` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[id_project,slug]` on the table `task` will be added. If there are existing duplicate values, this will fail. + - Added the required column `slug` to the `project` table without a default value. This is not possible if the table is not empty. + - Added the required column `slug` to the `task` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE `project` ADD COLUMN `slug` VARCHAR(255) NOT NULL; + +-- AlterTable +ALTER TABLE `task` ADD COLUMN `slug` VARCHAR(255) NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX `project_slug_key` ON `project`(`slug`); + +-- CreateIndex +CREATE INDEX `task_slug_idx` ON `task`(`slug`); + +-- CreateIndex +CREATE UNIQUE INDEX `task_id_project_slug_key` ON `task`(`id_project`, `slug`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c0d5fcb..0b9799d 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -205,6 +205,7 @@ model User { model Project { id Int @id @default(autoincrement()) id_admin Int //mentor project + slug String @unique @db.VarChar(255) project_icon ProjectIcon @default(code) project_name String @db.VarChar(255) description String @db.Text @@ -245,6 +246,7 @@ model ProjectMember { model Task { id Int @id @default(autoincrement()) id_project Int + slug String @db.VarChar(255) title String @db.VarChar(255) description String @db.Text deadline_at DateTime @@ -255,8 +257,10 @@ model Task { project Project @relation(fields: [id_project], references: [id], map: "task_id_project_foreign") submissions TaskSubmission[] + @@unique([id_project, slug]) @@index([id_project], map: "task_id_project_foreign") @@index([deadline_at]) + @@index([slug]) @@map("task") } diff --git a/prisma/seed.js b/prisma/seed.js index 93a6b2b..c6a691f 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', 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: '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' } }); const proj2 = await prisma.project.create({ - data: { id_admin: admin2.id, project_icon: 'shield', project_name: '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: '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' } }); const proj3 = await prisma.project.create({ - data: { id_admin: admin1.id, project_icon: 'chart', project_name: '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: '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' } }); // 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', 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: 'database-setup', 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', 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: 'api-specification', 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', 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: 'implement-jwt-validation', description: 'Setup middleware and keys for JWT validation.', deadline_at: new Date('2026-08-10'), submission_type: 'url_link' } }); // 13. User diff --git a/src/helpers/utils/slug.js b/src/helpers/utils/slug.js new file mode 100644 index 0000000..a82fb56 --- /dev/null +++ b/src/helpers/utils/slug.js @@ -0,0 +1,30 @@ +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; +} + +module.exports = { + slugify, +}; diff --git a/src/modules/project/repositories/project.repository.js b/src/modules/project/repositories/project.repository.js index f17dad8..94193cd 100644 --- a/src/modules/project/repositories/project.repository.js +++ b/src/modules/project/repositories/project.repository.js @@ -420,6 +420,44 @@ class ProjectRepository { }, }); } + + async findBySlug(slug) { + return prisma.project.findUnique({ + where: { + slug, + }, + include: this.projectDetailInclude(), + }); + } + + async findByIdOrSlug(identifier) { + if (/^\d+$/.test(String(identifier))) { + return this.findById(identifier); + } + + return this.findBySlug(identifier); + } + + async findSlugExists(slug, excludeProjectId = null) { + const where = { + slug, + }; + + if (excludeProjectId) { + where.id = { + not: parseInt(excludeProjectId), + }; + } + + const project = await prisma.project.findFirst({ + where, + select: { + id: true, + }, + }); + + return Boolean(project); + } } module.exports = new ProjectRepository(); diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index 9f431d3..c9b9512 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -7,6 +7,7 @@ const { ForbiddenError, ConflictError, } = require("../../../helpers/error"); +const { slugify } = require("../../../helpers/utils/slug"); class ProjectService { async createProject(projectPayload, actor) { @@ -72,8 +73,11 @@ class ProjectService { } } + const projectSlug = await this.generateUniqueProjectSlug(projectData.project_name); + const newProjectData = { id_admin: parseInt(actor.id), + slug: projectSlug, project_icon: projectData.project_icon, project_name: projectData.project_name, description: projectData.description || "", @@ -110,6 +114,7 @@ class ProjectService { const mappedProjects = projects.map((project) => ({ id: project.id, + slug: project.slug, project_icon: project.project_icon, project_name: project.project_name, description: project.description, @@ -131,7 +136,7 @@ class ProjectService { async getProjectById(id, actor = {}) { try { - const project = await projectRepository.findById(id); + const project = await projectRepository.findByIdOrSlug(id); if (!project) { return error(new NotFoundError("Project not found")); @@ -160,7 +165,7 @@ class ProjectService { ); } - const existingProject = await projectRepository.findById(id); + const existingProject = await projectRepository.findByIdOrSlug(id); if (!existingProject) { return error(new NotFoundError("Project not found")); @@ -193,7 +198,17 @@ class ProjectService { updateData.description = ""; } - const updatedProject = await projectRepository.updateById(id, updateData); + if ( + updateData.project_name !== undefined && + updateData.project_name !== existingProject.project_name + ) { + updateData.slug = await this.generateUniqueProjectSlug( + updateData.project_name, + existingProject.id + ); + } + + const updatedProject = await projectRepository.updateById(existingProject.id, updateData); return data(this.mapProjectDetail(updatedProject)); } catch (err) { @@ -213,7 +228,7 @@ class ProjectService { ); } - const existingProject = await projectRepository.findById(id); + const existingProject = await projectRepository.findByIdOrSlug(id); if (!existingProject) { return error(new NotFoundError("Project not found")); @@ -223,7 +238,7 @@ class ProjectService { return data(this.mapProjectDetail(existingProject)); } - const archivedProject = await projectRepository.archiveById(id); + const archivedProject = await projectRepository.archiveById(existingProject.id); return data(this.mapProjectDetail(archivedProject)); } catch (err) { @@ -283,6 +298,7 @@ class ProjectService { return { id: project.id, + slug: project.slug, project_icon: project.project_icon, project_name: project.project_name, description: project.description, @@ -351,6 +367,7 @@ class ProjectService { const mappedProjects = projects.map((project) => ({ id: project.id, + slug: project.slug, project_icon: project.project_icon, project_name: project.project_name, description: project.description, @@ -430,6 +447,7 @@ class ProjectService { const mappedProjects = projects.map((project) => ({ id: project.id, + slug: project.slug, project_icon: project.project_icon, project_name: project.project_name, description: project.description, @@ -536,6 +554,20 @@ class ProjectService { return error(err); } } + + async generateUniqueProjectSlug(projectName, excludeProjectId = null) { + const baseSlug = slugify(projectName, 'project'); + + let slug = baseSlug; + let counter = 2; + + while (await projectRepository.findSlugExists(slug, excludeProjectId)) { + slug = `${baseSlug}-${counter}`; + counter += 1; + } + + return slug; + } } module.exports = new ProjectService(); From 857b92d95655d2a11173af4874788a911dc425e7 Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Tue, 23 Jun 2026 22:40:41 +0700 Subject: [PATCH 2/5] 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(); From 8aedb19ca10ead99b0d84a72007dec26437e0bab Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Tue, 23 Jun 2026 23:10:55 +0700 Subject: [PATCH 3/5] feat: menambahkan akses slug ke beberapa fungsi yang belum terimplementasi --- .../repositories/project.repository.js | 2 ++ .../project/services/project.service.js | 2 ++ src/modules/task/services/task.service.js | 28 +++++++++---------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/modules/project/repositories/project.repository.js b/src/modules/project/repositories/project.repository.js index 94193cd..eb75e4b 100644 --- a/src/modules/project/repositories/project.repository.js +++ b/src/modules/project/repositories/project.repository.js @@ -194,6 +194,7 @@ class ProjectRepository { tasks: { select: { id: true, + slug: true, title: true, description: true, deadline_at: true, @@ -268,6 +269,7 @@ class ProjectRepository { project: { select: { id: true, + slug: true, project_name: true, project_icon: true, }, diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index c9b9512..e9e7a11 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -405,7 +405,9 @@ class ProjectService { const submission = task.submissions?.[0] || null; return { id: task.id, + slug: task.slug, id_project: task.id_project, + project_slug: task.project?.slug || null, project_name: task.project?.project_name || null, project_icon: task.project?.project_icon || null, title: task.title, diff --git a/src/modules/task/services/task.service.js b/src/modules/task/services/task.service.js index f650c95..1f897f3 100644 --- a/src/modules/task/services/task.service.js +++ b/src/modules/task/services/task.service.js @@ -16,8 +16,8 @@ const { slugify } = require('../../../helpers/utils/slug'); class TaskService { async createTask(idProject, payload, actor = {}) { try { - if (!this.isPositiveInteger(idProject)) { - return error(new BadRequestError('Project ID must be a valid number')); + if (!idProject) { + return error(new BadRequestError('Project ID or slug is required')); } if (!actor?.id || !this.isMentorOrAdmin(actor)) { @@ -30,18 +30,16 @@ class TaskService { return error(new NotFoundError('Project not found')); } - const taskSlug = await this.generateUniqueTaskSlug(project.id, payload.title); - - const accessError = await this.validateProjectAccess(idProject, actor); - + const accessError = await this.validateProjectAccess(project.id, actor); if (accessError) { return error(accessError); } + const taskSlug = await this.generateUniqueTaskSlug(project.id, payload.title); const deadlineAt = this.buildDeadlineAt(payload.deadline_date, payload.specific_time); const task = await taskRepository.createTask({ - id_project: parseInt(idProject), + id_project: project.id, slug: taskSlug, title: payload.title, description: payload.description, @@ -63,8 +61,8 @@ class TaskService { async getTasksByProject(idProject, actor = {}) { try { - if (!this.isPositiveInteger(idProject)) { - return error(new BadRequestError('Project ID must be a valid number')); + if (!idProject) { + return error(new BadRequestError('Project ID or slug is required')); } const project = await taskRepository.findProjectByIdOrSlug(idProject); @@ -142,8 +140,8 @@ class TaskService { async updateTask(idTask, payload, 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')); } if (!actor?.id || !this.isMentorOrAdmin(actor)) { @@ -212,8 +210,8 @@ class TaskService { async deleteTask(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')); } if (!actor?.id || !this.isMentorOrAdmin(actor)) { @@ -248,8 +246,8 @@ class TaskService { async submitTask(idTask, payload, file, 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')); } if (!actor?.id || actor.role !== 'intern') { From 76eba6eb1368b11a818a6217df8c98f363bc28cb Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Tue, 23 Jun 2026 23:58:24 +0700 Subject: [PATCH 4/5] docs: dokumentasi slug di module project & task --- src/routes/project.routes.js | 45 +++++++++---- src/routes/task.routes.js | 125 ++++++++++++++++++++++++++++------- 2 files changed, 134 insertions(+), 36 deletions(-) diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 62d7324..1f3d334 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -16,6 +16,9 @@ const router = express.Router(); * id: * type: integer * example: 1 + * slug: + * type: string + * example: internify-lms * project_icon: * type: string * enum: [code, chart, cloud, mobile, gear, users, clipboard, speedometer, lightbulb, shield] @@ -128,6 +131,9 @@ const router = express.Router(); * id: * type: integer * example: 1 + * slug: + * type: string + * example: internify-platform-dev * project_icon: * type: string * example: code @@ -216,6 +222,9 @@ const router = express.Router(); * id: * type: integer * example: 1 + * slug: + * type: string + * example: database-setup * title: * type: string * example: Database Setup @@ -332,9 +341,15 @@ const router = express.Router(); * id: * type: integer * example: 1 + * slug: + * type: string + * example: database-setup * id_project: * type: integer * example: 1 + * project_slug: + * type: string + * example: internify-platform-dev * project_name: * type: string * example: Internify Platform Dev @@ -526,7 +541,7 @@ router.get('/get', verifyJWT, projectController.getAllProjects); * /project-api/get/{id}: * get: * summary: Get project detail - * description: Admin can access any project. Intern can only access project where they are active members. + * description: Admin can access any project. Mentor can access their own project. Intern can only access project where they are active members. The path parameter supports project ID or slug. * tags: [Project] * security: * - bearerAuth: [] @@ -534,10 +549,12 @@ router.get('/get', verifyJWT, projectController.getAllProjects); * - in: path * name: id * schema: - * type: integer + * oneOf: + * - type: integer + * - type: string * required: true - * description: Project ID - * example: 1 + * description: Project ID or slug + * example: internify-platform-dev * responses: * 200: * description: Project detail retrieved successfully @@ -561,7 +578,7 @@ router.get('/get/:id', verifyJWT, projectController.getProjectById); * /project-api/update/{id}: * patch: * summary: Update project - * description: Update project information. Only admin can access this endpoint. + * description: Update project information. Only admin can access this endpoint. The path parameter supports project ID or slug. * tags: [Project] * security: * - bearerAuth: [] @@ -569,10 +586,12 @@ router.get('/get/:id', verifyJWT, projectController.getProjectById); * - in: path * name: id * schema: - * type: integer + * oneOf: + * - type: integer + * - type: string * required: true - * description: Project ID - * example: 1 + * description: Project ID or slug + * example: internify-platform-dev * requestBody: * required: true * content: @@ -613,7 +632,7 @@ router.patch('/update/:id', verifyJWT, isMentorOrAdmin, projectController.update * /project-api/delete/{id}: * delete: * summary: Archive project - * description: Archive project by changing its status to archived. This endpoint does not hard delete the project. + * description: Archive project by changing its status to archived. This endpoint does not hard delete the project. The path parameter supports project ID or slug. * tags: [Project] * security: * - bearerAuth: [] @@ -621,10 +640,12 @@ router.patch('/update/:id', verifyJWT, isMentorOrAdmin, projectController.update * - in: path * name: id * schema: - * type: integer + * oneOf: + * - type: integer + * - type: string * required: true - * description: Project ID - * example: 1 + * description: Project ID or slug + * example: internify-platform-dev * responses: * 200: * description: Project archived successfully diff --git a/src/routes/task.routes.js b/src/routes/task.routes.js index 2973901..e0e52ad 100644 --- a/src/routes/task.routes.js +++ b/src/routes/task.routes.js @@ -23,6 +23,9 @@ const router = express.Router(); * id_project: * type: integer * example: 1 + * slug: + * type: string + * example: laporan-tugas-2 * title: * type: string * example: Laporan Tugas 2 @@ -192,6 +195,9 @@ const router = express.Router(); * id: * type: integer * example: 1 + * slug: + * type: string + * example: internify-project * project_name: * type: string * example: Internify Project @@ -232,6 +238,9 @@ const router = express.Router(); * id: * type: integer * example: 1 + * slug: + * type: string + * example: internify-project * project_name: * type: string * example: Internify Project @@ -345,7 +354,7 @@ const router = express.Router(); * /task-api/projects/{id_project}/tasks: * post: * summary: Create task in project - * description: Create a new task inside a project. Only admin or mentor can access this endpoint. Mentors can only create tasks in projects they own. + * description: Create a new task inside a project. Project can be referenced by ID or slug. Only admin or mentor can access this endpoint. Mentors can only create tasks in projects they own. * tags: [Task] * security: * - bearerAuth: [] @@ -354,9 +363,13 @@ const router = express.Router(); * name: id_project * required: true * schema: - * type: integer - * description: Project ID - * example: 1 + * oneOf: + * - type: integer + * example: 1 + * - type: string + * example: internify-project + * description: Project ID or slug + * example: internify-project * requestBody: * required: true * content: @@ -388,7 +401,7 @@ router.post('/projects/:id_project/tasks', verifyJWT, isMentorOrAdmin, taskContr * /task-api/projects/{id_project}/tasks: * get: * summary: Get project tasks - * description: Get all tasks from a project. Admin can access any project task list. Intern can only access tasks from their active project. + * description: Get all tasks from a project. Project can be referenced by ID or slug. Admin can access any project task list. Intern can only access tasks from their active project. * tags: [Task] * security: * - bearerAuth: [] @@ -397,9 +410,13 @@ router.post('/projects/:id_project/tasks', verifyJWT, isMentorOrAdmin, taskContr * name: id_project * required: true * schema: - * type: integer - * description: Project ID - * example: 1 + * oneOf: + * - type: integer + * example: 1 + * - type: string + * example: internify-project + * description: Project ID or slug + * example: internify-project * responses: * 200: * description: Tasks retrieved successfully @@ -423,7 +440,7 @@ router.get('/projects/:id_project/tasks', verifyJWT, taskController.getTasksByPr * /task-api/tasks/{id}: * get: * summary: Get task detail - * description: Get task detail. Admin receives all intern submissions. Intern receives only their own submission. + * description: Get task detail. Admin/mentor receives all intern submissions. Intern receives only their own submission. The path parameter supports task ID. Task slug is also supported when project query is provided. * tags: [Task] * security: * - bearerAuth: [] @@ -432,9 +449,24 @@ router.get('/projects/:id_project/tasks', verifyJWT, taskController.getTasksByPr * name: id * required: true * schema: - * type: integer - * description: Task ID - * example: 1 + * oneOf: + * - type: integer + * example: 1 + * - type: string + * example: laporan-tugas-2 + * description: Task ID or task slug. If task slug is used, project query parameter is required. + * example: laporan-tugas-2 + * - in: query + * name: project + * required: false + * schema: + * oneOf: + * - type: integer + * example: 1 + * - type: string + * example: internify-project + * description: Project ID or slug. Required when using task slug instead of numeric task ID. + * example: internify-project * responses: * 200: * description: Task detail retrieved successfully @@ -538,7 +570,7 @@ router.get('/tasks/:id', verifyJWT, taskController.getTaskById); * /task-api/tasks/{id}: * patch: * summary: Update task - * description: Update task information. Submission type cannot be changed if the task already has submissions. Only admin or mentor can access this endpoint. Mentors can only update tasks in projects they own. + * description: Update task information. Submission type cannot be changed if the task already has submissions. Only admin or mentor can access this endpoint. Mentors can only update tasks in projects they own. The path parameter supports task ID. Task slug is also supported when project query is provided. * tags: [Task] * security: * - bearerAuth: [] @@ -547,9 +579,24 @@ router.get('/tasks/:id', verifyJWT, taskController.getTaskById); * name: id * required: true * schema: - * type: integer - * description: Task ID - * example: 1 + * oneOf: + * - type: integer + * example: 1 + * - type: string + * example: laporan-tugas-2 + * description: Task ID or task slug. If task slug is used, project query parameter is required. + * example: laporan-tugas-2 + * - in: query + * name: project + * required: false + * schema: + * oneOf: + * - type: integer + * example: 1 + * - type: string + * example: internify-project + * description: Project ID or slug. Required when using task slug instead of numeric task ID. + * example: internify-project * requestBody: * required: true * content: @@ -583,7 +630,7 @@ router.patch('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.updateTask * /task-api/tasks/{id}: * delete: * summary: Delete task - * description: Delete task only if it does not have submissions. Only admin or mentor can access this endpoint. Mentors can only delete tasks in projects they own. + * description: Delete task only if it does not have submissions. Only admin or mentor can access this endpoint. Mentors can only delete tasks in projects they own. The path parameter supports task ID. Task slug is also supported when project query is provided. * tags: [Task] * security: * - bearerAuth: [] @@ -592,9 +639,24 @@ router.patch('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.updateTask * name: id * required: true * schema: - * type: integer - * description: Task ID - * example: 1 + * oneOf: + * - type: integer + * example: 1 + * - type: string + * example: laporan-tugas-2 + * description: Task ID or task slug. If task slug is used, project query parameter is required. + * example: laporan-tugas-2 + * - in: query + * name: project + * required: false + * schema: + * oneOf: + * - type: integer + * example: 1 + * - type: string + * example: internify-project + * description: Project ID or slug. Required when using task slug instead of numeric task ID. + * example: internify-project * responses: * 200: * description: Task deleted successfully @@ -616,7 +678,7 @@ router.delete('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.deleteTas * /task-api/tasks/{id}/submissions: * post: * summary: Submit task - * description: Submit a task as an intern. For file_upload tasks, send submission_file. For url_link tasks, send url_link. + * description: Submit a task as an intern. For file_upload tasks, send submission_file. For url_link tasks, send url_link. The path parameter supports task ID. Task slug is also supported when project query is provided. * tags: [Task] * security: * - bearerAuth: [] @@ -625,9 +687,24 @@ router.delete('/tasks/:id', verifyJWT, isMentorOrAdmin, taskController.deleteTas * name: id * required: true * schema: - * type: integer - * description: Task ID - * example: 1 + * oneOf: + * - type: integer + * example: 1 + * - type: string + * example: laporan-tugas-2 + * description: Task ID or task slug. If task slug is used, project query parameter is required. + * example: laporan-tugas-2 + * - in: query + * name: project + * required: false + * schema: + * oneOf: + * - type: integer + * example: 1 + * - type: string + * example: internify-project + * description: Project ID or slug. Required when using task slug instead of numeric task ID. + * example: internify-project * requestBody: * required: false * content: From 0224cd294dace5f69f071dba3639909dd6f66657 Mon Sep 17 00:00:00 2001 From: Muhammad Zhafran Ilham Date: Wed, 24 Jun 2026 00:13:20 +0700 Subject: [PATCH 5/5] chore(seed): menambahkan akun mentor dan project mentor --- prisma/seed.js | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/prisma/seed.js b/prisma/seed.js index 4640562..a03fb00 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -43,7 +43,7 @@ async function main() { role: 'admin', signature: `Admin-Two-${Date.now()}`, profile_picture: 'https://placehold.co/150', - professional_bio: 'Lead Engineering Mentor at Internify.' + professional_bio: 'Lead Engineering Admin at Internify.' } }); const admin3 = await prisma.admin.create({ @@ -55,7 +55,31 @@ async function main() { role: 'admin', signature: `Admin-Three-${Date.now()}`, profile_picture: 'https://placehold.co/150', - professional_bio: 'Design Mentor at Internify.' + professional_bio: 'Design Admin at Internify.' + } + }); + const mentor1 = await prisma.admin.create({ + data: { + nama_depan: 'Mentor', + nama_belakang: 'One', + email: 'mentor1@internify.com', + password: hashedPassword, + role: 'mentor', + signature: `Mentor-One-${Date.now()}`, + profile_picture: 'https://placehold.co/150', + professional_bio: 'Backend Mentor focused on API design, project review, and task evaluation.' + } + }); + const mentor2 = await prisma.admin.create({ + data: { + nama_depan: 'Mentor', + nama_belakang: 'Two', + email: 'mentor2@internify.com', + password: hashedPassword, + role: 'mentor', + signature: `Mentor-Two-${Date.now()}`, + profile_picture: 'https://placehold.co/150', + professional_bio: 'Product and UI/UX Mentor focused on design review and intern guidance.' } }); @@ -159,6 +183,12 @@ async function main() { const proj3 = await prisma.project.create({ 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' } }); + const proj4 = await prisma.project.create({ + data: { id_admin: admin1.id, project_icon: 'gear', project_name: 'Mentor Backend Task Management Project', slug: slugify('Mentor Backend Project', 'Project 4 slug'), description: 'Project owned by mentor for backend project and task management.', start_date: new Date('2026-07-20'), end_date: new Date('2026-10-15'), status: 'active' } + }); + const proj5 = await prisma.project.create({ + data: { id_admin: admin1.id, project_icon: 'clipboard', project_name: 'Mentor UI UX Evaluation Project', slug: slugify('Mentor UI UX Evaluation Project', 'Project 5 slug'), description: 'Project owned by mentor for testing UI/UX mentoring workflow.', start_date: new Date('2026-07-18'), end_date: new Date('2026-10-15'), status: 'active' } + }); // 11. LamaranMagang const lamaran1 = await prisma.lamaranMagang.create({ @@ -181,6 +211,15 @@ async function main() { const task3 = await prisma.task.create({ 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' } }); + const task4 = await prisma.task.create({ + data: { id_project: proj4.id, slug: slugify('Review REST API Structure', 'task'), title: 'Review REST API Structure', description: 'Review endpoint structure, response format, and service-repository separation.', deadline_at: new Date('2026-08-20'), submission_type: 'url_link' } + }); + const task5 = await prisma.task.create({ + data: { id_project: proj4.id, slug: slugify('Prepare Backend Progress Report', 'task'), title: 'Prepare Backend Progress Report', description: 'Prepare a short report about implemented backend features and blockers.', deadline_at: new Date('2026-08-25'), submission_type: 'file_upload'} + }); + const task6 =await prisma.task.create({ + data: { id_project: proj5.id, slug: slugify('Create UI Review Notes', 'task'), title: 'Create UI Review Notes', description: 'Create UI/UX review notes for dashboard, project detail, and task submission pages.', deadline_at: new Date('2026-08-22'), submission_type: 'file_upload' } + }); // 13. User const user1 = await prisma.user.create({