Make slug optional on create and auto-generate from title when omitted

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-24 15:58:56 +03:30
co-authored by Cursor
parent aa990c3204
commit 1522ff62f5
9 changed files with 118 additions and 23 deletions
+34 -2
View File
@@ -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<boolean>,
fallbackBase = 'item',
): Promise<string> {
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<boolean>,
provided?: string | null,
fallbackBase = 'item',
): Promise<string> {
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<boolean>,
): Promise<string> {
return uniqueSlugFromTitle(title, exists, 'business');
}
@@ -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<BusinessCategoryEntity> {
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);
@@ -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()
@@ -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,
@@ -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()
+16 -4
View File
@@ -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<BusinessEntity> {
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,
@@ -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()
@@ -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()
@@ -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<ServiceTemplateEntity> {
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,