Initial admin edits
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user