Initial commit: Meshkee CMS API
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { CategoryTechnicalFormService } from './category-technical-form.service';
|
||||
import { CategoryTechnicalFormAiService } from './category-technical-form-ai.service';
|
||||
import { CategoryAiService } from './category-ai.service';
|
||||
import { CategoryVariationsService } from './category-variations.service';
|
||||
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
|
||||
import {
|
||||
ReplaceCategoryTechnicalFormDto,
|
||||
SuggestCategoryTechnicalFormDto,
|
||||
} from './dto/category-technical-form.dto';
|
||||
import { ReplaceCategoryVariationsDto } from './dto/category-variation.dto';
|
||||
import { GenerateCategoriesDto } from './dto/category-ai.dto';
|
||||
|
||||
@Controller('businesses/:businessId/categories')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class CategoriesController {
|
||||
constructor(
|
||||
private readonly service: CategoriesService,
|
||||
private readonly variationsService: CategoryVariationsService,
|
||||
private readonly technicalFormService: CategoryTechnicalFormService,
|
||||
private readonly technicalFormAiService: CategoryTechnicalFormAiService,
|
||||
private readonly categoryAiService: CategoryAiService,
|
||||
) {}
|
||||
|
||||
@Get('color-presets')
|
||||
@RequireBusinessPermission('categories.read')
|
||||
listColorPresets() {
|
||||
return this.variationsService.listColorPresets();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('categories.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListCategoriesDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Post('ai-generate')
|
||||
@RequireBusinessPermission('categories.create')
|
||||
generateWithAi(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: GenerateCategoriesDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.categoryAiService.generateProductCategories(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('categories.create')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateCategoryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':categoryId')
|
||||
@RequireBusinessPermission('categories.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@Body() dto: UpdateCategoryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, categoryId, dto, user);
|
||||
}
|
||||
|
||||
@Get(':categoryId/variations')
|
||||
@RequireBusinessPermission('categories.read')
|
||||
listVariations(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.variationsService.listForCategory(businessId, categoryId, user);
|
||||
}
|
||||
|
||||
@Put(':categoryId/variations')
|
||||
@RequireBusinessPermission('categories.update')
|
||||
replaceVariations(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@Body() dto: ReplaceCategoryVariationsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.variationsService.replaceForCategory(
|
||||
businessId,
|
||||
categoryId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':categoryId/technical-form')
|
||||
@RequireBusinessPermission('categories.read')
|
||||
getTechnicalForm(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.technicalFormService.getForCategory(businessId, categoryId, user);
|
||||
}
|
||||
|
||||
@Put(':categoryId/technical-form')
|
||||
@RequireBusinessPermission('categories.update')
|
||||
replaceTechnicalForm(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@Body() dto: ReplaceCategoryTechnicalFormDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.technicalFormService.replaceForCategory(
|
||||
businessId,
|
||||
categoryId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':categoryId/technical-form/ai-suggest')
|
||||
@RequireBusinessPermission('categories.update')
|
||||
suggestTechnicalForm(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@Body() dto: SuggestCategoryTechnicalFormDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.technicalFormAiService.suggestForCategory(
|
||||
businessId,
|
||||
categoryId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':categoryId')
|
||||
@RequireBusinessPermission('categories.delete')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, categoryId, user);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('tenants/:host/categories')
|
||||
export class PublicCategoriesController {
|
||||
constructor(private readonly service: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string, @Query() query: ListCategoriesDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { CategoriesController, PublicCategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { CategoryTechnicalFormAiService } from './category-technical-form-ai.service';
|
||||
import { CategoryAiService } from './category-ai.service';
|
||||
import { CategoryTechnicalFormService } from './category-technical-form.service';
|
||||
import { CategoryVariationsService } from './category-variations.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TenantModule],
|
||||
controllers: [CategoriesController, PublicCategoriesController],
|
||||
providers: [
|
||||
CategoriesService,
|
||||
CategoryVariationsService,
|
||||
CategoryTechnicalFormService,
|
||||
CategoryTechnicalFormAiService,
|
||||
CategoryAiService,
|
||||
],
|
||||
exports: [CategoryVariationsService, CategoryTechnicalFormService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { MediaEntityType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
|
||||
|
||||
function slugify(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'category'
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
) {}
|
||||
|
||||
async list(businessIdRaw: string, query: ListCategoriesDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.read');
|
||||
|
||||
const entityType = query.entityType ?? MediaEntityType.product;
|
||||
|
||||
const items = await this.prisma.category.findMany({
|
||||
where: { businessId, entityType, isActive: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
include: {
|
||||
_count: { select: { variations: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((item) =>
|
||||
this.serialize(item, item._count.variations),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async listPublic(host: string, query: ListCategoriesDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const entityType = query.entityType ?? MediaEntityType.product;
|
||||
|
||||
const items = await this.prisma.category.findMany({
|
||||
where: { businessId, entityType, isActive: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.serialize(item)),
|
||||
};
|
||||
}
|
||||
|
||||
async create(businessIdRaw: string, dto: CreateCategoryDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.create');
|
||||
|
||||
const parentId = dto.parentId ? BigInt(dto.parentId) : null;
|
||||
|
||||
if (parentId) {
|
||||
const parent = await this.prisma.category.findFirst({
|
||||
where: { id: parentId, businessId, entityType: dto.entityType },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new BadRequestException('Parent category not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
const slug = await this.ensureUniqueSlug(
|
||||
businessId,
|
||||
dto.entityType,
|
||||
dto.slug ?? slugify(dto.name),
|
||||
);
|
||||
|
||||
const created = await this.prisma.category.create({
|
||||
data: {
|
||||
businessId,
|
||||
entityType: dto.entityType,
|
||||
parentId,
|
||||
name: dto.name.trim(),
|
||||
nameFa: dto.nameFa?.trim() || null,
|
||||
slug,
|
||||
description: dto.description?.trim() || null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Category created successfully',
|
||||
category: this.serialize(created),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
dto: UpdateCategoryDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.update');
|
||||
|
||||
const existing = await this.prisma.category.findFirst({
|
||||
where: { id: categoryId, businessId },
|
||||
});
|
||||
|
||||
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 = BigInt(dto.parentId);
|
||||
if (parentId === categoryId) {
|
||||
throw new BadRequestException('Category cannot be its own parent');
|
||||
}
|
||||
const parent = await this.prisma.category.findFirst({
|
||||
where: { id: parentId, businessId, entityType: existing.entityType },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new BadRequestException('Parent category not found for this business');
|
||||
}
|
||||
const descendantIds = await this.collectDescendantIds(categoryId);
|
||||
if (descendantIds.includes(parentId)) {
|
||||
throw new BadRequestException('Cannot move category under its own descendant');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let slug = existing.slug;
|
||||
if (dto.slug) {
|
||||
slug = await this.ensureUniqueSlug(
|
||||
businessId,
|
||||
existing.entityType,
|
||||
dto.slug,
|
||||
categoryId,
|
||||
);
|
||||
} else if (dto.name && dto.name !== existing.name) {
|
||||
slug = await this.ensureUniqueSlug(
|
||||
businessId,
|
||||
existing.entityType,
|
||||
slugify(dto.name),
|
||||
categoryId,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.category.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.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Category updated successfully',
|
||||
category: this.serialize(updated),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, categoryIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.delete');
|
||||
|
||||
const existing = await this.prisma.category.findFirst({
|
||||
where: { id: categoryId, businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Category not found');
|
||||
}
|
||||
|
||||
const descendantIds = await this.collectDescendantIds(categoryId);
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.category.deleteMany({
|
||||
where: { id: { in: descendantIds } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
message: 'Category deleted successfully',
|
||||
deletedIds: descendantIds.map((id) => id.toString()),
|
||||
};
|
||||
}
|
||||
|
||||
private async collectDescendantIds(rootId: bigint): Promise<bigint[]> {
|
||||
const all = await this.prisma.category.findMany({
|
||||
where: { businessId: (await this.getBusinessIdForCategory(rootId))! },
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
|
||||
const result: bigint[] = [rootId];
|
||||
const queue = [rootId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const children = all.filter((c) => c.parentId === current).map((c) => c.id);
|
||||
for (const childId of children) {
|
||||
result.push(childId);
|
||||
queue.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async getBusinessIdForCategory(categoryId: bigint) {
|
||||
const category = await this.prisma.category.findUnique({
|
||||
where: { id: categoryId },
|
||||
select: { businessId: true },
|
||||
});
|
||||
return category?.businessId;
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(
|
||||
businessId: bigint,
|
||||
entityType: MediaEntityType,
|
||||
baseSlug: string,
|
||||
excludeId?: bigint,
|
||||
) {
|
||||
let slug = baseSlug;
|
||||
let suffix = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
entityType,
|
||||
slug,
|
||||
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Missing permission: ${permission} for this business`);
|
||||
}
|
||||
}
|
||||
|
||||
private serialize(
|
||||
category: {
|
||||
id: bigint;
|
||||
businessId: bigint;
|
||||
entityType: MediaEntityType;
|
||||
parentId: bigint | null;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
variationCount = 0,
|
||||
) {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
businessId: category.businessId.toString(),
|
||||
entityType: category.entityType,
|
||||
parentId: category.parentId?.toString() ?? null,
|
||||
name: category.name,
|
||||
nameFa: category.nameFa,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
sortOrder: category.sortOrder,
|
||||
isActive: category.isActive,
|
||||
variationCount,
|
||||
createdAt: category.createdAt,
|
||||
updatedAt: category.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MediaEntityType, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import {
|
||||
requestAiJsonCompletion,
|
||||
resolveAiProvider,
|
||||
} from '../common/ai-provider.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { GenerateCategoriesDto } from './dto/category-ai.dto';
|
||||
|
||||
type AiCategoryNode = {
|
||||
nameEn?: string;
|
||||
nameFa?: string;
|
||||
description?: string;
|
||||
children?: AiCategoryNode[];
|
||||
};
|
||||
|
||||
type AiCategoryTreeResponse = {
|
||||
categories?: AiCategoryNode[];
|
||||
};
|
||||
|
||||
function slugify(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'category'
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_DEPTH = 3;
|
||||
const MAX_TOTAL = 40;
|
||||
|
||||
@Injectable()
|
||||
export class CategoryAiService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async generateProductCategories(
|
||||
businessIdRaw: string,
|
||||
dto: GenerateCategoriesDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.create');
|
||||
|
||||
const tree = await this.generateTree(dto.prompt.trim());
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
return this.createTree(
|
||||
tx,
|
||||
businessId,
|
||||
MediaEntityType.product,
|
||||
tree,
|
||||
null,
|
||||
0,
|
||||
{ count: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
message: `Created ${created.length} categor${created.length === 1 ? 'y' : 'ies'} with AI.`,
|
||||
categories: created.map((item) => this.serialize(item)),
|
||||
};
|
||||
}
|
||||
|
||||
private async generateTree(prompt: string): Promise<AiCategoryNode[]> {
|
||||
const provider = resolveAiProvider(this.config);
|
||||
|
||||
const systemPrompt = `You design product category trees for Iranian e-commerce stores.
|
||||
Return ONLY valid JSON with this shape:
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"nameEn": "Category name in English",
|
||||
"nameFa": "نام فارسی",
|
||||
"description": "Short English description",
|
||||
"children": [
|
||||
{
|
||||
"nameEn": "Subcategory",
|
||||
"nameFa": "زیردسته",
|
||||
"description": "Optional description",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Generate a practical category hierarchy based on the user prompt.
|
||||
- Use 2-6 top-level categories when appropriate.
|
||||
- Add subcategories only where they help shoppers browse (max depth ${MAX_DEPTH}).
|
||||
- Every node needs nameEn and nameFa (Farsi in Persian script).
|
||||
- Descriptions are optional, max 120 characters, English only.
|
||||
- Do not duplicate category names at the same level.
|
||||
- Total nodes must not exceed ${MAX_TOTAL}.`;
|
||||
|
||||
const userPrompt = JSON.stringify({ prompt });
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI request failed';
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
|
||||
let parsed: AiCategoryTreeResponse;
|
||||
try {
|
||||
parsed = JSON.parse(content) as AiCategoryTreeResponse;
|
||||
} catch {
|
||||
throw new BadRequestException('AI returned invalid JSON');
|
||||
}
|
||||
|
||||
const categories = Array.isArray(parsed.categories) ? parsed.categories : [];
|
||||
const normalized = this.normalizeNodes(categories, 0);
|
||||
|
||||
if (!normalized.length) {
|
||||
throw new BadRequestException('AI did not return any valid categories');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeNodes(nodes: AiCategoryNode[], depth: number): AiCategoryNode[] {
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: AiCategoryNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const raw of nodes) {
|
||||
const nameEn = String(raw.nameEn ?? '').trim();
|
||||
const nameFa = String(raw.nameFa ?? '').trim();
|
||||
if (!nameEn || nameEn.length < 2) continue;
|
||||
|
||||
const key = nameEn.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const children = Array.isArray(raw.children)
|
||||
? this.normalizeNodes(raw.children, depth + 1)
|
||||
: [];
|
||||
|
||||
result.push({
|
||||
nameEn,
|
||||
nameFa: nameFa || nameEn,
|
||||
description: String(raw.description ?? '').trim().slice(0, 120) || undefined,
|
||||
children,
|
||||
});
|
||||
|
||||
if (result.length >= MAX_TOTAL) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async createTree(
|
||||
tx: Prisma.TransactionClient,
|
||||
businessId: bigint,
|
||||
entityType: MediaEntityType,
|
||||
nodes: AiCategoryNode[],
|
||||
parentId: bigint | null,
|
||||
depth: number,
|
||||
state: { count: number },
|
||||
) {
|
||||
const created: Prisma.CategoryGetPayload<object>[] = [];
|
||||
|
||||
for (let index = 0; index < nodes.length; index += 1) {
|
||||
if (state.count >= MAX_TOTAL) break;
|
||||
|
||||
const node = nodes[index];
|
||||
const slug = await this.ensureUniqueSlug(
|
||||
tx,
|
||||
businessId,
|
||||
entityType,
|
||||
slugify(node.nameEn!),
|
||||
);
|
||||
|
||||
const category = await tx.category.create({
|
||||
data: {
|
||||
businessId,
|
||||
entityType,
|
||||
parentId,
|
||||
name: node.nameEn!,
|
||||
nameFa: node.nameFa?.trim() || null,
|
||||
slug,
|
||||
description: node.description?.trim() || null,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
|
||||
created.push(category);
|
||||
state.count += 1;
|
||||
|
||||
if (node.children?.length && depth + 1 < MAX_DEPTH) {
|
||||
const childCreated = await this.createTree(
|
||||
tx,
|
||||
businessId,
|
||||
entityType,
|
||||
node.children,
|
||||
category.id,
|
||||
depth + 1,
|
||||
state,
|
||||
);
|
||||
created.push(...childCreated);
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(
|
||||
tx: Prisma.TransactionClient,
|
||||
businessId: bigint,
|
||||
entityType: MediaEntityType,
|
||||
baseSlug: string,
|
||||
) {
|
||||
let slug = baseSlug;
|
||||
let suffix = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await tx.category.findFirst({
|
||||
where: { businessId, entityType, slug },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
private serialize(category: Prisma.CategoryGetPayload<object>) {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
businessId: category.businessId.toString(),
|
||||
entityType: category.entityType,
|
||||
parentId: category.parentId?.toString() ?? null,
|
||||
name: category.name,
|
||||
nameFa: category.nameFa,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
sortOrder: category.sortOrder,
|
||||
isActive: category.isActive,
|
||||
variationCount: 0,
|
||||
createdAt: category.createdAt,
|
||||
updatedAt: category.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Missing permission: ${permission} for this business`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MediaEntityType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import {
|
||||
requestAiJsonCompletion,
|
||||
resolveAiProvider,
|
||||
} from '../common/ai-provider.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CategoryTechnicalFormService } from './category-technical-form.service';
|
||||
import {
|
||||
SuggestCategoryTechnicalFormDto,
|
||||
TechnicalFormFieldInputDto,
|
||||
} from './dto/category-technical-form.dto';
|
||||
|
||||
type AiTechnicalFormDraft = {
|
||||
fields?: {
|
||||
label?: string;
|
||||
type?: string;
|
||||
isRequired?: boolean;
|
||||
options?: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES = new Set(['text', 'textarea', 'select', 'multi_select']);
|
||||
|
||||
@Injectable()
|
||||
export class CategoryTechnicalFormAiService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly technicalFormService: CategoryTechnicalFormService,
|
||||
) {}
|
||||
|
||||
async suggestForCategory(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
dto: SuggestCategoryTechnicalFormDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const existing = await this.technicalFormService.getForCategory(
|
||||
businessIdRaw,
|
||||
categoryIdRaw,
|
||||
actor,
|
||||
);
|
||||
|
||||
const categoryName = await this.getCategoryName(businessIdRaw, categoryIdRaw);
|
||||
|
||||
const fields = await this.generateFields({
|
||||
categoryName,
|
||||
hint: dto.hint?.trim(),
|
||||
existingFieldCount: existing.form?.fields.length ?? 0,
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Technical form draft generated. Review fields before saving.',
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
private async getCategoryName(businessIdRaw: string, categoryIdRaw: string) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: BigInt(categoryIdRaw),
|
||||
businessId: BigInt(businessIdRaw),
|
||||
entityType: MediaEntityType.product,
|
||||
isActive: true,
|
||||
},
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundException('Product category not found');
|
||||
}
|
||||
|
||||
return category.name;
|
||||
}
|
||||
|
||||
private async generateFields(input: {
|
||||
categoryName: string;
|
||||
hint?: string;
|
||||
existingFieldCount: number;
|
||||
}): Promise<TechnicalFormFieldInputDto[]> {
|
||||
const provider = resolveAiProvider(this.config);
|
||||
|
||||
const systemPrompt = `You design technical specification forms for e-commerce product categories on an Iranian marketplace.
|
||||
Return ONLY valid JSON with this shape:
|
||||
{
|
||||
"fields": [
|
||||
{
|
||||
"label": "Field label in English",
|
||||
"type": "text | textarea | select | multi_select",
|
||||
"isRequired": true,
|
||||
"options": ["Option A", "Option B"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Generate 5-12 practical fields that buyers need for products in the given category.
|
||||
- Use a mix of text, textarea, select, and multi_select where appropriate.
|
||||
- select and multi_select fields must include 3-8 realistic options.
|
||||
- text fields are for short values (dimensions, weight, model year).
|
||||
- textarea fields are for longer specs (features, care instructions).
|
||||
- Mark important buyer-facing specs as required.
|
||||
- Labels must be unique and human-readable.
|
||||
- Do not include price, stock, SKU, or warranty period fields.
|
||||
- Prefer metric units common in Iran when relevant.`;
|
||||
|
||||
const userPrompt = JSON.stringify({
|
||||
category: input.categoryName,
|
||||
hint: input.hint || null,
|
||||
existingFieldCount: input.existingFieldCount,
|
||||
note:
|
||||
input.existingFieldCount > 0
|
||||
? 'Category already has a form; suggest a fresh complete replacement set.'
|
||||
: 'Category has no form yet.',
|
||||
});
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI request failed';
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
|
||||
let parsed: AiTechnicalFormDraft;
|
||||
try {
|
||||
parsed = JSON.parse(content) as AiTechnicalFormDraft;
|
||||
} catch {
|
||||
throw new BadRequestException('AI returned invalid JSON');
|
||||
}
|
||||
|
||||
return this.normalizeFields(parsed);
|
||||
}
|
||||
|
||||
private normalizeFields(draft: AiTechnicalFormDraft): TechnicalFormFieldInputDto[] {
|
||||
const rawFields = Array.isArray(draft.fields) ? draft.fields : [];
|
||||
const labels = new Set<string>();
|
||||
const fields: TechnicalFormFieldInputDto[] = [];
|
||||
|
||||
for (const raw of rawFields) {
|
||||
const label = String(raw.label ?? '').trim();
|
||||
if (!label) continue;
|
||||
|
||||
const normalizedLabel = label.toLowerCase();
|
||||
if (labels.has(normalizedLabel)) continue;
|
||||
labels.add(normalizedLabel);
|
||||
|
||||
const type = ALLOWED_TYPES.has(String(raw.type))
|
||||
? (raw.type as TechnicalFormFieldInputDto['type'])
|
||||
: 'text';
|
||||
|
||||
const field: TechnicalFormFieldInputDto = {
|
||||
label,
|
||||
type,
|
||||
isRequired: Boolean(raw.isRequired),
|
||||
};
|
||||
|
||||
if (type === 'select' || type === 'multi_select') {
|
||||
const options = Array.isArray(raw.options)
|
||||
? [...new Set(raw.options.map((option) => String(option).trim()).filter(Boolean))]
|
||||
: [];
|
||||
|
||||
if (!options.length) continue;
|
||||
field.options = options.slice(0, 12);
|
||||
}
|
||||
|
||||
fields.push(field);
|
||||
if (fields.length >= 12) break;
|
||||
}
|
||||
|
||||
if (!fields.length) {
|
||||
throw new BadRequestException('AI did not return any valid technical form fields');
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { MediaEntityType, TechnicalFieldType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
ReplaceCategoryTechnicalFormDto,
|
||||
TechnicalFormFieldInputDto,
|
||||
} from './dto/category-technical-form.dto';
|
||||
|
||||
function slugifyKey(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'field'
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CategoryTechnicalFormService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async getForCategory(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.read');
|
||||
await this.getProductCategory(businessId, categoryId);
|
||||
|
||||
const form = await this.prisma.categoryTechnicalForm.findUnique({
|
||||
where: { categoryId },
|
||||
include: {
|
||||
fields: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
options: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
return { form: null };
|
||||
}
|
||||
|
||||
return { form: this.serializeForm(form) };
|
||||
}
|
||||
|
||||
async replaceForCategory(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
dto: ReplaceCategoryTechnicalFormDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.update');
|
||||
await this.getProductCategory(businessId, categoryId);
|
||||
|
||||
this.validateFieldInputs(dto.fields);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.categoryTechnicalForm.deleteMany({
|
||||
where: { businessId, categoryId },
|
||||
});
|
||||
|
||||
if (!dto.fields.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = await tx.categoryTechnicalForm.create({
|
||||
data: { businessId, categoryId },
|
||||
});
|
||||
|
||||
const usedKeys = new Set<string>();
|
||||
|
||||
for (const [index, field] of dto.fields.entries()) {
|
||||
let fieldKey = slugifyKey(field.label);
|
||||
if (usedKeys.has(fieldKey)) {
|
||||
let suffix = 2;
|
||||
while (usedKeys.has(`${fieldKey}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
fieldKey = `${fieldKey}-${suffix}`;
|
||||
}
|
||||
usedKeys.add(fieldKey);
|
||||
|
||||
const createdField = await tx.categoryTechnicalFormField.create({
|
||||
data: {
|
||||
formId: form.id,
|
||||
label: field.label.trim(),
|
||||
fieldKey,
|
||||
fieldType: field.type as TechnicalFieldType,
|
||||
isRequired: field.isRequired ?? false,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
|
||||
if (field.type === 'select' || field.type === 'multi_select') {
|
||||
const uniqueOptions = [
|
||||
...new Set(field.options!.map((o) => o.trim()).filter(Boolean)),
|
||||
];
|
||||
|
||||
await tx.categoryTechnicalFormFieldOption.createMany({
|
||||
data: uniqueOptions.map((label, optionIndex) => ({
|
||||
fieldId: createdField.id,
|
||||
label,
|
||||
value: slugifyKey(label) || `option-${optionIndex + 1}`,
|
||||
sortOrder: optionIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.getForCategory(businessIdRaw, categoryIdRaw, actor);
|
||||
}
|
||||
|
||||
async getFormForCategory(businessId: bigint, categoryId: bigint) {
|
||||
const form = await this.prisma.categoryTechnicalForm.findUnique({
|
||||
where: { categoryId },
|
||||
include: {
|
||||
fields: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
options: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!form || form.businessId !== businessId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.serializeForm(form);
|
||||
}
|
||||
|
||||
private validateFieldInputs(fields: TechnicalFormFieldInputDto[]) {
|
||||
const labels = new Set<string>();
|
||||
|
||||
for (const field of fields) {
|
||||
const label = field.label.trim().toLowerCase();
|
||||
if (!label) {
|
||||
throw new BadRequestException('Each field must have a label');
|
||||
}
|
||||
if (labels.has(label)) {
|
||||
throw new BadRequestException(
|
||||
`Duplicate field label "${field.label}"`,
|
||||
);
|
||||
}
|
||||
labels.add(label);
|
||||
|
||||
if (field.type === 'select' || field.type === 'multi_select') {
|
||||
const options = field.options?.map((o) => o.trim()).filter(Boolean) ?? [];
|
||||
if (!options.length) {
|
||||
throw new BadRequestException(
|
||||
`Field "${field.label}" requires at least one option`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getProductCategory(businessId: bigint, categoryId: bigint) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: categoryId,
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundException('Product category not found');
|
||||
}
|
||||
|
||||
return category;
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private serializeForm(form: {
|
||||
id: bigint;
|
||||
categoryId: bigint;
|
||||
fields: {
|
||||
id: bigint;
|
||||
label: string;
|
||||
fieldKey: string;
|
||||
fieldType: TechnicalFieldType;
|
||||
isRequired: boolean;
|
||||
sortOrder: number;
|
||||
options: {
|
||||
id: bigint;
|
||||
label: string;
|
||||
value: string;
|
||||
sortOrder: number;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
return {
|
||||
id: form.id.toString(),
|
||||
categoryId: form.categoryId.toString(),
|
||||
fields: form.fields.map((field) => ({
|
||||
id: field.id.toString(),
|
||||
label: field.label,
|
||||
key: field.fieldKey,
|
||||
type: field.fieldType,
|
||||
isRequired: field.isRequired,
|
||||
sortOrder: field.sortOrder,
|
||||
options:
|
||||
field.fieldType === 'select' || field.fieldType === 'multi_select'
|
||||
? field.options.map((option) => ({
|
||||
id: option.id.toString(),
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
sortOrder: option.sortOrder,
|
||||
}))
|
||||
: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { MediaEntityType, VariationType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
COLOR_PRESETS,
|
||||
getColorHex,
|
||||
isColorPreset,
|
||||
} from './color-presets';
|
||||
import {
|
||||
CategoryVariationInputDto,
|
||||
ReplaceCategoryVariationsDto,
|
||||
} from './dto/category-variation.dto';
|
||||
|
||||
function slugifyValue(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CategoryVariationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
listColorPresets() {
|
||||
return { items: COLOR_PRESETS };
|
||||
}
|
||||
|
||||
async listForCategory(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.read');
|
||||
await this.getProductCategory(businessId, categoryId);
|
||||
|
||||
const variations = await this.prisma.categoryVariation.findMany({
|
||||
where: { businessId, categoryId },
|
||||
include: { options: { orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return { items: variations.map((item) => this.serialize(item)) };
|
||||
}
|
||||
|
||||
async replaceForCategory(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
dto: ReplaceCategoryVariationsDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.update');
|
||||
await this.getProductCategory(businessId, categoryId);
|
||||
|
||||
this.validateVariationInputs(dto.variations);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.categoryVariation.deleteMany({
|
||||
where: { businessId, categoryId },
|
||||
});
|
||||
|
||||
for (const [index, variation] of dto.variations.entries()) {
|
||||
const created = await tx.categoryVariation.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId,
|
||||
name: this.resolveVariationName(variation),
|
||||
variationType: variation.type as VariationType,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
|
||||
const uniqueValues = [...new Set(variation.values.map((v) => v.trim()).filter(Boolean))];
|
||||
|
||||
await tx.categoryVariationOption.createMany({
|
||||
data: uniqueValues.map((label, optionIndex) => ({
|
||||
variationId: created.id,
|
||||
label,
|
||||
value: slugifyValue(label) || `option-${optionIndex + 1}`,
|
||||
colorHex:
|
||||
variation.type === 'color' ? getColorHex(label) : null,
|
||||
sortOrder: optionIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return this.listForCategory(businessIdRaw, categoryIdRaw, actor);
|
||||
}
|
||||
|
||||
async getForProductCategory(businessId: bigint, categoryId: bigint) {
|
||||
const variations = await this.prisma.categoryVariation.findMany({
|
||||
where: { businessId, categoryId },
|
||||
include: { options: { orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return variations.map((item) => this.serialize(item));
|
||||
}
|
||||
|
||||
private validateVariationInputs(variations: CategoryVariationInputDto[]) {
|
||||
const colorCount = variations.filter((v) => v.type === 'color').length;
|
||||
const sizeCount = variations.filter((v) => v.type === 'size').length;
|
||||
|
||||
if (colorCount > 1) {
|
||||
throw new BadRequestException('Only one color variation is allowed per category');
|
||||
}
|
||||
|
||||
if (sizeCount > 1) {
|
||||
throw new BadRequestException('Only one size variation is allowed per category');
|
||||
}
|
||||
|
||||
const customNames = new Set<string>();
|
||||
|
||||
for (const variation of variations) {
|
||||
if (variation.type === 'color') {
|
||||
for (const value of variation.values) {
|
||||
if (!isColorPreset(value.trim())) {
|
||||
throw new BadRequestException(
|
||||
`Invalid color "${value}". Choose from the predefined color palette.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (variation.type === 'size' || variation.type === 'custom') {
|
||||
const values = variation.values.map((v) => v.trim()).filter(Boolean);
|
||||
if (!values.length) {
|
||||
throw new BadRequestException('Each variation must have at least one value');
|
||||
}
|
||||
}
|
||||
|
||||
if (variation.type === 'custom') {
|
||||
const name = variation.name.trim().toLowerCase();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Custom variations require a name');
|
||||
}
|
||||
if (customNames.has(name)) {
|
||||
throw new BadRequestException(
|
||||
`Duplicate custom variation name "${variation.name}"`,
|
||||
);
|
||||
}
|
||||
customNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveVariationName(variation: CategoryVariationInputDto): string {
|
||||
if (variation.type === 'color') {
|
||||
return 'Color';
|
||||
}
|
||||
if (variation.type === 'size') {
|
||||
return 'Size';
|
||||
}
|
||||
return variation.name.trim();
|
||||
}
|
||||
|
||||
private async getProductCategory(businessId: bigint, categoryId: bigint) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: categoryId,
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundException('Product category not found');
|
||||
}
|
||||
|
||||
return category;
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Missing permission: ${permission} for this business`);
|
||||
}
|
||||
}
|
||||
|
||||
private serialize(variation: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
variationType: VariationType;
|
||||
sortOrder: number;
|
||||
options: {
|
||||
id: bigint;
|
||||
label: string;
|
||||
value: string;
|
||||
colorHex: string | null;
|
||||
sortOrder: number;
|
||||
}[];
|
||||
}) {
|
||||
return {
|
||||
id: variation.id.toString(),
|
||||
name: variation.name,
|
||||
type: variation.variationType,
|
||||
sortOrder: variation.sortOrder,
|
||||
values: variation.options.map((option) => option.label),
|
||||
options: variation.options.map((option) => ({
|
||||
id: option.id.toString(),
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
colorHex: option.colorHex,
|
||||
sortOrder: option.sortOrder,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export const COLOR_PRESETS = [
|
||||
{ name: 'Red', hex: '#EF4444' },
|
||||
{ name: 'Blue', hex: '#3B82F6' },
|
||||
{ name: 'Green', hex: '#22C55E' },
|
||||
{ name: 'Black', hex: '#111827' },
|
||||
{ name: 'White', hex: '#FFFFFF' },
|
||||
{ name: 'Silver', hex: '#C0C0C0' },
|
||||
{ name: 'Gold', hex: '#D4AF37' },
|
||||
{ name: 'Navy', hex: '#1E3A5F' },
|
||||
{ name: 'Yellow', hex: '#EAB308' },
|
||||
{ name: 'Orange', hex: '#F97316' },
|
||||
{ name: 'Purple', hex: '#A855F7' },
|
||||
{ name: 'Pink', hex: '#EC4899' },
|
||||
{ name: 'Brown', hex: '#92400E' },
|
||||
{ name: 'Gray', hex: '#6B7280' },
|
||||
{ name: 'Beige', hex: '#D4C4A8' },
|
||||
] as const;
|
||||
|
||||
export const COLOR_PRESET_NAMES = COLOR_PRESETS.map((color) => color.name);
|
||||
|
||||
export function isColorPreset(value: string): boolean {
|
||||
return COLOR_PRESET_NAMES.some((name) => name === value);
|
||||
}
|
||||
|
||||
export function getColorHex(name: string): string | null {
|
||||
return COLOR_PRESETS.find((color) => color.name === name)?.hex ?? null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class GenerateCategoriesDto {
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
prompt!: string;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class TechnicalFormFieldInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
label!: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['text', 'textarea', 'select', 'multi_select'])
|
||||
type!: 'text' | 'textarea' | 'select' | 'multi_select';
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRequired?: boolean;
|
||||
|
||||
@ValidateIf((o: TechnicalFormFieldInputDto) =>
|
||||
o.type === 'select' || o.type === 'multi_select',
|
||||
)
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export class ReplaceCategoryTechnicalFormDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TechnicalFormFieldInputDto)
|
||||
fields!: TechnicalFormFieldInputDto[];
|
||||
}
|
||||
|
||||
export class SuggestCategoryTechnicalFormDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
hint?: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CategoryVariationInputDto {
|
||||
@IsString()
|
||||
@IsIn(['color', 'size', 'custom'])
|
||||
type!: 'color' | 'size' | 'custom';
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
values!: string[];
|
||||
}
|
||||
|
||||
export class ReplaceCategoryVariationsDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CategoryVariationInputDto)
|
||||
variations!: CategoryVariationInputDto[];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { MediaEntityType } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Matches, Min, MinLength } from 'class-validator';
|
||||
|
||||
export class ListCategoriesDto {
|
||||
@IsOptional()
|
||||
@IsEnum(MediaEntityType)
|
||||
entityType?: MediaEntityType;
|
||||
}
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@IsEnum(MediaEntityType)
|
||||
entityType!: MediaEntityType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
nameFa?: string;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
export class UpdateCategoryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameFa?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user