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:
co-authored by
Cursor
parent
a9e29b79fd
commit
6f2ce12c0f
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user