diff --git a/backend/src/services/notifications/list.js b/backend/src/services/notifications/list.js index 29b198d..e9ce120 100644 --- a/backend/src/services/notifications/list.js +++ b/backend/src/services/notifications/list.js @@ -34,6 +34,7 @@ const errors = { revokingOwnPermission: `You can't revoke your own owner permission`, deletingHimself: `You can't delete yourself`, emailRequired: 'Email is required', + slugAlreadyExists: 'This slug is already in use by another project', }, }, diff --git a/backend/src/services/projects.js b/backend/src/services/projects.js index c800c4b..5abbcf8 100644 --- a/backend/src/services/projects.js +++ b/backend/src/services/projects.js @@ -43,9 +43,47 @@ module.exports = class ProjectsService { return uniqueSlug; } + /** + * Validate slug uniqueness before create/update + * @param {string} slug - Slug to validate + * @param {string|null} excludeId - Project ID to exclude (for updates) + * @param {Transaction} transaction - DB transaction + * @throws {ValidationError} if slug already exists + * @returns {string} Normalized slug + */ + static async validateSlugUniqueness(slug, excludeId, transaction) { + const normalizedSlug = ProjectsService.normalizeSlug(slug); + + const whereClause = { slug: normalizedSlug }; + if (excludeId) { + whereClause.id = { [db.Sequelize.Op.ne]: excludeId }; + } + + const existing = await db.projects.findOne({ + where: whereClause, + paranoid: false, + transaction, + }); + + if (existing) { + throw new ValidationError('iam.errors.slugAlreadyExists'); + } + + return normalizedSlug; + } + static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { + // Validate slug uniqueness if provided + if (data.slug) { + data.slug = await ProjectsService.validateSlugUniqueness( + data.slug, + null, + transaction, + ); + } + const createdProject = await ProjectsDBApi.create(data, { currentUser, transaction, @@ -196,6 +234,15 @@ module.exports = class ProjectsService { throw new ValidationError('projectsNotFound'); } + // Validate slug uniqueness if slug is being changed + if (data.slug && data.slug !== projects.slug) { + data.slug = await ProjectsService.validateSlugUniqueness( + data.slug, + id, + transaction, + ); + } + const updatedProjects = await ProjectsDBApi.update(id, data, { currentUser, transaction, diff --git a/frontend/src/pages/projects/projects-edit.tsx b/frontend/src/pages/projects/projects-edit.tsx index 25ac2db..b287603 100644 --- a/frontend/src/pages/projects/projects-edit.tsx +++ b/frontend/src/pages/projects/projects-edit.tsx @@ -24,6 +24,7 @@ import BaseButton from '../../components/BaseButton'; import { deleteItem, update, fetch } from '../../stores/projects/projectsSlice'; import { useAppDispatch, useAppSelector } from '../../stores/hooks'; import { useRouter } from 'next/router'; +import { toast, ToastContainer } from 'react-toastify'; import type { Project } from '../../types/entities'; import { logger } from '../../lib/logger'; @@ -143,8 +144,22 @@ const EditProjectsPage = () => { og_image_url: data.og_image_url, }; - await dispatch(update({ id: id as string, data: apiData })); - await router.push('/projects/projects-list'); + try { + await dispatch(update({ id: id as string, data: apiData })).unwrap(); + toast('Project settings saved', { + type: 'success', + position: 'bottom-center', + }); + } catch (error: unknown) { + const errorMessage = + error && typeof error === 'object' && 'message' in error + ? String((error as { message: string }).message) + : 'Failed to save project settings'; + toast(errorMessage, { + type: 'error', + position: 'bottom-center', + }); + } }; const handleDelete = async () => { @@ -322,6 +337,7 @@ const EditProjectsPage = () => { )} + );