diff --git a/src/common/utils/slug.util.ts b/src/common/utils/slug.util.ts index 305eaba..ab7621e 100644 --- a/src/common/utils/slug.util.ts +++ b/src/common/utils/slug.util.ts @@ -1,3 +1,5 @@ +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + export function slugifyTitle(value: string): string { return value .trim() @@ -8,13 +10,19 @@ export function slugifyTitle(value: string): string { .replace(/^-|-$/g, ''); } -export async function uniqueBusinessSlug( +export function normalizeSlugInput(value?: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export async function uniqueSlugFromTitle( title: string, exists: (slug: string) => Promise, + fallbackBase = 'item', ): Promise { let base = slugifyTitle(title); if (!base) { - base = 'business'; + base = fallbackBase; } let slug = base; let suffix = 0; @@ -24,3 +32,27 @@ export async function uniqueBusinessSlug( } return slug; } + +export async function resolveUniqueSlug( + title: string, + exists: (slug: string) => Promise, + provided?: string | null, + fallbackBase = 'item', +): Promise { + const normalized = normalizeSlugInput(provided); + if (normalized) { + if (!SLUG_PATTERN.test(normalized)) { + throw new Error('Invalid slug format'); + } + return normalized; + } + return uniqueSlugFromTitle(title, exists, fallbackBase); +} + +/** @deprecated Use uniqueSlugFromTitle */ +export async function uniqueBusinessSlug( + title: string, + exists: (slug: string) => Promise, +): Promise { + return uniqueSlugFromTitle(title, exists, 'business'); +} diff --git a/src/modules/business-categories/business-categories.service.ts b/src/modules/business-categories/business-categories.service.ts index 57c2de4..6dd6ea2 100644 --- a/src/modules/business-categories/business-categories.service.ts +++ b/src/modules/business-categories/business-categories.service.ts @@ -6,6 +6,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { deleteCategoryCascade } from '../../common/utils/business-cascade.util'; +import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util'; import { BusinessImagesService } from '../businesses/business-images.service'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; import { BusinessEntity } from '../../entities/business.entity'; @@ -38,14 +39,25 @@ export class BusinessCategoriesService { async create( payload: CreateBusinessCategoryDto, ): Promise { - const exists = await this.repo.findOne({ where: { slug: payload.slug } }); - if (exists) { - throw new ConflictException('Slug already exists'); + const explicitSlug = normalizeSlugInput(payload.slug); + if (explicitSlug) { + const taken = await this.repo.findOne({ where: { slug: explicitSlug } }); + if (taken) { + throw new ConflictException('Slug already exists'); + } } + const slug = await resolveUniqueSlug( + payload.title, + async (candidate) => + Boolean(await this.repo.findOne({ where: { slug: candidate } })), + payload.slug, + 'category', + ); + const entity = this.repo.create({ title: payload.title, - slug: payload.slug, + slug, isActive: payload.isActive ?? true, }); return this.repo.save(entity); diff --git a/src/modules/business-categories/dto/create-business-category.dto.ts b/src/modules/business-categories/dto/create-business-category.dto.ts index abe7057..d025ce4 100644 --- a/src/modules/business-categories/dto/create-business-category.dto.ts +++ b/src/modules/business-categories/dto/create-business-category.dto.ts @@ -7,13 +7,14 @@ export class CreateBusinessCategoryDto { @MaxLength(150) title!: string; - @ApiProperty({ example: 'car-wash' }) + @ApiPropertyOptional({ example: 'car-wash' }) + @IsOptional() @IsString() @MaxLength(120) @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { message: 'slug must be lowercase words separated by hyphens', }) - slug!: string; + slug?: string; @ApiPropertyOptional({ example: true }) @IsOptional() diff --git a/src/modules/business-registration-requests/business-registration-requests.service.ts b/src/modules/business-registration-requests/business-registration-requests.service.ts index cfa0ee5..e532088 100644 --- a/src/modules/business-registration-requests/business-registration-requests.service.ts +++ b/src/modules/business-registration-requests/business-registration-requests.service.ts @@ -8,7 +8,7 @@ import { import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { uniqueBusinessSlug } from '../../common/utils/slug.util'; +import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util'; import { BusinessRegistrationRequestEntity, BusinessRegistrationStatus, @@ -72,12 +72,26 @@ export class BusinessRegistrationRequestsService { await this.ensureCategoryExists(dto.categoryId); - const slug = await uniqueBusinessSlug(dto.title, async (candidate) => { + const slugExists = async (candidate: string) => { const inBusiness = await this.businessRepo.findOne({ where: { slug: candidate } }); if (inBusiness) return true; const inRequest = await this.repo.findOne({ where: { slug: candidate } }); return Boolean(inRequest); - }); + }; + + const explicitSlug = normalizeSlugInput(dto.slug); + if (explicitSlug) { + if (await slugExists(explicitSlug)) { + throw new ConflictException('Slug already exists'); + } + } + + const slug = await resolveUniqueSlug( + dto.title, + slugExists, + dto.slug, + 'business', + ); const entity = this.repo.create({ applicantId, diff --git a/src/modules/business-registration-requests/dto/create-business-registration.dto.ts b/src/modules/business-registration-requests/dto/create-business-registration.dto.ts index 03c24c1..4bc7fca 100644 --- a/src/modules/business-registration-requests/dto/create-business-registration.dto.ts +++ b/src/modules/business-registration-requests/dto/create-business-registration.dto.ts @@ -5,6 +5,7 @@ import { IsOptional, IsString, IsUUID, + Matches, MaxLength, } from 'class-validator'; @@ -18,6 +19,15 @@ export class CreateBusinessRegistrationDto { @IsUUID() categoryId!: string; + @ApiPropertyOptional({ example: 'mohammad-carwash' }) + @IsOptional() + @IsString() + @MaxLength(120) + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: 'slug must be lowercase words separated by hyphens', + }) + slug?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index d233d11..a2fc8c1 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -8,6 +8,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { deleteBusinessCascade } from '../../common/utils/business-cascade.util'; +import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util'; import { BusinessImagesService } from './business-images.service'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; import { BusinessEntity } from '../../entities/business.entity'; @@ -79,16 +80,27 @@ export class BusinessesService { } async create(payload: CreateBusinessDto): Promise { - const slugTaken = await this.repo.findOne({ where: { slug: payload.slug } }); - if (slugTaken) { - throw new ConflictException('Slug already exists'); + const explicitSlug = normalizeSlugInput(payload.slug); + if (explicitSlug) { + const taken = await this.repo.findOne({ where: { slug: explicitSlug } }); + if (taken) { + throw new ConflictException('Slug already exists'); + } } + const slug = await resolveUniqueSlug( + payload.title, + async (candidate) => + Boolean(await this.repo.findOne({ where: { slug: candidate } })), + payload.slug, + 'business', + ); + await this.ensureCategoryExists(payload.categoryId); const entity = this.repo.create({ title: payload.title, - slug: payload.slug, + slug, categoryId: payload.categoryId, description: payload.description ?? null, contactPhone: payload.contactPhone ?? null, diff --git a/src/modules/businesses/dto/create-business.dto.ts b/src/modules/businesses/dto/create-business.dto.ts index 7fd2987..ff4f8c0 100644 --- a/src/modules/businesses/dto/create-business.dto.ts +++ b/src/modules/businesses/dto/create-business.dto.ts @@ -16,13 +16,14 @@ export class CreateBusinessDto { @MaxLength(150) title!: string; - @ApiProperty({ example: 'shine-carwash' }) + @ApiPropertyOptional({ example: 'shine-carwash' }) + @IsOptional() @IsString() @MaxLength(120) @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { message: 'slug must be lowercase words separated by hyphens', }) - slug!: string; + slug?: string; @ApiProperty({ format: 'uuid' }) @IsUUID() diff --git a/src/modules/service-templates/dto/create-service-template.dto.ts b/src/modules/service-templates/dto/create-service-template.dto.ts index 582a2dc..c244076 100644 --- a/src/modules/service-templates/dto/create-service-template.dto.ts +++ b/src/modules/service-templates/dto/create-service-template.dto.ts @@ -17,13 +17,14 @@ export class CreateServiceTemplateDto { @MaxLength(150) title!: string; - @ApiProperty({ example: 'basic-wash' }) + @ApiPropertyOptional({ example: 'basic-wash' }) + @IsOptional() @IsString() @MaxLength(120) @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { message: 'slug must be lowercase words separated by hyphens', }) - slug!: string; + slug?: string; @ApiProperty({ format: 'uuid' }) @IsUUID() diff --git a/src/modules/service-templates/service-templates.service.ts b/src/modules/service-templates/service-templates.service.ts index 1612478..3367222 100644 --- a/src/modules/service-templates/service-templates.service.ts +++ b/src/modules/service-templates/service-templates.service.ts @@ -7,6 +7,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; import { ServiceTemplateEntity } from '../../entities/service-template.entity'; +import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util'; import { CreateServiceTemplateDto } from './dto/create-service-template.dto'; import { UpdateServiceTemplateDto } from './dto/update-service-template.dto'; @@ -57,16 +58,27 @@ export class ServiceTemplatesService { } async create(p: CreateServiceTemplateDto): Promise { - const slugTaken = await this.repo.findOne({ where: { slug: p.slug } }); - if (slugTaken) { - throw new ConflictException('Slug already exists'); + const explicitSlug = normalizeSlugInput(p.slug); + if (explicitSlug) { + const taken = await this.repo.findOne({ where: { slug: explicitSlug } }); + if (taken) { + throw new ConflictException('Slug already exists'); + } } + const slug = await resolveUniqueSlug( + p.title, + async (candidate) => + Boolean(await this.repo.findOne({ where: { slug: candidate } })), + p.slug, + 'service', + ); + await this.ensureCategoryExists(p.categoryId); const entity = this.repo.create({ title: p.title, - slug: p.slug, + slug, categoryId: p.categoryId, description: p.description ?? null, defaultDurationMinutes: p.defaultDurationMinutes ?? 30,