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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-04 11:44:26 +03:30
co-authored by Cursor
parent a9e29b79fd
commit 6f2ce12c0f
3 changed files with 355 additions and 10 deletions
@@ -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);
}
}
+228 -10
View File
@@ -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<bigint[]> {
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;
}
}
@@ -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;
}