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:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
+176
View File
@@ -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);
}
}
+23
View File
@@ -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 {}
+320
View File
@@ -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,
};
}
}
+280
View File
@@ -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,
})),
};
}
}
+27
View File
@@ -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;
}
+7
View File
@@ -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[];
}
+77
View File
@@ -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;
}