Initial commit of Balout Pastry NestJS API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-02 17:21:34 +03:30
co-authored by Cursor
commit f40075fd5e
84 changed files with 15435 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
import { CategoriesModule } from './categories/categories.module';
import { FlavorsModule } from './flavors/flavors.module';
import { MediaModule } from './media/media.module';
import { OrdersModule } from './orders/orders.module';
import { PrismaModule } from './prisma/prisma.module';
import { ProductsModule } from './products/products.module';
import { SettingsModule } from './settings/settings.module';
import { StorageModule } from './storage/storage.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
StorageModule,
AuthModule,
UsersModule,
FlavorsModule,
CategoriesModule,
ProductsModule,
OrdersModule,
MediaModule,
SettingsModule,
],
})
export class AppModule {}
+37
View File
@@ -0,0 +1,37 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { UserRole } from '@prisma/client';
import type { AuthUser } from './auth.types';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { Roles } from './decorators/roles.decorator';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('login')
login(@Body() dto: LoginDto) {
return this.auth.login(dto);
}
@Post('refresh')
refresh(@Body() dto: RefreshTokenDto) {
return this.auth.refresh(dto.refreshToken);
}
@Post('logout')
logout(@Body() dto: RefreshTokenDto) {
return this.auth.logout(dto.refreshToken);
}
@Get('me')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.admin, UserRole.superAdmin)
me(@CurrentUser() user: AuthUser) {
return this.auth.me(user);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
signOptions: {
expiresIn: config.get<string>('JWT_ACCESS_EXPIRES_IN', '15m') as
| `${number}s`
| `${number}m`
| `${number}h`
| `${number}d`,
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+149
View File
@@ -0,0 +1,149 @@
import {
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser, displayName, isElevatedRole } from './auth.types';
import { LoginDto } from './dto/login.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber },
});
if (!user) {
throw new UnauthorizedException('شماره یا رمز عبور اشتباه است');
}
if (user.disabled) {
throw new ForbiddenException('حساب کاربری غیرفعال است');
}
if (!isElevatedRole(user.role)) {
throw new ForbiddenException('فقط ادمین می‌تواند وارد پنل شود');
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('شماره یا رمز عبور اشتباه است');
}
return this.issueTokens(user);
}
async refresh(refreshToken: string) {
const tokenHash = this.hashToken(refreshToken);
const stored = await this.prisma.refreshToken.findFirst({
where: { tokenHash },
include: { user: true },
});
if (!stored || stored.expiresAt < new Date()) {
if (stored) {
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
}
throw new UnauthorizedException('توکن منقضی شده است');
}
const user = stored.user;
if (user.disabled || !isElevatedRole(user.role)) {
throw new UnauthorizedException('نشست نامعتبر است');
}
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
return this.issueTokens(user);
}
async logout(refreshToken?: string) {
if (!refreshToken) return { ok: true };
const tokenHash = this.hashToken(refreshToken);
await this.prisma.refreshToken.deleteMany({ where: { tokenHash } });
return { ok: true };
}
async me(actor: AuthUser) {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: actor.id },
});
return {
id: user.id,
title: user.title,
firstName: user.firstName,
lastName: user.lastName,
cellNumber: user.cellNumber,
role: user.role,
name: displayName(user),
};
}
private async issueTokens(user: {
id: string;
cellNumber: string;
role: AuthUser['role'];
firstName: string;
lastName: string;
title: string;
}) {
const accessExpiresIn = this.config.get<string>(
'JWT_ACCESS_EXPIRES_IN',
'15m',
);
const accessToken = await this.jwt.signAsync(
{ sub: user.id, role: user.role },
{
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: accessExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
},
);
const refreshToken = randomBytes(48).toString('hex');
const refreshDays = this.parseDurationDays(
this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d'),
);
await this.prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: this.hashToken(refreshToken),
expiresAt: new Date(Date.now() + refreshDays * 24 * 60 * 60 * 1000),
},
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
title: user.title,
firstName: user.firstName,
lastName: user.lastName,
cellNumber: user.cellNumber,
role: user.role,
name: displayName(user),
},
};
}
private hashToken(token: string) {
return createHash('sha256').update(token).digest('hex');
}
private parseDurationDays(value: string): number {
const match = /^(\d+)d$/i.exec(value.trim());
return match ? Number(match[1]) : 7;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { UserRole } from '@prisma/client';
export type AuthUser = {
id: string;
cellNumber: string;
role: UserRole;
firstName: string;
lastName: string;
title: string;
};
export function isElevatedRole(role: UserRole): boolean {
return role === UserRole.admin || role === UserRole.superAdmin;
}
export function displayName(user: {
title: string;
firstName: string;
lastName: string;
}): string {
return `${user.title} ${user.firstName} ${user.lastName}`.trim();
}
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthUser } from '../auth.types';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => {
const request = ctx.switchToHttp().getRequest<{ user: AuthUser }>();
return request.user;
},
);
+5
View File
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
+13
View File
@@ -0,0 +1,13 @@
import { IsString, Matches, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
@Matches(/^09\d{9}$/, {
message: 'شماره موبایل معتبر نیست',
})
cellNumber!: string;
@IsString()
@MinLength(4)
password!: string;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class RefreshTokenDto {
@IsString()
@MinLength(20)
refreshToken!: string;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+35
View File
@@ -0,0 +1,35 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '@prisma/client';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { AuthUser } from '../auth.types';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles?.length) {
return true;
}
const request = context.switchToHttp().getRequest<{ user?: AuthUser }>();
const user = request.user;
if (!user || !requiredRoles.includes(user.role)) {
throw new ForbiddenException('دسترسی مجاز نیست');
}
return true;
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { UserRole } from '@prisma/client';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';
import { AuthUser, isElevatedRole } from '../auth.types';
type JwtPayload = {
sub: string;
role: UserRole;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
});
}
async validate(payload: JwtPayload): Promise<AuthUser> {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user || user.disabled || !isElevatedRole(user.role)) {
throw new UnauthorizedException('نشست نامعتبر است');
}
return {
id: user.id,
cellNumber: user.cellNumber,
role: user.role,
firstName: user.firstName,
lastName: user.lastName,
title: user.title,
};
}
}
+59
View File
@@ -0,0 +1,59 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { CategoriesService } from './categories.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { ReplaceCategoryOptionsDto } from './dto/replace-category-options.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
@Controller('categories')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.admin, UserRole.superAdmin)
export class CategoriesController {
constructor(private readonly categories: CategoriesService) {}
@Get()
listTree() {
return this.categories.listTree();
}
@Post()
create(@Body() dto: CreateCategoryDto) {
return this.categories.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateCategoryDto) {
return this.categories.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.categories.remove(id);
}
@Get(':id/options')
getOptions(@Param('id') id: string) {
return this.categories.getOptions(id);
}
@Put(':id/options')
replaceOptions(
@Param('id') id: string,
@Body() dto: ReplaceCategoryOptionsDto,
) {
return this.categories.replaceOptions(id, dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}
+137
View File
@@ -0,0 +1,137 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { buildCategoryTree } from './categories.utils';
import { CreateCategoryDto } from './dto/create-category.dto';
import { ReplaceCategoryOptionsDto } from './dto/replace-category-options.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
async listTree() {
const categories = await this.prisma.category.findMany({
orderBy: [{ sortOrder: 'asc' }, { nameFa: 'asc' }],
});
return buildCategoryTree(categories);
}
async create(dto: CreateCategoryDto) {
if (dto.parentId) {
await this.ensureExists(dto.parentId);
}
return this.prisma.category.create({
data: {
nameFa: dto.nameFa,
nameEn: dto.nameEn,
parentId: dto.parentId ?? null,
sortOrder: dto.sortOrder ?? 0,
},
});
}
async update(id: string, dto: UpdateCategoryDto) {
await this.ensureExists(id);
if (dto.parentId) {
if (dto.parentId === id) {
throw new BadRequestException('دسته‌بندی نمی‌تواند والد خود باشد');
}
await this.ensureExists(dto.parentId);
}
return this.prisma.category.update({
where: { id },
data: {
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
...(dto.parentId !== undefined ? { parentId: dto.parentId } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
},
});
}
async remove(id: string) {
await this.ensureExists(id);
const [childrenCount, productsCount] = await Promise.all([
this.prisma.category.count({ where: { parentId: id } }),
this.prisma.product.count({ where: { categoryId: id } }),
]);
if (childrenCount > 0) {
throw new BadRequestException('دسته‌بندی دارای زیرمجموعه است و قابل حذف نیست');
}
if (productsCount > 0) {
throw new BadRequestException('دسته‌بندی دارای محصول است و قابل حذف نیست');
}
await this.prisma.category.delete({ where: { id } });
return { ok: true };
}
async getOptions(id: string) {
await this.ensureExists(id);
return this.prisma.categoryOptionBlock.findMany({
where: { categoryId: id },
include: {
flavor: true,
entries: { orderBy: { sortOrder: 'asc' } },
},
orderBy: { sortOrder: 'asc' },
});
}
async replaceOptions(id: string, dto: ReplaceCategoryOptionsDto) {
await this.ensureExists(id);
const flavorIds = [...new Set(dto.blocks.map((block) => block.flavorId))];
const flavors = await this.prisma.flavor.findMany({
where: { id: { in: flavorIds } },
select: { id: true },
});
if (flavors.length !== flavorIds.length) {
throw new BadRequestException('برخی از طعم‌ها یافت نشدند');
}
await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.categoryOptionBlock.deleteMany({ where: { categoryId: id } });
for (const [blockIndex, block] of dto.blocks.entries()) {
await tx.categoryOptionBlock.create({
data: {
categoryId: id,
flavorId: block.flavorId,
sortOrder: block.sortOrder ?? blockIndex,
entries: {
create: block.entries.map((entry, entryIndex) => ({
amount: entry.amount,
price: entry.price,
sortOrder: entryIndex,
})),
},
},
});
}
});
return this.getOptions(id);
}
private async ensureExists(id: string) {
const category = await this.prisma.category.findUnique({ where: { id } });
if (!category) {
throw new NotFoundException('دسته‌بندی یافت نشد');
}
return category;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Category } from '@prisma/client';
export type CategoryTreeNode = Category & { children: CategoryTreeNode[] };
export function buildCategoryTree(categories: Category[]): CategoryTreeNode[] {
const map = new Map<string, CategoryTreeNode>();
const roots: CategoryTreeNode[] = [];
for (const category of categories) {
map.set(category.id, { ...category, children: [] });
}
for (const category of categories) {
const node = map.get(category.id)!;
if (category.parentId && map.has(category.parentId)) {
map.get(category.parentId)!.children.push(node);
} else if (!category.parentId) {
roots.push(node);
}
}
const sortNodes = (nodes: CategoryTreeNode[]) => {
nodes.sort((a, b) => a.sortOrder - b.sortOrder);
for (const node of nodes) {
sortNodes(node.children);
}
};
sortNodes(roots);
return roots;
}
+19
View File
@@ -0,0 +1,19 @@
import { IsInt, IsOptional, IsString, MinLength } from 'class-validator';
export class CreateCategoryDto {
@IsString()
@MinLength(1)
nameFa!: string;
@IsString()
@MinLength(1)
nameEn!: string;
@IsOptional()
@IsString()
parentId?: string | null;
@IsOptional()
@IsInt()
sortOrder?: number;
}
@@ -0,0 +1,43 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
export class CategoryOptionEntryDto {
@IsString()
@MinLength(1)
amount!: string;
@IsInt()
@Min(0)
price!: number;
}
export class CategoryOptionBlockDto {
@IsString()
flavorId!: string;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CategoryOptionEntryDto)
entries!: CategoryOptionEntryDto[];
@IsOptional()
@IsInt()
sortOrder?: number;
}
export class ReplaceCategoryOptionsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => CategoryOptionBlockDto)
blocks!: CategoryOptionBlockDto[];
}
+21
View File
@@ -0,0 +1,21 @@
import { IsInt, IsOptional, IsString, MinLength } from 'class-validator';
export class UpdateCategoryDto {
@IsOptional()
@IsString()
@MinLength(1)
nameFa?: string;
@IsOptional()
@IsString()
@MinLength(1)
nameEn?: string;
@IsOptional()
@IsString()
parentId?: string | null;
@IsOptional()
@IsInt()
sortOrder?: number;
}
+29
View File
@@ -0,0 +1,29 @@
/** Districts of Qom used across settings (shipping, branches). */
export const DISTRICTS = [
'پردیسان',
'جعفریه',
'زنبیل‌آباد',
'سالاریه',
'صفاییه',
'عطاران',
'انارستان',
'بنیاد',
'آذر',
'هنرستان',
'باجک',
'دورشهر',
'خیابان ارم',
'شهرک قدس',
'شهرک امام حسن',
'شهرک مهدیه',
'قلعه کامکار',
'نیروگاه',
'حرم',
'بلوار امین',
] as const;
export type District = (typeof DISTRICTS)[number];
export function isDistrict(value: string): value is District {
return (DISTRICTS as readonly string[]).includes(value);
}
+11
View File
@@ -0,0 +1,11 @@
import { IsString, MinLength } from 'class-validator';
export class CreateFlavorDto {
@IsString()
@MinLength(1)
nameFa!: string;
@IsString()
@MinLength(1)
nameEn!: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
export class UpdateFlavorDto {
@IsOptional()
@IsString()
@MinLength(1)
nameFa?: string;
@IsOptional()
@IsString()
@MinLength(1)
nameEn?: string;
}
+44
View File
@@ -0,0 +1,44 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { CreateFlavorDto } from './dto/create-flavor.dto';
import { UpdateFlavorDto } from './dto/update-flavor.dto';
import { FlavorsService } from './flavors.service';
@Controller('flavors')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.admin, UserRole.superAdmin)
export class FlavorsController {
constructor(private readonly flavors: FlavorsService) {}
@Get()
list() {
return this.flavors.list();
}
@Post()
create(@Body() dto: CreateFlavorDto) {
return this.flavors.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateFlavorDto) {
return this.flavors.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.flavors.remove(id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FlavorsController } from './flavors.controller';
import { FlavorsService } from './flavors.service';
@Module({
controllers: [FlavorsController],
providers: [FlavorsService],
exports: [FlavorsService],
})
export class FlavorsModule {}
+52
View File
@@ -0,0 +1,52 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateFlavorDto } from './dto/create-flavor.dto';
import { UpdateFlavorDto } from './dto/update-flavor.dto';
@Injectable()
export class FlavorsService {
constructor(private readonly prisma: PrismaService) {}
list() {
return this.prisma.flavor.findMany({ orderBy: { nameFa: 'asc' } });
}
async create(dto: CreateFlavorDto) {
return this.prisma.flavor.create({ data: dto });
}
async update(id: string, dto: UpdateFlavorDto) {
await this.ensureExists(id);
return this.prisma.flavor.update({ where: { id }, data: dto });
}
async remove(id: string) {
await this.ensureExists(id);
const [categoryBlocks, productOptions] = await Promise.all([
this.prisma.categoryOptionBlock.count({ where: { flavorId: id } }),
this.prisma.productOption.count({ where: { flavorId: id } }),
]);
if (categoryBlocks > 0 || productOptions > 0) {
throw new BadRequestException(
'این طعم در گزینه‌های دسته‌بندی یا محصول استفاده شده و قابل حذف نیست',
);
}
await this.prisma.flavor.delete({ where: { id } });
return { ok: true };
}
private async ensureExists(id: string) {
const flavor = await this.prisma.flavor.findUnique({ where: { id } });
if (!flavor) {
throw new NotFoundException('طعم یافت نشد');
}
return flavor;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1');
app.enableCors({
origin: process.env.CORS_ORIGIN?.split(',').map((s) => s.trim()) ?? true,
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
const port = Number(process.env.PORT ?? 3100);
await app.listen(port);
// eslint-disable-next-line no-console
console.log(`Balout API listening on http://localhost:${port}/api/v1`);
}
void bootstrap();
+9
View File
@@ -0,0 +1,9 @@
import { IsIn } from 'class-validator';
export const MEDIA_KINDS = ['main', 'gallery', 'temp'] as const;
export type MediaKind = (typeof MEDIA_KINDS)[number];
export class MediaUploadQueryDto {
@IsIn(MEDIA_KINDS, { message: 'نوع فایل نامعتبر است' })
kind!: MediaKind;
}
+36
View File
@@ -0,0 +1,36 @@
import {
Controller,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { UserRole } from '@prisma/client';
import { memoryStorage } from 'multer';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { MediaUploadQueryDto } from './dto/media-upload-query.dto';
import { MediaService } from './media.service';
@Controller('media')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.admin, UserRole.superAdmin)
export class MediaController {
constructor(private readonly media: MediaService) {}
@Post('upload')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
}),
)
upload(
@Query() query: MediaUploadQueryDto,
@UploadedFile() file: Express.Multer.File,
) {
return this.media.upload(query.kind, file);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { StorageModule } from '../storage/storage.module';
import { MediaController } from './media.controller';
import { MediaService } from './media.service';
@Module({
imports: [ConfigModule, StorageModule],
controllers: [MediaController],
providers: [MediaService],
})
export class MediaModule {}
+82
View File
@@ -0,0 +1,82 @@
import {
BadRequestException,
Injectable,
PayloadTooLargeException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
import { extname } from 'path';
import {
productGalleryKey,
productMainKey,
tempMediaKey,
} from '../storage/storage-keys';
import { StorageService } from '../storage/storage.service';
import { MediaKind } from './dto/media-upload-query.dto';
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const MIME_EXTENSIONS: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
};
@Injectable()
export class MediaService {
constructor(
private readonly storage: StorageService,
private readonly config: ConfigService,
) {}
async upload(kind: MediaKind, file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('فایل ارسال نشده است');
}
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
throw new BadRequestException('فرمت تصویر مجاز نیست');
}
const maxMb = Number(
this.config.get<string>('MEDIA_MAX_FILE_SIZE_MB', '10'),
);
const maxBytes = maxMb * 1024 * 1024;
if (file.size > maxBytes) {
throw new PayloadTooLargeException(
`حجم فایل نباید بیشتر از ${maxMb} مگابایت باشد`,
);
}
const ext =
MIME_EXTENSIONS[file.mimetype] ?? extname(file.originalname) ?? '';
const fileName = `${randomUUID()}${ext}`;
const key = this.resolveKey(kind, fileName);
const stored = await this.storage.upload({
key,
body: file.buffer,
contentType: file.mimetype,
});
return {
url: stored.publicUrl,
storageKey: stored.storagePath,
storageDisk: stored.storageDisk,
};
}
private resolveKey(kind: MediaKind, fileName: string): string {
switch (kind) {
case 'main':
return productMainKey(fileName);
case 'gallery':
return productGalleryKey(fileName);
case 'temp':
return tempMediaKey(fileName);
default:
throw new BadRequestException('نوع فایل نامعتبر است');
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { DeliveryType } from '@prisma/client';
export class CreateOrderItemDto {
@IsString()
@MinLength(1)
productId!: string;
@IsInt()
@Min(1)
quantity!: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
optionIds?: string[];
}
export class CreateOrderDto {
@IsString()
@MinLength(1)
customerId!: string;
@IsEnum(DeliveryType)
deliveryType!: DeliveryType;
@ValidateIf((dto: CreateOrderDto) => dto.deliveryType === DeliveryType.pickup)
@IsString()
@MinLength(1)
branchId?: string;
@ValidateIf(
(dto: CreateOrderDto) => dto.deliveryType === DeliveryType.shipping,
)
@IsString()
@MinLength(1)
shippingAddressId?: string;
@IsOptional()
@IsString()
note?: string;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateOrderItemDto)
items!: CreateOrderItemDto[];
}
+37
View File
@@ -0,0 +1,37 @@
import { OrderStatus } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class ListOrdersQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsEnum(OrderStatus)
status?: OrderStatus;
@IsOptional()
@IsString()
customerId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
@@ -0,0 +1,7 @@
import { OrderStatus } from '@prisma/client';
import { IsEnum } from 'class-validator';
export class UpdateOrderStatusDto {
@IsEnum(OrderStatus)
status!: OrderStatus;
}
+45
View File
@@ -0,0 +1,45 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { CreateOrderDto } from './dto/create-order.dto';
import { ListOrdersQueryDto } from './dto/list-orders-query.dto';
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
import { OrdersService } from './orders.service';
@Controller('orders')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.admin, UserRole.superAdmin)
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Get()
list(@Query() query: ListOrdersQueryDto) {
return this.orders.list(query);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.orders.findOne(id);
}
@Post()
create(@Body() dto: CreateOrderDto) {
return this.orders.create(dto);
}
@Patch(':id/status')
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
return this.orders.updateStatus(id, dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
+336
View File
@@ -0,0 +1,336 @@
import {
DeliveryType,
Order,
OrderItem,
OrderItemOption,
OrderStatus,
Prisma,
SellUnit,
} from '@prisma/client';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { displayName } from '../auth/auth.types';
import { PrismaService } from '../prisma/prisma.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { ListOrdersQueryDto } from './dto/list-orders-query.dto';
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
type OrderWithItems = Order & {
items: (OrderItem & { options: OrderItemOption[] })[];
};
@Injectable()
export class OrdersService {
constructor(private readonly prisma: PrismaService) {}
async list(query: ListOrdersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where: Prisma.OrderWhereInput = {
...(query.status ? { status: query.status } : {}),
...(query.customerId ? { customerId: query.customerId } : {}),
...(query.q
? {
OR: [
{
customerName: {
contains: query.q,
mode: 'insensitive' as const,
},
},
{ customerPhone: { contains: query.q } },
...(Number.isFinite(Number(query.q.replace(/\D/g, ''))) &&
query.q.replace(/\D/g, '').length > 0
? [{ number: Number(query.q.replace(/\D/g, '')) }]
: []),
],
}
: {}),
};
const [rows, total] = await Promise.all([
this.prisma.order.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
items: {
orderBy: { sortOrder: 'asc' },
include: { options: { orderBy: { sortOrder: 'asc' } } },
},
},
}),
this.prisma.order.count({ where }),
]);
return {
items: rows.map((order) => this.serializeOrder(order)),
total,
page,
pageSize,
};
}
async findOne(id: string) {
const order = await this.prisma.order.findUnique({
where: { id },
include: {
items: {
orderBy: { sortOrder: 'asc' },
include: { options: { orderBy: { sortOrder: 'asc' } } },
},
},
});
if (!order) {
throw new NotFoundException('سفارش یافت نشد');
}
return this.serializeOrder(order);
}
async create(dto: CreateOrderDto) {
const customer = await this.prisma.user.findUnique({
where: { id: dto.customerId },
});
if (!customer) {
throw new NotFoundException('کاربر یافت نشد');
}
if (customer.disabled) {
throw new BadRequestException('کاربر غیرفعال است');
}
const delivery = await this.resolveDelivery(dto);
const lineInputs = await this.resolveItems(dto.items);
const itemCount = lineInputs.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = lineInputs.reduce(
(sum, item) => sum + item.quantity * item.unitTotal,
0,
);
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
customerId: customer.id,
customerName: displayName(customer),
customerPhone: customer.cellNumber,
deliveryType: dto.deliveryType,
branchId: delivery.branchId,
branchName: delivery.branchName,
shippingAddressId: delivery.shippingAddressId,
shippingName: delivery.shippingName,
shippingDistrict: delivery.shippingDistrict,
shippingAddressLine: delivery.shippingAddressLine,
shippingLandline: delivery.shippingLandline,
note: dto.note?.trim() ?? '',
itemCount,
totalPrice,
items: {
create: lineInputs.map((item, index) => ({
productId: item.productId,
nameFa: item.nameFa,
quantity: item.quantity,
unitPrice: item.unitPrice,
sellUnit: item.sellUnit,
sortOrder: index,
options: item.options.length
? {
create: item.options.map((option, optionIndex) => ({
name: option.name,
price: option.price,
sortOrder: optionIndex,
})),
}
: undefined,
})),
},
},
include: {
items: {
orderBy: { sortOrder: 'asc' },
include: { options: { orderBy: { sortOrder: 'asc' } } },
},
},
});
await tx.user.update({
where: { id: customer.id },
data: {
totalOrders: { increment: 1 },
totalTransaction: { increment: totalPrice },
},
});
return created;
});
return this.serializeOrder(order);
}
async updateStatus(id: string, dto: UpdateOrderStatusDto) {
const existing = await this.prisma.order.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException('سفارش یافت نشد');
}
const order = await this.prisma.order.update({
where: { id },
data: { status: dto.status },
include: {
items: {
orderBy: { sortOrder: 'asc' },
include: { options: { orderBy: { sortOrder: 'asc' } } },
},
},
});
return this.serializeOrder(order);
}
private async resolveDelivery(dto: CreateOrderDto) {
if (dto.deliveryType === DeliveryType.pickup) {
if (!dto.branchId) {
throw new BadRequestException('انتخاب شعبه الزامی است');
}
const branch = await this.prisma.branch.findUnique({
where: { id: dto.branchId },
});
if (!branch) {
throw new NotFoundException('شعبه یافت نشد');
}
return {
branchId: branch.id,
branchName: branch.name,
shippingAddressId: null as string | null,
shippingName: null as string | null,
shippingDistrict: null as string | null,
shippingAddressLine: null as string | null,
shippingLandline: null as string | null,
};
}
if (!dto.shippingAddressId) {
throw new BadRequestException('انتخاب آدرس ارسال الزامی است');
}
const address = await this.prisma.userAddress.findFirst({
where: { id: dto.shippingAddressId, userId: dto.customerId },
});
if (!address) {
throw new NotFoundException('آدرس ارسال یافت نشد');
}
return {
branchId: null as string | null,
branchName: null as string | null,
shippingAddressId: address.id,
shippingName: address.name,
shippingDistrict: address.district,
shippingAddressLine: address.address,
shippingLandline: address.landline,
};
}
private async resolveItems(items: CreateOrderDto['items']) {
const productIds = [...new Set(items.map((item) => item.productId))];
const products = await this.prisma.product.findMany({
where: { id: { in: productIds } },
include: { options: { include: { flavor: true } } },
});
const productMap = new Map(products.map((product) => [product.id, product]));
return items.map((item) => {
const product = productMap.get(item.productId);
if (!product) {
throw new NotFoundException(`محصول یافت نشد: ${item.productId}`);
}
const selectedIds = item.optionIds ?? [];
const optionsById = new Map(
product.options.map((option) => [option.id, option]),
);
const options = selectedIds.map((optionId) => {
const option = optionsById.get(optionId);
if (!option) {
throw new BadRequestException(
`گزینه نامعتبر برای محصول ${product.nameFa}`,
);
}
return {
name: `${option.flavor.nameFa} — ${option.amount}`,
price: option.price,
};
});
const optionsTotal = options.reduce((sum, option) => sum + option.price, 0);
return {
productId: product.id,
nameFa: product.nameFa,
quantity: item.quantity,
unitPrice: product.price,
sellUnit: product.sellUnit as SellUnit,
options,
unitTotal: product.price + optionsTotal,
};
});
}
private serializeOrder(order: OrderWithItems) {
const delivery =
order.deliveryType === DeliveryType.pickup
? {
type: 'pickup' as const,
branch: order.branchName ?? '',
branchId: order.branchId,
}
: {
type: 'shipping' as const,
shippingAddress: {
id: order.shippingAddressId ?? '',
name: order.shippingName ?? '',
district: order.shippingDistrict ?? '',
address: order.shippingAddressLine ?? '',
landline: order.shippingLandline ?? '',
},
};
return {
id: order.id,
code: `BL-${order.number}`,
number: order.number,
createdAt: order.createdAt.toISOString(),
customerId: order.customerId,
customerName: order.customerName,
customerPhone: order.customerPhone,
itemCount: order.itemCount,
totalPrice: order.totalPrice,
status: order.status as OrderStatus,
note: order.note || undefined,
delivery,
items: order.items.map((item) => ({
id: item.id,
productId: item.productId,
nameFa: item.nameFa,
quantity: item.quantity,
unitPrice: item.unitPrice,
sellUnit: item.sellUnit,
options:
item.options.length > 0
? item.options.map((option) => ({
id: option.id,
name: option.name,
price: option.price,
}))
: undefined,
})),
};
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+88
View File
@@ -0,0 +1,88 @@
import { SellUnit } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
export class ProductGalleryItemDto {
@IsString()
@MinLength(1)
url!: string;
@IsString()
@MinLength(1)
storageKey!: string;
}
export class ProductOptionItemDto {
@IsString()
flavorId!: string;
@IsString()
@MinLength(1)
amount!: string;
@IsInt()
@Min(0)
price!: number;
}
export class CreateProductDto {
@IsString()
@MinLength(1)
nameFa!: string;
@IsString()
@MinLength(1)
nameEn!: string;
@IsString()
categoryId!: string;
@IsInt()
@Min(0)
price!: number;
@IsEnum(SellUnit)
sellUnit!: SellUnit;
@IsOptional()
@IsString()
intro?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
mainImageUrl?: string;
@IsOptional()
@IsString()
mainImageKey?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductGalleryItemDto)
gallery?: ProductGalleryItemDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductOptionItemDto)
options?: ProductOptionItemDto[];
}
@@ -0,0 +1,37 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListProductsQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
minPrice?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
maxPrice?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
+74
View File
@@ -0,0 +1,74 @@
import { SellUnit } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import {
ProductGalleryItemDto,
ProductOptionItemDto,
} from './create-product.dto';
export class UpdateProductDto {
@IsOptional()
@IsString()
@MinLength(1)
nameFa?: string;
@IsOptional()
@IsString()
@MinLength(1)
nameEn?: string;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsInt()
@Min(0)
price?: number;
@IsOptional()
@IsEnum(SellUnit)
sellUnit?: SellUnit;
@IsOptional()
@IsString()
intro?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
mainImageUrl?: string | null;
@IsOptional()
@IsString()
mainImageKey?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductGalleryItemDto)
gallery?: ProductGalleryItemDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductOptionItemDto)
options?: ProductOptionItemDto[];
}
+51
View File
@@ -0,0 +1,51 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { CreateProductDto } from './dto/create-product.dto';
import { ListProductsQueryDto } from './dto/list-products-query.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { ProductsService } from './products.service';
@Controller('products')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.admin, UserRole.superAdmin)
export class ProductsController {
constructor(private readonly products: ProductsService) {}
@Get()
list(@Query() query: ListProductsQueryDto) {
return this.products.list(query);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.products.findOne(id);
}
@Post()
create(@Body() dto: CreateProductDto) {
return this.products.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
return this.products.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.products.remove(id);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { StorageModule } from '../storage/storage.module';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
imports: [StorageModule],
controllers: [ProductsController],
providers: [ProductsService],
exports: [ProductsService],
})
export class ProductsModule {}
+281
View File
@@ -0,0 +1,281 @@
import { Prisma } from '@prisma/client';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { StorageService } from '../storage/storage.service';
import { CreateProductDto } from './dto/create-product.dto';
import { ListProductsQueryDto } from './dto/list-products-query.dto';
import { UpdateProductDto } from './dto/update-product.dto';
type ProductOptionInput = {
flavorId: string;
amount: string;
price: number;
};
@Injectable()
export class ProductsService {
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
async list(query: ListProductsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = {
...(query.categoryId ? { categoryId: query.categoryId } : {}),
...(query.minPrice !== undefined || query.maxPrice !== undefined
? {
price: {
...(query.minPrice !== undefined ? { gte: query.minPrice } : {}),
...(query.maxPrice !== undefined ? { lte: query.maxPrice } : {}),
},
}
: {}),
...(query.q
? {
OR: [
{ nameFa: { contains: query.q, mode: 'insensitive' as const } },
{ nameEn: { contains: query.q, mode: 'insensitive' as const } },
],
}
: {}),
};
const [items, total] = await Promise.all([
this.prisma.product.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: { category: true },
}),
this.prisma.product.count({ where }),
]);
return { items, total, page, pageSize };
}
async findOne(id: string) {
const product = await this.prisma.product.findUnique({
where: { id },
include: {
category: true,
gallery: { orderBy: { sortOrder: 'asc' } },
options: { include: { flavor: true } },
},
});
if (!product) {
throw new NotFoundException('محصول یافت نشد');
}
return product;
}
async create(dto: CreateProductDto) {
await this.ensureCategoryExists(dto.categoryId);
await this.validateProductOptions(dto.categoryId, dto.options);
return this.prisma.product.create({
data: {
nameFa: dto.nameFa,
nameEn: dto.nameEn,
categoryId: dto.categoryId,
price: dto.price,
sellUnit: dto.sellUnit,
intro: dto.intro ?? '',
description: dto.description ?? '',
tags: dto.tags ?? [],
mainImageUrl: dto.mainImageUrl,
mainImageKey: dto.mainImageKey,
gallery: dto.gallery?.length
? {
create: dto.gallery.map((item, index) => ({
url: item.url,
storageKey: item.storageKey,
sortOrder: index,
})),
}
: undefined,
options: dto.options?.length
? {
create: dto.options.map((option) => ({
flavorId: option.flavorId,
amount: option.amount,
price: option.price,
})),
}
: undefined,
},
include: {
category: true,
gallery: { orderBy: { sortOrder: 'asc' } },
options: { include: { flavor: true } },
},
});
}
async update(id: string, dto: UpdateProductDto) {
const existing = await this.prisma.product.findUnique({
where: { id },
include: { gallery: true, options: true },
});
if (!existing) {
throw new NotFoundException('محصول یافت نشد');
}
const categoryId = dto.categoryId ?? existing.categoryId;
if (dto.categoryId) {
await this.ensureCategoryExists(dto.categoryId);
}
if (dto.options !== undefined) {
await this.validateProductOptions(categoryId, dto.options);
} else if (dto.categoryId && dto.categoryId !== existing.categoryId) {
await this.validateProductOptions(
categoryId,
existing.options.map((option) => ({
flavorId: option.flavorId,
amount: option.amount,
price: option.price,
})),
);
}
return this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
if (dto.gallery !== undefined) {
await tx.productGalleryImage.deleteMany({ where: { productId: id } });
}
if (dto.options !== undefined) {
await tx.productOption.deleteMany({ where: { productId: id } });
}
return tx.product.update({
where: { id },
data: {
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
...(dto.categoryId !== undefined ? { categoryId: dto.categoryId } : {}),
...(dto.price !== undefined ? { price: dto.price } : {}),
...(dto.sellUnit !== undefined ? { sellUnit: dto.sellUnit } : {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.description !== undefined ? { description: dto.description } : {}),
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
...(dto.mainImageUrl !== undefined
? { mainImageUrl: dto.mainImageUrl }
: {}),
...(dto.mainImageKey !== undefined
? { mainImageKey: dto.mainImageKey }
: {}),
...(dto.gallery !== undefined
? {
gallery: {
create: dto.gallery.map((item, index) => ({
url: item.url,
storageKey: item.storageKey,
sortOrder: index,
})),
},
}
: {}),
...(dto.options !== undefined
? {
options: {
create: dto.options.map((option) => ({
flavorId: option.flavorId,
amount: option.amount,
price: option.price,
})),
},
}
: {}),
},
include: {
category: true,
gallery: { orderBy: { sortOrder: 'asc' } },
options: { include: { flavor: true } },
},
});
});
}
async remove(id: string) {
const product = await this.prisma.product.findUnique({
where: { id },
include: { gallery: true },
});
if (!product) {
throw new NotFoundException('محصول یافت نشد');
}
const keysToDelete = [
...(product.mainImageKey ? [product.mainImageKey] : []),
...product.gallery.map((image) => image.storageKey),
];
for (const key of keysToDelete) {
await this.storage.delete(key);
}
await this.prisma.product.delete({ where: { id } });
return { ok: true };
}
private async ensureCategoryExists(categoryId: string) {
const category = await this.prisma.category.findUnique({
where: { id: categoryId },
});
if (!category) {
throw new NotFoundException('دسته‌بندی یافت نشد');
}
return category;
}
private async validateProductOptions(
categoryId: string,
options: ProductOptionInput[] | undefined,
) {
const blocks = await this.prisma.categoryOptionBlock.findMany({
where: { categoryId },
include: { entries: true },
});
if (blocks.length === 0) {
if (options && options.length > 0) {
throw new BadRequestException('این دسته‌بندی قالب گزینه‌ای ندارد');
}
return;
}
if (!options?.length) {
return;
}
const allowed = new Set<string>();
for (const block of blocks) {
for (const entry of block.entries) {
allowed.add(`${block.flavorId}:${entry.amount}:${entry.price}`);
}
}
for (const option of options) {
const key = `${option.flavorId}:${option.amount}:${option.price}`;
if (!allowed.has(key)) {
throw new BadRequestException(
'گزینه محصول با قالب دسته‌بندی مطابقت ندارد',
);
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import { IsOptional, IsString, Matches, MinLength } from 'class-validator';
export class CreateBranchDto {
@IsString()
@MinLength(1)
name!: string;
@IsString()
district!: string;
@IsString()
@MinLength(1)
address!: string;
@IsOptional()
@IsString()
landline?: string;
@IsOptional()
@IsString()
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
cellNumber?: string;
}
+26
View File
@@ -0,0 +1,26 @@
import { IsOptional, IsString, Matches, MinLength } from 'class-validator';
export class UpdateBranchDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsString()
district?: string;
@IsOptional()
@IsString()
@MinLength(1)
address?: string;
@IsOptional()
@IsString()
landline?: string;
@IsOptional()
@IsString()
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
cellNumber?: string;
}
+25
View File
@@ -0,0 +1,25 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsInt,
IsString,
Min,
ValidateNested,
} from 'class-validator';
export class ShippingExceptionItemDto {
@IsString()
district!: string;
@IsInt()
@Min(0)
price!: number;
}
export class UpdateShippingDto {
@IsArray()
@ArrayMinSize(0)
@ValidateNested({ each: true })
@Type(() => ShippingExceptionItemDto)
exceptions!: ShippingExceptionItemDto[];
}
+61
View File
@@ -0,0 +1,61 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { CreateBranchDto } from './dto/create-branch.dto';
import { UpdateBranchDto } from './dto/update-branch.dto';
import { UpdateShippingDto } from './dto/update-shipping.dto';
import { SettingsService } from './settings.service';
@Controller('settings')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.admin, UserRole.superAdmin)
export class SettingsController {
constructor(private readonly settings: SettingsService) {}
@Get('districts')
getDistricts() {
return this.settings.getDistricts();
}
@Get('shipping')
listShipping() {
return this.settings.listShippingExceptions();
}
@Put('shipping')
replaceShipping(@Body() dto: UpdateShippingDto) {
return this.settings.replaceShippingExceptions(dto);
}
@Get('branches')
listBranches() {
return this.settings.listBranches();
}
@Post('branches')
createBranch(@Body() dto: CreateBranchDto) {
return this.settings.createBranch(dto);
}
@Patch('branches/:id')
updateBranch(@Param('id') id: string, @Body() dto: UpdateBranchDto) {
return this.settings.updateBranch(id, dto);
}
@Delete('branches/:id')
removeBranch(@Param('id') id: string) {
return this.settings.removeBranch(id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
@Module({
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}
+105
View File
@@ -0,0 +1,105 @@
import { Prisma } from '@prisma/client';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DISTRICTS, isDistrict } from '../common/districts';
import { PrismaService } from '../prisma/prisma.service';
import { CreateBranchDto } from './dto/create-branch.dto';
import { UpdateBranchDto } from './dto/update-branch.dto';
import { UpdateShippingDto } from './dto/update-shipping.dto';
@Injectable()
export class SettingsService {
constructor(private readonly prisma: PrismaService) {}
getDistricts() {
return DISTRICTS;
}
listShippingExceptions() {
return this.prisma.shippingException.findMany({
orderBy: { district: 'asc' },
});
}
async replaceShippingExceptions(dto: UpdateShippingDto) {
for (const item of dto.exceptions) {
this.assertDistrict(item.district);
}
const districts = dto.exceptions.map((item) => item.district);
const unique = new Set(districts);
if (unique.size !== districts.length) {
throw new BadRequestException('منطقه تکراری در لیست ارسال وجود دارد');
}
await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.shippingException.deleteMany();
if (dto.exceptions.length > 0) {
await tx.shippingException.createMany({
data: dto.exceptions.map((item) => ({
district: item.district,
price: item.price,
})),
});
}
});
return this.listShippingExceptions();
}
listBranches() {
return this.prisma.branch.findMany({ orderBy: { name: 'asc' } });
}
createBranch(dto: CreateBranchDto) {
this.assertDistrict(dto.district);
return this.prisma.branch.create({
data: {
name: dto.name,
district: dto.district,
address: dto.address,
landline: dto.landline ?? '',
cellNumber: dto.cellNumber ?? '',
},
});
}
async updateBranch(id: string, dto: UpdateBranchDto) {
await this.ensureBranchExists(id);
if (dto.district !== undefined) {
this.assertDistrict(dto.district);
}
return this.prisma.branch.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.district !== undefined ? { district: dto.district } : {}),
...(dto.address !== undefined ? { address: dto.address } : {}),
...(dto.landline !== undefined ? { landline: dto.landline } : {}),
...(dto.cellNumber !== undefined ? { cellNumber: dto.cellNumber } : {}),
},
});
}
async removeBranch(id: string) {
await this.ensureBranchExists(id);
await this.prisma.branch.delete({ where: { id } });
return { ok: true };
}
private assertDistrict(district: string) {
if (!isDistrict(district)) {
throw new BadRequestException('منطقه نامعتبر است');
}
}
private async ensureBranchExists(id: string) {
const branch = await this.prisma.branch.findUnique({ where: { id } });
if (!branch) {
throw new NotFoundException('شعبه یافت نشد');
}
return branch;
}
}
+85
View File
@@ -0,0 +1,85 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { StoredObject, UploadObjectInput } from './storage.types';
@Injectable()
export class S3StorageDriver {
private readonly client: S3Client;
private readonly bucket: string;
private readonly publicUrlBase: string;
private readonly storageDisk = 's3';
constructor(private readonly config: ConfigService) {
const endpoint = this.config.getOrThrow<string>('S3_ENDPOINT');
const region = this.config.get<string>('S3_REGION', 'us-east-1');
const forcePathStyle =
this.config.get<string>('S3_FORCE_PATH_STYLE', 'true') === 'true';
this.bucket = this.config.getOrThrow<string>('S3_BUCKET');
this.publicUrlBase = this.config
.getOrThrow<string>('S3_PUBLIC_URL')
.replace(/\/$/, '');
this.client = new S3Client({
endpoint,
region,
forcePathStyle,
credentials: {
accessKeyId: this.config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
secretAccessKey: this.config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
},
});
}
async upload(input: UploadObjectInput): Promise<StoredObject> {
const key = input.key.replace(/^\/+/, '');
await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: input.body,
ContentType: input.contentType,
}),
);
return {
storageDisk: this.storageDisk,
storagePath: key,
publicUrl: `${this.publicUrlBase}/${key}`,
};
}
async getBuffer(storagePath: string): Promise<Buffer> {
const key = storagePath.replace(/^\/+/, '');
const result = await this.client.send(
new GetObjectCommand({
Bucket: this.bucket,
Key: key,
}),
);
if (!result.Body) {
throw new Error(`Empty object body for ${key}`);
}
return Buffer.from(await result.Body.transformToByteArray());
}
async delete(storagePath: string): Promise<void> {
const key = storagePath.replace(/^\/+/, '');
await this.client.send(
new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
}),
);
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Parspack S3 object key layout (path-style public URLs):
*
* balout/
* products/main/{uuid}{ext}
* products/gallery/{uuid}{ext}
* categories/{uuid}{ext}
* branches/{uuid}{ext}
* users/{uuid}{ext}
* temp/{uuid}{ext}
*/
const ROOT = 'balout';
export function productMainKey(fileName: string): string {
return `${ROOT}/products/main/${fileName}`;
}
export function productGalleryKey(fileName: string): string {
return `${ROOT}/products/gallery/${fileName}`;
}
export function categoryMediaKey(fileName: string): string {
return `${ROOT}/categories/${fileName}`;
}
export function branchMediaKey(fileName: string): string {
return `${ROOT}/branches/${fileName}`;
}
export function userMediaKey(fileName: string): string {
return `${ROOT}/users/${fileName}`;
}
export function tempMediaKey(fileName: string): string {
return `${ROOT}/temp/${fileName}`;
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { S3StorageDriver } from './s3-storage.driver';
import { StorageService } from './storage.service';
@Module({
providers: [S3StorageDriver, StorageService],
exports: [StorageService],
})
export class StorageModule {}
+20
View File
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { S3StorageDriver } from './s3-storage.driver';
import { StoredObject, UploadObjectInput } from './storage.types';
@Injectable()
export class StorageService {
constructor(private readonly s3: S3StorageDriver) {}
upload(input: UploadObjectInput): Promise<StoredObject> {
return this.s3.upload(input);
}
getBuffer(storagePath: string): Promise<Buffer> {
return this.s3.getBuffer(storagePath);
}
delete(storagePath: string): Promise<void> {
return this.s3.delete(storagePath);
}
}
+11
View File
@@ -0,0 +1,11 @@
export type StoredObject = {
storageDisk: string;
storagePath: string;
publicUrl: string;
};
export type UploadObjectInput = {
key: string;
body: Buffer;
contentType: string;
};
+19
View File
@@ -0,0 +1,19 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
export class CreateUserAddressDto {
@IsString()
@MinLength(1)
name!: string;
@IsString()
@MinLength(1)
district!: string;
@IsString()
@MinLength(1)
address!: string;
@IsOptional()
@IsString()
landline?: string;
}
+44
View File
@@ -0,0 +1,44 @@
import { UserRole } from '@prisma/client';
import {
IsBoolean,
IsEnum,
IsIn,
IsOptional,
IsString,
Matches,
MinLength,
} from 'class-validator';
import { USER_CATEGORIES, USER_TITLES } from '../users.constants';
export class CreateUserDto {
@IsIn(USER_TITLES, { message: 'عنوان نامعتبر است' })
title!: string;
@IsString()
@MinLength(1)
firstName!: string;
@IsString()
@MinLength(1)
lastName!: string;
@IsOptional()
@IsIn(USER_CATEGORIES, { message: 'دسته‌بندی نامعتبر است' })
category?: string;
@IsString()
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
cellNumber!: string;
@IsString()
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
password!: string;
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@IsOptional()
@IsBoolean()
disabled?: boolean;
}
+43
View File
@@ -0,0 +1,43 @@
import { UserRole } from '@prisma/client';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class ListUsersQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@IsOptional()
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
})
@IsBoolean()
disabled?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
+22
View File
@@ -0,0 +1,22 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
export class UpdateUserAddressDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsString()
@MinLength(1)
district?: string;
@IsOptional()
@IsString()
@MinLength(1)
address?: string;
@IsOptional()
@IsString()
landline?: string;
}
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class UpdateUserPasswordDto {
@IsString()
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
password!: string;
}
+7
View File
@@ -0,0 +1,7 @@
import { UserRole } from '@prisma/client';
import { IsEnum } from 'class-validator';
export class UpdateUserRoleDto {
@IsEnum(UserRole, { message: 'نقش نامعتبر است' })
role!: UserRole;
}
+38
View File
@@ -0,0 +1,38 @@
import {
IsBoolean,
IsIn,
IsOptional,
IsString,
Matches,
MinLength,
} from 'class-validator';
import { USER_CATEGORIES, USER_TITLES } from '../users.constants';
export class UpdateUserDto {
@IsOptional()
@IsIn(USER_TITLES, { message: 'عنوان نامعتبر است' })
title?: string;
@IsOptional()
@IsString()
@MinLength(1)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
lastName?: string;
@IsOptional()
@IsIn(USER_CATEGORIES, { message: 'دسته‌بندی نامعتبر است' })
category?: string | null;
@IsOptional()
@IsString()
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
cellNumber?: string;
@IsOptional()
@IsBoolean()
disabled?: boolean;
}
+13
View File
@@ -0,0 +1,13 @@
export const USER_TITLES = [
'سرکار خانم',
'جناب آقای',
'جناب دکتر',
'خانم دکتر',
'جناب آقای مهندس',
'سرکار خانم مهندس',
] as const;
export const USER_CATEGORIES = ['پزشک', 'کسبه', 'مهندس'] as const;
export type UserTitle = (typeof USER_TITLES)[number];
export type UserCategory = (typeof USER_CATEGORIES)[number];
+102
View File
@@ -0,0 +1,102 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { UserRole } from '@prisma/client';
import type { AuthUser } from '../auth/auth.types';
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 { CreateUserAddressDto } from './dto/create-user-address.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { UpdateUserAddressDto } from './dto/update-user-address.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UpdateUserPasswordDto } from './dto/update-user-password.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
import { UsersService } from './users.service';
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.admin, UserRole.superAdmin)
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
list(@Query() query: ListUsersQueryDto) {
return this.users.list(query);
}
@Get(':id/addresses')
listAddresses(@Param('id') id: string) {
return this.users.listAddresses(id);
}
@Post(':id/addresses')
createAddress(@Param('id') id: string, @Body() dto: CreateUserAddressDto) {
return this.users.createAddress(id, dto);
}
@Patch(':id/addresses/:addressId')
updateAddress(
@Param('id') id: string,
@Param('addressId') addressId: string,
@Body() dto: UpdateUserAddressDto,
) {
return this.users.updateAddress(id, addressId, dto);
}
@Delete(':id/addresses/:addressId')
removeAddress(
@Param('id') id: string,
@Param('addressId') addressId: string,
) {
return this.users.removeAddress(id, addressId);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.users.findOne(id);
}
@Post()
create(@CurrentUser() actor: AuthUser, @Body() dto: CreateUserDto) {
return this.users.create(actor, dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
return this.users.update(id, dto);
}
@Patch(':id/role')
@Roles(UserRole.superAdmin)
updateRole(
@CurrentUser() actor: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateUserRoleDto,
) {
return this.users.updateRole(actor, id, dto);
}
@Patch(':id/password')
updatePassword(
@Param('id') id: string,
@Body() dto: UpdateUserPasswordDto,
) {
return this.users.updatePassword(id, dto);
}
@Delete(':id')
remove(@CurrentUser() actor: AuthUser, @Param('id') id: string) {
return this.users.remove(actor, id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+254
View File
@@ -0,0 +1,254 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { User, UserRole } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { isDistrict } from '../common/districts';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser, displayName } from '../auth/auth.types';
import { CreateUserAddressDto } from './dto/create-user-address.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { UpdateUserAddressDto } from './dto/update-user-address.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UpdateUserPasswordDto } from './dto/update-user-password.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async list(query: ListUsersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = {
...(query.role ? { role: query.role } : {}),
...(query.disabled !== undefined ? { disabled: query.disabled } : {}),
...(query.q
? {
OR: [
{ firstName: { contains: query.q, mode: 'insensitive' as const } },
{ lastName: { contains: query.q, mode: 'insensitive' as const } },
{ cellNumber: { contains: query.q } },
],
}
: {}),
};
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.user.count({ where }),
]);
return {
items: items.map((user) => this.serializeUser(user)),
total,
page,
pageSize,
};
}
async findOne(id: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException('کاربر یافت نشد');
}
return this.serializeUser(user);
}
async create(actor: AuthUser, dto: CreateUserDto) {
const role = dto.role ?? UserRole.customer;
this.assertCanAssignRole(actor, role);
const existing = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber },
});
if (existing) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: {
title: dto.title,
firstName: dto.firstName,
lastName: dto.lastName,
category: dto.category,
cellNumber: dto.cellNumber,
passwordHash,
role,
disabled: dto.disabled ?? false,
},
});
return this.serializeUser(user);
}
async update(id: string, dto: UpdateUserDto) {
await this.ensureExists(id);
if (dto.cellNumber) {
const existing = await this.prisma.user.findFirst({
where: { cellNumber: dto.cellNumber, NOT: { id } },
});
if (existing) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
}
const user = await this.prisma.user.update({
where: { id },
data: {
...(dto.title !== undefined ? { title: dto.title } : {}),
...(dto.firstName !== undefined ? { firstName: dto.firstName } : {}),
...(dto.lastName !== undefined ? { lastName: dto.lastName } : {}),
...(dto.category !== undefined ? { category: dto.category } : {}),
...(dto.cellNumber !== undefined ? { cellNumber: dto.cellNumber } : {}),
...(dto.disabled !== undefined ? { disabled: dto.disabled } : {}),
},
});
return this.serializeUser(user);
}
async updateRole(actor: AuthUser, id: string, dto: UpdateUserRoleDto) {
if (actor.role !== UserRole.superAdmin) {
throw new ForbiddenException('فقط سوپرادمین می‌تواند نقش را تغییر دهد');
}
await this.ensureExists(id);
const user = await this.prisma.user.update({
where: { id },
data: { role: dto.role },
});
return this.serializeUser(user);
}
async updatePassword(id: string, dto: UpdateUserPasswordDto) {
await this.ensureExists(id);
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.update({
where: { id },
data: { passwordHash },
});
return this.serializeUser(user);
}
async remove(actor: AuthUser, id: string) {
if (actor.id === id) {
throw new BadRequestException('امکان حذف حساب خود وجود ندارد');
}
await this.ensureExists(id);
await this.prisma.user.delete({ where: { id } });
return { ok: true };
}
async listAddresses(userId: string) {
await this.ensureExists(userId);
return this.prisma.userAddress.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
});
}
async createAddress(userId: string, dto: CreateUserAddressDto) {
await this.ensureExists(userId);
this.assertDistrict(dto.district);
return this.prisma.userAddress.create({
data: {
userId,
name: dto.name.trim(),
district: dto.district,
address: dto.address.trim(),
landline: dto.landline?.trim() ?? '',
},
});
}
async updateAddress(
userId: string,
addressId: string,
dto: UpdateUserAddressDto,
) {
await this.ensureAddress(userId, addressId);
if (dto.district !== undefined) {
this.assertDistrict(dto.district);
}
return this.prisma.userAddress.update({
where: { id: addressId },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.district !== undefined ? { district: dto.district } : {}),
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
...(dto.landline !== undefined
? { landline: dto.landline.trim() }
: {}),
},
});
}
async removeAddress(userId: string, addressId: string) {
await this.ensureAddress(userId, addressId);
await this.prisma.userAddress.delete({ where: { id: addressId } });
return { ok: true };
}
private assertDistrict(district: string) {
if (!isDistrict(district)) {
throw new BadRequestException('منطقه معتبر نیست');
}
}
private async ensureAddress(userId: string, addressId: string) {
await this.ensureExists(userId);
const address = await this.prisma.userAddress.findFirst({
where: { id: addressId, userId },
});
if (!address) {
throw new NotFoundException('آدرس یافت نشد');
}
return address;
}
private assertCanAssignRole(actor: AuthUser, role: UserRole) {
if (role === UserRole.admin || role === UserRole.superAdmin) {
if (actor.role !== UserRole.superAdmin) {
throw new ForbiddenException('فقط سوپرادمین می‌تواند ادمین ایجاد کند');
}
}
}
private async ensureExists(id: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException('کاربر یافت نشد');
}
return user;
}
private serializeUser(user: User) {
const { passwordHash: _passwordHash, ...rest } = user;
return {
...rest,
name: displayName(user),
};
}
}