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();