Initial admin edits

This commit is contained in:
2026-10-01 21:02:58 +03:30
parent ac0cd63dbd
commit 301f3ff1c8
50 changed files with 1191 additions and 867 deletions
+52 -1
View File
@@ -1,9 +1,16 @@
import { ValidationPipe } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
colorVariantImageLists,
parseColorEntries,
parseGeneralImageUrls,
} from './color-entries';
import { serializeColorsJsonForDb } from './dto/product-color-json.dto';
import {
ProductColorJsonItemDto,
serializeColorsJsonForDb,
} from './dto/product-color-json.dto';
import { UpdateProductDto } from './dto/product.dto';
describe('product image separation', () => {
it('keeps general images separate from color variant urls', () => {
@@ -66,6 +73,50 @@ describe('product image separation', () => {
]);
});
it('accepts colorsJson through Nest ValidationPipe (same as PATCH /products)', async () => {
const pipe = new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
transformOptions: { enableImplicitConversion: true },
});
const body = {
name: 'تست',
color: 'قرمز',
colorsJson: [
{
name: 'قرمز شرابی',
imageUrl: 'data:image/jpeg;base64,abc',
imageUrls: ['data:image/jpeg;base64,abc'],
},
],
};
const dto = await pipe.transform(body, {
type: 'body',
metatype: UpdateProductDto,
});
expect(dto.colorsJson?.[0]).toBeInstanceOf(ProductColorJsonItemDto);
});
it('accepts colorsJson name/imageUrl/imageUrls under whitelist validation', async () => {
const dto = plainToInstance(UpdateProductDto, {
name: 'تست',
colorsJson: [
{
name: 'قرمز شرابی',
imageUrl: 'data:image/jpeg;base64,abc',
imageUrls: ['data:image/jpeg;base64,abc'],
},
],
});
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
});
expect(errors).toEqual([]);
expect(dto.colorsJson?.[0]).toBeInstanceOf(ProductColorJsonItemDto);
});
it('does not merge color images into general list when only imageUrlsJson is set', () => {
const entries = parseColorEntries([{ name: 'Red', imageUrl: 'https://cdn/r.jpg' }]);
expect(parseGeneralImageUrls(['https://cdn/g.jpg'], entries)).toEqual([
+59 -7
View File
@@ -1,4 +1,54 @@
import { IsArray, IsOptional, IsString, MinLength } from 'class-validator';
import {
IsArray,
IsOptional,
IsString,
MinLength,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
type ValidationOptions,
} from 'class-validator';
@ValidatorConstraint({ name: 'isColorsJson', async: false })
export class IsColorsJsonConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
if (value === undefined || value === null) return true;
if (!Array.isArray(value)) return false;
for (const item of value) {
if (!(item instanceof ProductColorJsonItemDto)) return false;
if (!item.name?.trim()) return false;
if (
item.imageUrl !== undefined &&
item.imageUrl !== null &&
typeof item.imageUrl !== 'string'
) {
return false;
}
if (item.imageUrls !== undefined && item.imageUrls !== null) {
if (!Array.isArray(item.imageUrls)) return false;
if (!item.imageUrls.every((u) => typeof u === 'string')) return false;
}
}
return true;
}
defaultMessage(): string {
return 'فرمت colorsJson نامعتبر است';
}
}
/** اعتبارسنجی colorsJson بعد از transform — بدون ValidateNested (سازگار با whitelist) */
export function IsColorsJson(validationOptions?: ValidationOptions) {
return function register(object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: IsColorsJsonConstraint,
});
};
}
export class ProductColorJsonItemDto {
@IsString()
@@ -38,7 +88,7 @@ export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] |
for (const item of value) {
if (typeof item === 'string') {
const name = item.trim();
if (name) out.push({ name });
if (name) out.push(Object.assign(new ProductColorJsonItemDto(), { name }));
continue;
}
if (item && typeof item === 'object') {
@@ -54,11 +104,13 @@ export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] |
typeof raw.imageUrl === 'string' && raw.imageUrl.trim()
? raw.imageUrl.trim()
: undefined;
out.push({
name,
imageUrl,
imageUrls: imageUrls?.length ? imageUrls : imageUrl ? [imageUrl] : undefined,
});
out.push(
Object.assign(new ProductColorJsonItemDto(), {
name,
imageUrl,
imageUrls: imageUrls?.length ? imageUrls : imageUrl ? [imageUrl] : undefined,
}),
);
}
}
return out;
+17 -10
View File
@@ -7,21 +7,19 @@ import {
IsString,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import {
IsColorsJson,
ProductColorJsonItemDto,
transformColorsJson,
} from './product-color-json.dto';
export class CreateProductDto {
@IsString()
companyId!: string;
/** خالی = شرکت پیش‌فرض (نادمی تکس) */
@IsOptional()
@IsString()
brandId?: string | null;
companyId?: string;
@IsString()
@MinLength(1)
@@ -44,6 +42,11 @@ export class CreateProductDto {
@IsString()
description?: string;
/** مشخصات آزاد — نمایش در specs-extra اپ */
@IsOptional()
@IsString()
specs?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@@ -139,8 +142,7 @@ export class CreateProductDto {
@IsOptional()
@Transform(({ value }) => transformColorsJson(value))
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductColorJsonItemDto)
@IsColorsJson()
colorsJson?: ProductColorJsonItemDto[];
@IsOptional()
@@ -149,18 +151,23 @@ export class CreateProductDto {
imageUrlsJson?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
tagsJson?: string[];
}
/** همهٔ فیلدهای Create به‌جز companyId — شامل brandId برای PATCH ادمین */
/** همهٔ فیلدهای Create به‌جز companyId — companyId در PATCH برای تغییر شرکت محصول */
export class UpdateProductDto extends PartialType(
OmitType(CreateProductDto, ['companyId'] as const),
) {
@IsOptional()
@IsString()
companyId?: string;
@IsOptional()
@Transform(({ value }) => transformColorsJson(value))
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductColorJsonItemDto)
@IsColorsJson()
colorsJson?: ProductColorJsonItemDto[];
}
+20
View File
@@ -0,0 +1,20 @@
import { isProductUnavailableForApp } from './product-availability';
describe('isProductUnavailableForApp', () => {
it('treats admin-inactive products as unavailable', () => {
expect(isProductUnavailableForApp({ isActive: false, stock: 10 })).toBe(
true,
);
});
it('keeps active products available when stock is unset or positive', () => {
expect(isProductUnavailableForApp({ isActive: true })).toBe(false);
expect(isProductUnavailableForApp({ isActive: true, stock: 4 })).toBe(
false,
);
});
it('treats zero stock as unavailable', () => {
expect(isProductUnavailableForApp({ isActive: true, stock: 0 })).toBe(true);
});
});
+8
View File
@@ -0,0 +1,8 @@
/** App lists keep inactive products; they are shown as unavailable via `isActive`. */
export function isProductUnavailableForApp(product: {
isActive: boolean;
stock?: number | null;
}): boolean {
if (!product.isActive) return true;
return product.stock != null && product.stock <= 0;
}
+6
View File
@@ -109,4 +109,10 @@ export class ProductsController {
setActive(@Param('id') id: string, @Body() dto: SetProductActiveDto) {
return this.productsService.setActive(id, dto.isActive);
}
@AuthType('admin')
@Delete(':id')
remove(@Param('id') id: string) {
return this.productsService.remove(id);
}
}
+48 -22
View File
@@ -1,5 +1,4 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
@@ -9,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto, UpdateProductDto } from './dto/product.dto';
import { serializeColorsJsonForDb } from './dto/product-color-json.dto';
import { loadCatalogCategories } from '../catalog/category-defs';
import { resolveProductCompanyId } from '../companies/default-company';
import {
colorVariantImageLists,
parseColorEntries,
@@ -17,7 +17,6 @@ import {
const productInclude = {
company: { select: { name: true } },
brand: { select: { id: true, name: true } },
};
@Injectable()
@@ -118,17 +117,20 @@ export class ProductsService {
async create(dto: CreateProductDto, preservedColorsJson?: unknown) {
const code = (dto.code || dto.productCode || '').trim();
if (!code) throw new ConflictException('کد محصول الزامی است');
const brandId = await this.resolveBrandId(dto.brandId ?? null);
const companyId = await resolveProductCompanyId(
this.prisma,
dto.companyId,
);
try {
const product = await this.prisma.product.create({
data: {
companyId: dto.companyId,
brandId,
companyId,
name: dto.name.trim(),
code,
color: dto.color.trim(),
description: dto.description?.trim() || null,
specs: dto.specs?.trim() || null,
minMeterage: dto.minMeterage ?? 30,
orderMultiplier: dto.orderMultiplier ?? dto.orderStep ?? 10,
basePrice: this.pickNumber(dto.basePrice, dto.price),
@@ -177,13 +179,18 @@ export class ProductsService {
: dto.productCode !== undefined
? dto.productCode.trim()
: undefined;
const brandId = await this.resolveBrandId(dto.brandId);
try {
const product = await this.prisma.product.update({
where: { id },
data: {
...(brandId !== undefined ? { brandId } : {}),
...(dto.companyId !== undefined
? {
companyId: await resolveProductCompanyId(
this.prisma,
dto.companyId,
),
}
: {}),
name: dto.name?.trim(),
code,
color: dto.color?.trim(),
@@ -191,6 +198,8 @@ export class ProductsService {
dto.description === undefined
? undefined
: dto.description?.trim() || null,
specs:
dto.specs === undefined ? undefined : dto.specs?.trim() || null,
minMeterage: dto.minMeterage,
orderMultiplier:
dto.orderMultiplier !== undefined
@@ -268,6 +277,35 @@ export class ProductsService {
return this.map(product);
}
async remove(id: string) {
await this.ensure(id);
const catalogRequestCount = await this.prisma.catalogRequestItem.count({
where: { productId: id },
});
if (catalogRequestCount > 0) {
throw new ConflictException(
'این محصول در درخواست کالیته ثبت شده و قابل حذف نیست.',
);
}
try {
await this.prisma.$transaction([
this.prisma.cartItem.deleteMany({ where: { productId: id } }),
this.prisma.product.delete({ where: { id } }),
]);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2003'
) {
throw new ConflictException(
'این محصول به داده‌های دیگر وابسته است و قابل حذف نیست.',
);
}
throw e;
}
return { ok: true };
}
async listFavoriteIds(customerId: string) {
const rows = await this.prisma.productFavorite.findMany({
where: { customerId },
@@ -316,17 +354,6 @@ export class ProductsService {
return product;
}
private async resolveBrandId(
brandId?: string | null,
): Promise<string | null | undefined> {
if (brandId === undefined) return undefined;
const id = brandId?.trim() || null;
if (!id) return null;
const brand = await this.prisma.brand.findUnique({ where: { id } });
if (!brand) throw new BadRequestException('برند یافت نشد');
return id;
}
private map(
product: {
id: string;
@@ -335,6 +362,7 @@ export class ProductsService {
code: string;
color: string;
description: string | null;
specs?: string | null;
minMeterage: number;
orderMultiplier: number;
basePrice: number | null;
@@ -343,7 +371,6 @@ export class ProductsService {
createdAt: Date;
updatedAt: Date;
company: { name: string };
brand?: { id: string; name: string } | null;
usageCategory?: string;
season?: string;
widthCm?: number | null;
@@ -381,13 +408,12 @@ export class ProductsService {
catalogCode: product.code,
/** @deprecated use `name` */
catalogName: product.name,
brandId: product.brand?.id ?? null,
brandName: product.brand?.name ?? null,
name: product.name,
code: product.code,
productCode: product.code,
color: product.color,
description: product.description ?? undefined,
specs: product.specs?.trim() ? product.specs : undefined,
minMeterage: product.minMeterage,
orderMultiplier: product.orderMultiplier,
orderStep: product.orderMultiplier,