Make slug optional on create and auto-generate from title when omitted
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
+17
-3
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user