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 {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -9,6 +14,10 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { BusinessCategoriesService } from './business-categories.service';
|
import { BusinessCategoriesService } from './business-categories.service';
|
||||||
|
import {
|
||||||
|
CreateBusinessCategoryDto,
|
||||||
|
UpdateBusinessCategoryDto,
|
||||||
|
} from './dto/business-category.dto';
|
||||||
|
|
||||||
class ListBusinessCategoriesDto {
|
class ListBusinessCategoriesDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -29,4 +38,32 @@ export class BusinessCategoriesController {
|
|||||||
) {
|
) {
|
||||||
return this.service.list(user, query.q);
|
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 { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
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()
|
@Injectable()
|
||||||
export class BusinessCategoriesService {
|
export class BusinessCategoriesService {
|
||||||
@@ -12,15 +31,7 @@ export class BusinessCategoriesService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(actor: AuthUser, q?: string) {
|
async list(actor: AuthUser, q?: string) {
|
||||||
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
|
await this.assertCanRead(actor);
|
||||||
const canRead =
|
|
||||||
isSuperAdmin ||
|
|
||||||
actor.roles.includes('business_owner') ||
|
|
||||||
actor.roles.includes('business_staff');
|
|
||||||
|
|
||||||
if (!canRead) {
|
|
||||||
throw new ForbiddenException('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const term = q?.trim();
|
const term = q?.trim();
|
||||||
if (term) {
|
if (term) {
|
||||||
@@ -88,4 +99,211 @@ export class BusinessCategoriesService {
|
|||||||
|
|
||||||
return { items: categories };
|
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