Initial commit: Mana Collection NestJS backend.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-07 20:49:55 +03:30
co-authored by Cursor
commit fe78a99b78
87 changed files with 10557 additions and 0 deletions
+181
View File
@@ -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[];
}
+71
View File
@@ -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);
}
}
+11
View File
@@ -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 {}
+190
View File
@@ -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('محصول یافت نشد');
}
}
}