Initial commit: Mana Collection NestJS backend.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ProductVariantDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
colorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sizeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
packageId?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
price!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
discountPrice?: number;
|
||||
}
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
nameFa!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
summary?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
price!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
discountPrice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
brandId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
galleryUrls?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
galleryKeys?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
published?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ProductVariantDto)
|
||||
variants?: ProductVariantDto[];
|
||||
|
||||
/** SUPER_ADMIN only */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storeId?: string;
|
||||
}
|
||||
|
||||
export class UpdateProductDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
nameFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
summary?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
discountPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
brandId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageKey?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
galleryUrls?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
galleryKeys?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
published?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ProductVariantDto)
|
||||
variants?: ProductVariantDto[];
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import type { AuthUser } from '../auth/auth.types';
|
||||
import {
|
||||
CreateProductDto,
|
||||
UpdateProductDto,
|
||||
} from './dto/create-product.dto';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Controller('products')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN)
|
||||
export class ProductsController {
|
||||
constructor(private readonly products: ProductsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('storeId') storeId?: string,
|
||||
@Query('name') name?: string,
|
||||
@Query('categoryId') categoryId?: string,
|
||||
@Query('minPrice') minPrice?: number,
|
||||
@Query('maxPrice') maxPrice?: number,
|
||||
) {
|
||||
return this.products.list(user, {
|
||||
storeId,
|
||||
name,
|
||||
categoryId,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.products.findOne(user, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateProductDto) {
|
||||
return this.products.create(user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateProductDto,
|
||||
) {
|
||||
return this.products.update(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.products.remove(user, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StoreScopeService } from '../common/store-scope.service';
|
||||
import { ProductsController } from './products.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ProductsController],
|
||||
providers: [ProductsService, StoreScopeService],
|
||||
exports: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma, UserRole } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { StoreScopeService } from '../common/store-scope.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CreateProductDto,
|
||||
UpdateProductDto,
|
||||
} from './dto/create-product.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storeScope: StoreScopeService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
actor: AuthUser,
|
||||
query: {
|
||||
storeId?: string;
|
||||
name?: string;
|
||||
categoryId?: string;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
},
|
||||
) {
|
||||
const storeId =
|
||||
actor.role === UserRole.SUPER_ADMIN
|
||||
? query.storeId
|
||||
: (await this.storeScope.requireActiveStore(actor)).id;
|
||||
|
||||
const where: Prisma.ProductWhereInput = {
|
||||
...(storeId ? { storeId } : {}),
|
||||
...(query.categoryId ? { categoryId: query.categoryId } : {}),
|
||||
...(query.name
|
||||
? {
|
||||
OR: [
|
||||
{ nameFa: { contains: query.name, mode: 'insensitive' } },
|
||||
{ nameEn: { contains: query.name, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(query.minPrice !== undefined || query.maxPrice !== undefined
|
||||
? {
|
||||
price: {
|
||||
...(query.minPrice !== undefined ? { gte: query.minPrice } : {}),
|
||||
...(query.maxPrice !== undefined ? { lte: query.maxPrice } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return this.prisma.product.findMany({
|
||||
where,
|
||||
include: {
|
||||
variants: true,
|
||||
category: { select: { id: true, nameFa: true } },
|
||||
brand: { select: { id: true, nameFa: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(actor: AuthUser, id: string) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: { variants: true, category: true, brand: true },
|
||||
});
|
||||
if (!product) {
|
||||
throw new NotFoundException('محصول یافت نشد');
|
||||
}
|
||||
await this.assertStoreAccess(actor, product.storeId);
|
||||
return product;
|
||||
}
|
||||
|
||||
async create(actor: AuthUser, dto: CreateProductDto) {
|
||||
const store = await this.storeScope.requireActiveStore(
|
||||
actor,
|
||||
dto.storeId,
|
||||
);
|
||||
|
||||
return this.prisma.product.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
nameFa: dto.nameFa,
|
||||
nameEn: dto.nameEn ?? '',
|
||||
summary: dto.summary ?? '',
|
||||
description: dto.description ?? '',
|
||||
price: dto.price,
|
||||
discountPrice: dto.discountPrice,
|
||||
categoryId: dto.categoryId,
|
||||
brandId: dto.brandId,
|
||||
imageUrl: dto.imageUrl,
|
||||
imageKey: dto.imageKey,
|
||||
galleryUrls: dto.galleryUrls ?? [],
|
||||
galleryKeys: dto.galleryKeys ?? [],
|
||||
tags: dto.tags ?? [],
|
||||
published: dto.published ?? true,
|
||||
variants: dto.variants?.length
|
||||
? {
|
||||
create: dto.variants.map((v) => ({
|
||||
colorId: v.colorId ?? '',
|
||||
sizeId: v.sizeId ?? '',
|
||||
packageId: v.packageId ?? '',
|
||||
price: v.price,
|
||||
discountPrice: v.discountPrice,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { variants: true },
|
||||
});
|
||||
}
|
||||
|
||||
async update(actor: AuthUser, id: string, dto: UpdateProductDto) {
|
||||
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException('محصول یافت نشد');
|
||||
}
|
||||
await this.assertStoreAccess(actor, existing.storeId);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (dto.variants) {
|
||||
await tx.productVariant.deleteMany({ where: { productId: id } });
|
||||
if (dto.variants.length) {
|
||||
await tx.productVariant.createMany({
|
||||
data: dto.variants.map((v) => ({
|
||||
productId: id,
|
||||
colorId: v.colorId ?? '',
|
||||
sizeId: v.sizeId ?? '',
|
||||
packageId: v.packageId ?? '',
|
||||
price: v.price,
|
||||
discountPrice: v.discountPrice,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.product.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
|
||||
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
|
||||
...(dto.summary !== undefined ? { summary: dto.summary } : {}),
|
||||
...(dto.description !== undefined
|
||||
? { description: dto.description }
|
||||
: {}),
|
||||
...(dto.price !== undefined ? { price: dto.price } : {}),
|
||||
...(dto.discountPrice !== undefined
|
||||
? { discountPrice: dto.discountPrice }
|
||||
: {}),
|
||||
...(dto.categoryId !== undefined
|
||||
? { categoryId: dto.categoryId }
|
||||
: {}),
|
||||
...(dto.brandId !== undefined ? { brandId: dto.brandId } : {}),
|
||||
...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl } : {}),
|
||||
...(dto.imageKey !== undefined ? { imageKey: dto.imageKey } : {}),
|
||||
...(dto.galleryUrls !== undefined
|
||||
? { galleryUrls: dto.galleryUrls }
|
||||
: {}),
|
||||
...(dto.galleryKeys !== undefined
|
||||
? { galleryKeys: dto.galleryKeys }
|
||||
: {}),
|
||||
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||
...(dto.published !== undefined ? { published: dto.published } : {}),
|
||||
},
|
||||
include: { variants: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async remove(actor: AuthUser, id: string) {
|
||||
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException('محصول یافت نشد');
|
||||
}
|
||||
await this.assertStoreAccess(actor, existing.storeId);
|
||||
await this.prisma.product.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async assertStoreAccess(actor: AuthUser, storeId: string) {
|
||||
if (actor.role === UserRole.SUPER_ADMIN) return;
|
||||
const store = await this.storeScope.requireActiveStore(actor);
|
||||
if (store.id !== storeId) {
|
||||
throw new NotFoundException('محصول یافت نشد');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user