Initialize the API and add managed app banners.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:47 +03:30
co-authored by Cursor
commit b15fefe438
158 changed files with 25624 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateProductDto {
@IsString()
companyId!: string;
@IsString()
catalogId!: string;
@IsString()
@MinLength(1)
name!: string;
@IsString()
@MinLength(1)
code!: string;
/** Alias accepted from admin UI */
@IsOptional()
@IsString()
productCode?: string;
@IsString()
@MinLength(1)
color!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
minMeterage?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.001)
orderMultiplier?: number;
/** Alias from admin UI */
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.001)
orderStep?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
basePrice?: number | null;
/** Alias from admin UI */
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
price?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
stock?: number | null;
/** Alias from admin UI */
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
internalStock?: number | null;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsString()
usageCategory?: string;
@IsOptional()
@IsString()
season?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
widthCm?: number | null;
@IsOptional()
@IsString()
composition?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
weightGrams?: number | null;
@IsOptional()
@IsString()
weaveType?: string | null;
@IsOptional()
@IsString()
subtitle?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
rollMeters?: number;
@IsOptional()
@IsBoolean()
isNew?: boolean;
@IsOptional()
colorsJson?: string[];
@IsOptional()
imageUrlsJson?: string[];
@IsOptional()
tagsJson?: string[];
}
export class UpdateProductDto {
@IsOptional()
@IsString()
catalogId?: string;
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsString()
@MinLength(1)
code?: string;
@IsOptional()
@IsString()
productCode?: string;
@IsOptional()
@IsString()
color?: string;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
minMeterage?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.001)
orderMultiplier?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.001)
orderStep?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
basePrice?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
price?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
stock?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
internalStock?: number | null;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsString()
usageCategory?: string;
@IsOptional()
@IsString()
season?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
widthCm?: number | null;
@IsOptional()
@IsString()
composition?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
weightGrams?: number | null;
@IsOptional()
@IsString()
weaveType?: string | null;
@IsOptional()
@IsString()
subtitle?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
rollMeters?: number;
@IsOptional()
@IsBoolean()
isNew?: boolean;
@IsOptional()
colorsJson?: string[];
@IsOptional()
imageUrlsJson?: string[];
@IsOptional()
tagsJson?: string[];
}
export class SetProductActiveDto {
@IsBoolean()
isActive!: boolean;
}
+105
View File
@@ -0,0 +1,105 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ProductsService } from './products.service';
import { AuthType } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import {
CreateProductDto,
SetProductActiveDto,
UpdateProductDto,
} from './dto/product.dto';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@AuthType('any')
@Get('meta/categories')
categories() {
return this.productsService.listCatalogCategories();
}
@AuthType('any')
@Get()
findAll(
@CurrentUser() user: AuthPrincipal | undefined,
@Query('catalogId') catalogId?: string,
@Query('companyId') companyId?: string,
@Query('color') color?: string,
@Query('activeOnly') activeOnly?: string,
@Query('categoryId') categoryId?: string,
@Query('usageCategory') usageCategory?: string,
@Query('season') season?: string,
) {
const isAdmin = user?.type === 'admin';
return this.productsService.findAll({
catalogId,
companyId,
color,
activeOnly: activeOnly === 'true' || activeOnly === '1',
usageCategory: usageCategory || categoryId,
season,
hidePublicPrice: !isAdmin,
});
}
@AuthType('customer')
@Get('favorites/ids')
favoriteIds(@CurrentUser() user: AuthPrincipal) {
return this.productsService.listFavoriteIds(user.id);
}
@AuthType('customer')
@Post(':id/favorite')
toggleFavorite(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
) {
return this.productsService.toggleFavorite(user.id, id);
}
@AuthType('customer')
@Delete(':id/favorite')
removeFavorite(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
) {
return this.productsService.removeFavorite(user.id, id);
}
@AuthType('any')
@Get(':id')
findOne(
@CurrentUser() user: AuthPrincipal | undefined,
@Param('id') id: string,
) {
return this.productsService.findOne(id, user?.type !== 'admin');
}
@AuthType('admin')
@Post()
create(@Body() dto: CreateProductDto) {
return this.productsService.create(dto);
}
@AuthType('admin')
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
return this.productsService.update(id, dto);
}
@AuthType('admin')
@Patch(':id/active')
setActive(@Param('id') id: string, @Body() dto: SetProductActiveDto) {
return this.productsService.setActive(id, dto.isActive);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
exports: [ProductsService],
})
export class ProductsModule {}
+354
View File
@@ -0,0 +1,354 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto, UpdateProductDto } from './dto/product.dto';
import { loadCatalogCategories } from '../catalog/category-defs';
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
listCatalogCategories() {
return loadCatalogCategories(this.prisma);
}
async findAll(filters: {
catalogId?: string;
companyId?: string;
color?: string;
activeOnly?: boolean;
usageCategory?: string;
season?: string;
hidePublicPrice?: boolean;
}) {
const products = await this.prisma.product.findMany({
where: {
...(filters.catalogId ? { catalogId: filters.catalogId } : {}),
...(filters.companyId ? { companyId: filters.companyId } : {}),
...(filters.color
? { color: { contains: filters.color, mode: 'insensitive' } }
: {}),
...(filters.activeOnly ? { isActive: true } : {}),
...(filters.usageCategory
? { usageCategory: filters.usageCategory as never }
: {}),
...(filters.season ? { season: filters.season as never } : {}),
},
orderBy: [{ isNew: 'desc' }, { name: 'asc' }, { color: 'asc' }],
include: {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
},
});
const hidePrice = await this.shouldHidePublicPrice(filters.hidePublicPrice);
return products.map((p) => this.map(p, hidePrice));
}
async findOne(id: string, hidePublicPrice?: boolean) {
const product = await this.prisma.product.findUnique({
where: { id },
include: {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
},
});
if (!product) throw new NotFoundException('محصول یافت نشد');
const hidePrice = await this.shouldHidePublicPrice(hidePublicPrice);
return this.map(product, hidePrice);
}
private async shouldHidePublicPrice(forceHide?: boolean) {
if (forceHide === false) return false;
if (forceHide === true) {
const settings = await this.prisma.systemSettings.findUnique({
where: { id: 'default' },
});
return !(settings?.showCatalogPublicPrice ?? false);
}
return false;
}
async create(dto: CreateProductDto) {
const catalog = await this.prisma.catalog.findUnique({
where: { id: dto.catalogId },
});
if (!catalog) throw new NotFoundException('کاتالوگ یافت نشد');
if (catalog.companyId !== dto.companyId) {
throw new ConflictException('کاتالوگ متعلق به این شرکت نیست');
}
const code = (dto.code || dto.productCode || '').trim();
if (!code) throw new ConflictException('کد محصول الزامی است');
try {
const product = await this.prisma.product.create({
data: {
companyId: dto.companyId,
catalogId: dto.catalogId,
name: dto.name.trim(),
code,
color: dto.color.trim(),
description: dto.description?.trim() || null,
minMeterage: dto.minMeterage ?? 30,
orderMultiplier: dto.orderMultiplier ?? dto.orderStep ?? 10,
basePrice: this.pickNumber(dto.basePrice, dto.price),
stock: this.pickNumber(dto.stock, dto.internalStock),
isActive: dto.isActive ?? true,
usageCategory: (dto.usageCategory as never) ?? undefined,
season: (dto.season as never) ?? undefined,
widthCm: dto.widthCm,
composition: dto.composition?.trim() || null,
weightGrams: dto.weightGrams,
weaveType: dto.weaveType?.trim() || null,
subtitle: dto.subtitle?.trim() || null,
rollMeters: dto.rollMeters,
isNew: dto.isNew,
colorsJson: dto.colorsJson ?? undefined,
imageUrlsJson: dto.imageUrlsJson ?? undefined,
tagsJson: dto.tagsJson ?? undefined,
},
include: {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
},
});
return this.map(product);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('کد محصول در این شرکت تکراری است');
}
throw e;
}
}
async update(id: string, dto: UpdateProductDto) {
await this.ensure(id);
const code =
dto.code !== undefined
? dto.code.trim()
: dto.productCode !== undefined
? dto.productCode.trim()
: undefined;
try {
const product = await this.prisma.product.update({
where: { id },
data: {
catalogId: dto.catalogId,
name: dto.name?.trim(),
code,
color: dto.color?.trim(),
description:
dto.description === undefined
? undefined
: dto.description?.trim() || null,
minMeterage: dto.minMeterage,
orderMultiplier:
dto.orderMultiplier !== undefined
? dto.orderMultiplier
: dto.orderStep,
basePrice:
dto.basePrice !== undefined
? dto.basePrice
: dto.price !== undefined
? dto.price
: undefined,
stock:
dto.stock !== undefined
? dto.stock
: dto.internalStock !== undefined
? dto.internalStock
: undefined,
isActive: dto.isActive,
usageCategory:
dto.usageCategory !== undefined
? (dto.usageCategory as never)
: undefined,
season: dto.season !== undefined ? (dto.season as never) : undefined,
widthCm: dto.widthCm,
composition:
dto.composition === undefined
? undefined
: dto.composition?.trim() || null,
weightGrams: dto.weightGrams,
weaveType:
dto.weaveType === undefined
? undefined
: dto.weaveType?.trim() || null,
subtitle:
dto.subtitle === undefined
? undefined
: dto.subtitle?.trim() || null,
rollMeters: dto.rollMeters,
isNew: dto.isNew,
colorsJson: dto.colorsJson,
imageUrlsJson: dto.imageUrlsJson,
tagsJson: dto.tagsJson,
},
include: {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
},
});
return this.map(product);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('کد محصول در این شرکت تکراری است');
}
throw e;
}
}
async setActive(id: string, isActive: boolean) {
await this.ensure(id);
const product = await this.prisma.product.update({
where: { id },
data: { isActive },
include: {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
},
});
return this.map(product);
}
async listFavoriteIds(customerId: string) {
const rows = await this.prisma.productFavorite.findMany({
where: { customerId },
select: { productId: true },
orderBy: { createdAt: 'desc' },
});
return rows.map((r) => r.productId);
}
async toggleFavorite(customerId: string, productId: string) {
await this.ensure(productId);
const existing = await this.prisma.productFavorite.findUnique({
where: {
customerId_productId: { customerId, productId },
},
});
if (existing) {
await this.prisma.productFavorite.delete({ where: { id: existing.id } });
return { productId, favorited: false };
}
await this.prisma.productFavorite.create({
data: { customerId, productId },
});
return { productId, favorited: true };
}
async removeFavorite(customerId: string, productId: string) {
await this.prisma.productFavorite.deleteMany({
where: { customerId, productId },
});
return { productId, favorited: false };
}
private pickNumber(
primary?: number | null,
alias?: number | null,
): number | null {
if (primary !== undefined) return primary;
if (alias !== undefined) return alias;
return null;
}
private async ensure(id: string) {
const product = await this.prisma.product.findUnique({ where: { id } });
if (!product) throw new NotFoundException('محصول یافت نشد');
return product;
}
private map(
product: {
id: string;
companyId: string;
catalogId: string;
name: string;
code: string;
color: string;
description: string | null;
minMeterage: number;
orderMultiplier: number;
basePrice: number | null;
stock: number | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
company: { name: string };
catalog: { code: string; name: string };
usageCategory?: string;
season?: string;
widthCm?: number | null;
composition?: string | null;
weightGrams?: number | null;
weaveType?: string | null;
colorsJson?: unknown;
tagsJson?: unknown;
imageUrlsJson?: unknown;
subtitle?: string | null;
rollMeters?: number;
isNew?: boolean;
},
hidePrice = false,
) {
const colors = Array.isArray(product.colorsJson)
? (product.colorsJson as string[])
: product.color
? [product.color]
: [];
const tags = Array.isArray(product.tagsJson)
? (product.tagsJson as string[])
: [];
const price = hidePrice ? null : product.basePrice;
return {
id: product.id,
companyId: product.companyId,
companyName: product.company.name,
catalogId: product.catalogId,
catalogCode: product.catalog.code,
catalogName: product.catalog.name,
name: product.name,
code: product.code,
productCode: product.code,
color: product.color,
description: product.description ?? undefined,
minMeterage: product.minMeterage,
orderMultiplier: product.orderMultiplier,
orderStep: product.orderMultiplier,
basePrice: price,
price,
stock: product.stock,
internalStock: product.stock ?? 0,
isActive: product.isActive,
categoryId: product.usageCategory ?? 'other',
usageCategory: product.usageCategory ?? 'other',
season: product.season ?? 'all_season',
widthCm: product.widthCm ?? undefined,
composition: product.composition ?? undefined,
weightGrams: product.weightGrams ?? undefined,
weaveType: product.weaveType ?? undefined,
colors,
tags,
imageUrls: Array.isArray(product.imageUrlsJson)
? product.imageUrlsJson
: [],
subtitle: product.subtitle ?? undefined,
rollMeters: product.rollMeters ?? 50,
isNew: product.isNew ?? false,
createdAt: product.createdAt.toISOString(),
updatedAt: product.updatedAt.toISOString(),
};
}
}