From 6f2ce12c0f63eda4626cb1dfd39a6ba0a6b7eda4 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 4 Oct 2026 11:44:26 +0330 Subject: [PATCH] Add business category create, update, and delete APIs. Super-admins can manage the marketplace category tree via POST/PATCH/DELETE on /api/v1/business-categories. Co-authored-by: Cursor --- .../business-categories.controller.ts | 37 +++ .../business-categories.service.ts | 238 +++++++++++++++++- .../dto/business-category.dto.ts | 90 +++++++ 3 files changed, 355 insertions(+), 10 deletions(-) create mode 100644 src/business-admin/dto/business-category.dto.ts diff --git a/src/business-admin/business-categories.controller.ts b/src/business-admin/business-categories.controller.ts index 026002d..853b126 100644 --- a/src/business-admin/business-categories.controller.ts +++ b/src/business-admin/business-categories.controller.ts @@ -1,6 +1,11 @@ import { + Body, Controller, + Delete, Get, + Param, + Patch, + Post, Query, UseGuards, } from '@nestjs/common'; @@ -9,6 +14,10 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AuthUser } from '../auth/auth.types'; import { BusinessCategoriesService } from './business-categories.service'; +import { + CreateBusinessCategoryDto, + UpdateBusinessCategoryDto, +} from './dto/business-category.dto'; class ListBusinessCategoriesDto { @IsOptional() @@ -29,4 +38,32 @@ export class BusinessCategoriesController { ) { return this.service.list(user, query.q); } + + @Post() + @UseGuards(JwtAuthGuard) + create( + @CurrentUser() user: AuthUser, + @Body() dto: CreateBusinessCategoryDto, + ) { + return this.service.create(user, dto); + } + + @Patch(':categoryId') + @UseGuards(JwtAuthGuard) + update( + @CurrentUser() user: AuthUser, + @Param('categoryId') categoryId: string, + @Body() dto: UpdateBusinessCategoryDto, + ) { + return this.service.update(user, categoryId, dto); + } + + @Delete(':categoryId') + @UseGuards(JwtAuthGuard) + remove( + @CurrentUser() user: AuthUser, + @Param('categoryId') categoryId: string, + ) { + return this.service.remove(user, categoryId); + } } diff --git a/src/business-admin/business-categories.service.ts b/src/business-admin/business-categories.service.ts index e30caf5..d5fc944 100644 --- a/src/business-admin/business-categories.service.ts +++ b/src/business-admin/business-categories.service.ts @@ -1,8 +1,27 @@ -import { Injectable, ForbiddenException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { PermissionsService } from '../auth/permissions.service'; import { AuthUser } from '../auth/auth.types'; +import { + CreateBusinessCategoryDto, + UpdateBusinessCategoryDto, +} from './dto/business-category.dto'; + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'category' + ); +} @Injectable() export class BusinessCategoriesService { @@ -12,15 +31,7 @@ export class BusinessCategoriesService { ) {} async list(actor: AuthUser, q?: string) { - const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id); - const canRead = - isSuperAdmin || - actor.roles.includes('business_owner') || - actor.roles.includes('business_staff'); - - if (!canRead) { - throw new ForbiddenException('Access denied'); - } + await this.assertCanRead(actor); const term = q?.trim(); if (term) { @@ -88,4 +99,211 @@ export class BusinessCategoriesService { return { items: categories }; } + + async create(actor: AuthUser, dto: CreateBusinessCategoryDto) { + await this.assertCanManage(actor); + + const parentId = await this.resolveParentId(dto.parentId); + const slug = await this.ensureUniqueSlug(dto.slug ?? slugify(dto.name)); + + const created = await this.prisma.businessCategory.create({ + data: { + parentId, + name: dto.name.trim(), + nameFa: dto.nameFa?.trim() || null, + slug, + description: dto.description?.trim() || null, + icon: dto.icon?.trim() || null, + sortOrder: dto.sortOrder ?? 0, + isActive: true, + }, + select: { + id: true, + parentId: true, + name: true, + nameFa: true, + slug: true, + description: true, + icon: true, + sortOrder: true, + tags: true, + }, + }); + + return { item: created }; + } + + async update( + actor: AuthUser, + categoryIdRaw: string, + dto: UpdateBusinessCategoryDto, + ) { + await this.assertCanManage(actor); + const categoryId = this.parseId(categoryIdRaw); + + const existing = await this.prisma.businessCategory.findFirst({ + where: { id: categoryId, isActive: true }, + }); + if (!existing) { + throw new NotFoundException('Category not found'); + } + + let parentId: bigint | null | undefined = undefined; + if (dto.parentId !== undefined) { + if (dto.parentId === null || dto.parentId === '') { + parentId = null; + } else { + parentId = await this.resolveParentId(dto.parentId); + if (parentId === categoryId) { + throw new BadRequestException('Category cannot be its own parent'); + } + const descendantIds = await this.collectDescendantIds(categoryId); + if (parentId && descendantIds.includes(parentId)) { + throw new BadRequestException( + 'Cannot move category under its own descendant', + ); + } + } + } + + let slug = existing.slug; + if (dto.slug) { + slug = await this.ensureUniqueSlug(dto.slug, categoryId); + } else if (dto.name && dto.name !== existing.name) { + slug = await this.ensureUniqueSlug(slugify(dto.name), categoryId); + } + + const updated = await this.prisma.businessCategory.update({ + where: { id: categoryId }, + data: { + ...(dto.name !== undefined ? { name: dto.name.trim() } : {}), + ...(dto.nameFa !== undefined + ? { nameFa: dto.nameFa?.trim() || null } + : {}), + ...(dto.description !== undefined + ? { description: dto.description?.trim() || null } + : {}), + ...(dto.icon !== undefined ? { icon: dto.icon?.trim() || null } : {}), + ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), + ...(parentId !== undefined ? { parentId } : {}), + slug, + }, + select: { + id: true, + parentId: true, + name: true, + nameFa: true, + slug: true, + description: true, + icon: true, + sortOrder: true, + tags: true, + }, + }); + + return { item: updated }; + } + + async remove(actor: AuthUser, categoryIdRaw: string) { + await this.assertCanManage(actor); + const categoryId = this.parseId(categoryIdRaw); + + const existing = await this.prisma.businessCategory.findFirst({ + where: { id: categoryId, isActive: true }, + select: { id: true }, + }); + if (!existing) { + throw new NotFoundException('Category not found'); + } + + const descendantIds = await this.collectDescendantIds(categoryId); + + await this.prisma.businessCategory.updateMany({ + where: { id: { in: descendantIds } }, + data: { isActive: false }, + }); + + return { + message: 'Category deleted successfully', + deletedIds: descendantIds.map((id) => id.toString()), + }; + } + + private async assertCanRead(actor: AuthUser) { + const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id); + const canRead = + isSuperAdmin || + actor.roles.includes('business_owner') || + actor.roles.includes('business_staff'); + + if (!canRead) { + throw new ForbiddenException('Access denied'); + } + } + + private async assertCanManage(actor: AuthUser) { + const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id); + if (!isSuperAdmin) { + throw new ForbiddenException('Only super admins can manage categories'); + } + } + + private parseId(value: string) { + if (!/^\d+$/.test(value)) { + throw new BadRequestException('Invalid category id'); + } + return BigInt(value); + } + + private async resolveParentId(parentIdRaw?: string | null) { + if (parentIdRaw == null || parentIdRaw === '') return null; + const parentId = this.parseId(parentIdRaw); + const parent = await this.prisma.businessCategory.findFirst({ + where: { id: parentId, isActive: true }, + select: { id: true }, + }); + if (!parent) { + throw new BadRequestException('Parent category not found'); + } + return parent.id; + } + + private async ensureUniqueSlug(base: string, excludeId?: bigint) { + let candidate = slugify(base); + let suffix = 2; + while (true) { + const existing = await this.prisma.businessCategory.findFirst({ + where: { + slug: candidate, + ...(excludeId ? { id: { not: excludeId } } : {}), + }, + select: { id: true }, + }); + if (!existing) return candidate; + candidate = `${slugify(base)}-${suffix}`; + suffix += 1; + } + } + + private async collectDescendantIds(rootId: bigint): Promise { + const all = await this.prisma.businessCategory.findMany({ + where: { isActive: true }, + select: { id: true, parentId: true }, + }); + + const result: bigint[] = [rootId]; + const queue = [rootId]; + + while (queue.length > 0) { + const current = queue.shift()!; + for (const child of all) { + if (child.parentId === current && !result.includes(child.id)) { + result.push(child.id); + queue.push(child.id); + } + } + } + + return result; + } } diff --git a/src/business-admin/dto/business-category.dto.ts b/src/business-admin/dto/business-category.dto.ts new file mode 100644 index 0000000..c53dd87 --- /dev/null +++ b/src/business-admin/dto/business-category.dto.ts @@ -0,0 +1,90 @@ +import { Type } from 'class-transformer'; +import { + IsInt, + IsOptional, + IsString, + Matches, + MaxLength, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; + +export class CreateBusinessCategoryDto { + @IsString() + @MinLength(2) + @MaxLength(255) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(255) + nameFa?: string; + + @IsOptional() + @ValidateIf((_, value) => value !== null && value !== '') + @IsString() + parentId?: string | null; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: 'slug must be lowercase letters, numbers, and hyphens', + }) + slug?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + sortOrder?: number; + + @IsOptional() + @IsString() + @MaxLength(100) + icon?: string; +} + +export class UpdateBusinessCategoryDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + nameFa?: string | null; + + @IsOptional() + @ValidateIf((_, value) => value !== null && value !== '') + @IsString() + parentId?: string | null; + + @IsOptional() + @IsString() + description?: string | null; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: 'slug must be lowercase letters, numbers, and hyphens', + }) + slug?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + sortOrder?: number; + + @IsOptional() + @IsString() + @MaxLength(100) + icon?: string | null; +}