Add SMS OTP auth, discounts, customer orders, and category slugs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
58380ab81d
commit
d09eca8702
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { DiscountsModule } from './discounts/discounts.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 { SmsModule } from './sms/sms.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
@@ -16,12 +18,14 @@ import { UsersModule } from './users/users.module';
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
StorageModule,
|
||||
SmsModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
FlavorsModule,
|
||||
CategoriesModule,
|
||||
ProductsModule,
|
||||
OrdersModule,
|
||||
DiscountsModule,
|
||||
MediaModule,
|
||||
SettingsModule,
|
||||
],
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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 { CellNumberDto } from './dto/cell-number.dto';
|
||||
import { ForgotPasswordResetDto } from './dto/forgot-password-reset.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { OtpVerifyDto } from './dto/otp-verify.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterSendCodeDto } from './dto/register-send-code.dto';
|
||||
import { RegisterVerifyDto } from './dto/register-verify.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './guards/roles.guard';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -18,6 +20,41 @@ export class AuthController {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@Post('login/send-code')
|
||||
loginSendCode(@Body() dto: CellNumberDto) {
|
||||
return this.auth.loginSendCode(dto);
|
||||
}
|
||||
|
||||
@Post('login/verify')
|
||||
loginVerify(@Body() dto: OtpVerifyDto) {
|
||||
return this.auth.loginVerify(dto);
|
||||
}
|
||||
|
||||
@Post('forgot-password/send-code')
|
||||
forgotPasswordSendCode(@Body() dto: CellNumberDto) {
|
||||
return this.auth.forgotPasswordSendCode(dto);
|
||||
}
|
||||
|
||||
@Post('forgot-password/verify')
|
||||
forgotPasswordVerify(@Body() dto: OtpVerifyDto) {
|
||||
return this.auth.forgotPasswordVerify(dto);
|
||||
}
|
||||
|
||||
@Post('forgot-password/reset')
|
||||
forgotPasswordReset(@Body() dto: ForgotPasswordResetDto) {
|
||||
return this.auth.forgotPasswordReset(dto);
|
||||
}
|
||||
|
||||
@Post('register/send-code')
|
||||
registerSendCode(@Body() dto: RegisterSendCodeDto) {
|
||||
return this.auth.registerSendCode(dto);
|
||||
}
|
||||
|
||||
@Post('register/verify')
|
||||
registerVerify(@Body() dto: RegisterVerifyDto) {
|
||||
return this.auth.registerVerify(dto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.auth.refresh(dto.refreshToken);
|
||||
@@ -29,8 +66,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.auth.me(user);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SmsModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
@@ -28,4 +30,4 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
export class AuthModule {}
|
||||
+291
-7
@@ -1,15 +1,43 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { SmsOtp, SmsOtpPurpose, UserRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { createHash, randomBytes, randomInt } from 'crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthUser, displayName, isElevatedRole } from './auth.types';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import { AuthUser, displayName } from './auth.types';
|
||||
import { CellNumberDto } from './dto/cell-number.dto';
|
||||
import { ForgotPasswordResetDto } from './dto/forgot-password-reset.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { OtpVerifyDto } from './dto/otp-verify.dto';
|
||||
import { RegisterSendCodeDto } from './dto/register-send-code.dto';
|
||||
import { RegisterVerifyDto } from './dto/register-verify.dto';
|
||||
|
||||
const DEFAULT_CUSTOMER_TITLE = 'جناب آقای';
|
||||
const OTP_TTL_MS = 5 * 60 * 1000;
|
||||
const OTP_RESEND_COOLDOWN_MS = 60 * 1000;
|
||||
const OTP_MAX_ATTEMPTS = 5;
|
||||
const OTP_LENGTH = 6;
|
||||
|
||||
type RegisterOtpPayload = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
title: string;
|
||||
passwordHash: string;
|
||||
};
|
||||
|
||||
type OtpSendResult = {
|
||||
ok: true;
|
||||
expiresInSeconds: number;
|
||||
resendAfterSeconds: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -17,6 +45,7 @@ export class AuthService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
@@ -32,10 +61,6 @@ export class AuthService {
|
||||
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
|
||||
if (!isElevatedRole(user.role)) {
|
||||
throw new ForbiddenException('فقط ادمین میتواند وارد پنل شود');
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
throw new UnauthorizedException('شماره یا رمز عبور اشتباه است');
|
||||
@@ -44,6 +69,146 @@ export class AuthService {
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
async loginSendCode(dto: CellNumberDto): Promise<OtpSendResult> {
|
||||
const user = await this.requireActiveUser(dto.cellNumber);
|
||||
return this.createAndSendOtp({
|
||||
cellNumber: user.cellNumber,
|
||||
purpose: SmsOtpPurpose.login,
|
||||
message: (code) => `کد ورود بلوط: ${code}`,
|
||||
});
|
||||
}
|
||||
|
||||
async loginVerify(dto: OtpVerifyDto) {
|
||||
await this.requireActiveUser(dto.cellNumber);
|
||||
await this.assertValidOtp({
|
||||
cellNumber: dto.cellNumber,
|
||||
purpose: SmsOtpPurpose.login,
|
||||
code: dto.code,
|
||||
consume: true,
|
||||
});
|
||||
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
});
|
||||
if (user.disabled) {
|
||||
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
async forgotPasswordSendCode(dto: CellNumberDto): Promise<OtpSendResult> {
|
||||
const user = await this.requireActiveUser(dto.cellNumber);
|
||||
return this.createAndSendOtp({
|
||||
cellNumber: user.cellNumber,
|
||||
purpose: SmsOtpPurpose.resetPassword,
|
||||
message: (code) => `کد بازیابی رمز بلوط: ${code}`,
|
||||
});
|
||||
}
|
||||
|
||||
async forgotPasswordVerify(dto: OtpVerifyDto) {
|
||||
await this.requireActiveUser(dto.cellNumber);
|
||||
await this.assertValidOtp({
|
||||
cellNumber: dto.cellNumber,
|
||||
purpose: SmsOtpPurpose.resetPassword,
|
||||
code: dto.code,
|
||||
consume: false,
|
||||
});
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
async forgotPasswordReset(dto: ForgotPasswordResetDto) {
|
||||
await this.requireActiveUser(dto.cellNumber);
|
||||
await this.assertValidOtp({
|
||||
cellNumber: dto.cellNumber,
|
||||
purpose: SmsOtpPurpose.resetPassword,
|
||||
code: dto.code,
|
||||
consume: true,
|
||||
});
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
||||
const user = await this.prisma.user.update({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
if (user.disabled) {
|
||||
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
|
||||
// Invalidate existing sessions after password change
|
||||
await this.prisma.refreshToken.deleteMany({ where: { userId: user.id } });
|
||||
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
async registerSendCode(dto: RegisterSendCodeDto): Promise<OtpSendResult> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const payload: RegisterOtpPayload = {
|
||||
firstName: dto.firstName.trim(),
|
||||
lastName: dto.lastName.trim(),
|
||||
title: DEFAULT_CUSTOMER_TITLE,
|
||||
passwordHash,
|
||||
};
|
||||
|
||||
return this.createAndSendOtp({
|
||||
cellNumber: dto.cellNumber,
|
||||
purpose: SmsOtpPurpose.register,
|
||||
payload,
|
||||
message: (code) => `کد تأیید بلوط: ${code}`,
|
||||
});
|
||||
}
|
||||
|
||||
async registerVerify(dto: RegisterVerifyDto) {
|
||||
const otp = await this.assertValidOtp({
|
||||
cellNumber: dto.cellNumber,
|
||||
purpose: SmsOtpPurpose.register,
|
||||
code: dto.code,
|
||||
consume: true,
|
||||
});
|
||||
|
||||
const payload = otp.payload as RegisterOtpPayload | null;
|
||||
if (
|
||||
!payload?.firstName ||
|
||||
!payload?.lastName ||
|
||||
!payload?.passwordHash ||
|
||||
!payload?.title
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'اطلاعات ثبتنام نامعتبر است. دوباره شروع کنید',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
title: payload.title,
|
||||
firstName: payload.firstName,
|
||||
lastName: payload.lastName,
|
||||
cellNumber: dto.cellNumber,
|
||||
passwordHash: payload.passwordHash,
|
||||
role: UserRole.customer,
|
||||
},
|
||||
});
|
||||
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
const tokenHash = this.hashToken(refreshToken);
|
||||
const stored = await this.prisma.refreshToken.findFirst({
|
||||
@@ -59,7 +224,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const user = stored.user;
|
||||
if (user.disabled || !isElevatedRole(user.role)) {
|
||||
if (user.disabled) {
|
||||
throw new UnauthorizedException('نشست نامعتبر است');
|
||||
}
|
||||
|
||||
@@ -90,6 +255,125 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
private async requireActiveUser(cellNumber: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { cellNumber } });
|
||||
if (!user) {
|
||||
throw new BadRequestException('کاربری با این شماره یافت نشد');
|
||||
}
|
||||
if (user.disabled) {
|
||||
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async createAndSendOtp(input: {
|
||||
cellNumber: string;
|
||||
purpose: SmsOtpPurpose;
|
||||
payload?: RegisterOtpPayload;
|
||||
message: (code: string) => string;
|
||||
}): Promise<OtpSendResult> {
|
||||
const latest = await this.prisma.smsOtp.findFirst({
|
||||
where: {
|
||||
cellNumber: input.cellNumber,
|
||||
purpose: input.purpose,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (
|
||||
latest &&
|
||||
Date.now() - latest.createdAt.getTime() < OTP_RESEND_COOLDOWN_MS
|
||||
) {
|
||||
const waitSec = Math.ceil(
|
||||
(OTP_RESEND_COOLDOWN_MS - (Date.now() - latest.createdAt.getTime())) /
|
||||
1000,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
`لطفاً ${waitSec} ثانیه دیگر دوباره تلاش کنید`,
|
||||
);
|
||||
}
|
||||
|
||||
const code = String(randomInt(0, 10 ** OTP_LENGTH)).padStart(
|
||||
OTP_LENGTH,
|
||||
'0',
|
||||
);
|
||||
|
||||
await this.prisma.smsOtp.deleteMany({
|
||||
where: {
|
||||
cellNumber: input.cellNumber,
|
||||
purpose: input.purpose,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.smsOtp.create({
|
||||
data: {
|
||||
cellNumber: input.cellNumber,
|
||||
purpose: input.purpose,
|
||||
codeHash: this.hashToken(code),
|
||||
payload: input.payload ?? undefined,
|
||||
expiresAt: new Date(Date.now() + OTP_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
await this.sms.send({
|
||||
to: input.cellNumber,
|
||||
message: input.message(code),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
expiresInSeconds: Math.floor(OTP_TTL_MS / 1000),
|
||||
resendAfterSeconds: Math.floor(OTP_RESEND_COOLDOWN_MS / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/** Validates OTP. When consume=true, deletes it on success. */
|
||||
private async assertValidOtp(input: {
|
||||
cellNumber: string;
|
||||
purpose: SmsOtpPurpose;
|
||||
code: string;
|
||||
consume: boolean;
|
||||
}): Promise<SmsOtp> {
|
||||
const otp = await this.prisma.smsOtp.findFirst({
|
||||
where: {
|
||||
cellNumber: input.cellNumber,
|
||||
purpose: input.purpose,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!otp || otp.expiresAt < new Date()) {
|
||||
if (otp) {
|
||||
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||
}
|
||||
throw new BadRequestException(
|
||||
'کد تأیید منقضی شده است. دوباره درخواست کنید',
|
||||
);
|
||||
}
|
||||
|
||||
if (otp.attempts >= OTP_MAX_ATTEMPTS) {
|
||||
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||
throw new BadRequestException(
|
||||
'تعداد تلاشها بیش از حد مجاز است. دوباره درخواست کنید',
|
||||
);
|
||||
}
|
||||
|
||||
const codeHash = this.hashToken(input.code.trim());
|
||||
if (codeHash !== otp.codeHash) {
|
||||
await this.prisma.smsOtp.update({
|
||||
where: { id: otp.id },
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
throw new BadRequestException('کد تأیید نادرست است');
|
||||
}
|
||||
|
||||
if (input.consume) {
|
||||
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||
}
|
||||
|
||||
return otp;
|
||||
}
|
||||
|
||||
private async issueTokens(user: {
|
||||
id: string;
|
||||
cellNumber: string;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
export class CellNumberDto {
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
cellNumber!: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IsString, Length, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class ForgotPasswordResetDto {
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(4, 8, { message: 'کد تأیید نامعتبر است' })
|
||||
@Matches(/^\d+$/, { message: 'کد تأیید نامعتبر است' })
|
||||
code!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class OtpVerifyDto {
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(4, 8, { message: 'کد تأیید نامعتبر است' })
|
||||
@Matches(/^\d+$/, { message: 'کد تأیید نامعتبر است' })
|
||||
code!: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterSendCodeDto {
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'نام الزامی است' })
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'نام خانوادگی الزامی است' })
|
||||
lastName!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsString, Matches, Length } from 'class-validator';
|
||||
|
||||
export class RegisterVerifyDto {
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(4, 8, { message: 'کد تأیید نامعتبر است' })
|
||||
@Matches(/^\d+$/, { message: 'کد تأیید نامعتبر است' })
|
||||
code!: string;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
import { AuthUser } from '../auth.types';
|
||||
|
||||
type JwtPayload = {
|
||||
sub: string;
|
||||
@@ -29,7 +29,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user || user.disabled || !isElevatedRole(user.role)) {
|
||||
if (!user || user.disabled) {
|
||||
throw new UnauthorizedException('نشست نامعتبر است');
|
||||
}
|
||||
|
||||
|
||||
@@ -19,37 +19,46 @@ 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) {}
|
||||
|
||||
/** Public category tree for storefront filters */
|
||||
@Get()
|
||||
listTree() {
|
||||
return this.categories.listTree();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
create(@Body() dto: CreateCategoryDto) {
|
||||
return this.categories.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCategoryDto) {
|
||||
return this.categories.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.categories.remove(id);
|
||||
}
|
||||
|
||||
@Get(':id/options')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
getOptions(@Param('id') id: string) {
|
||||
return this.categories.getOptions(id);
|
||||
}
|
||||
|
||||
@Put(':id/options')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
replaceOptions(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ReplaceCategoryOptionsDto,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { slugify } from '../common/slugify';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { buildCategoryTree } from './categories.utils';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
@@ -26,10 +27,13 @@ export class CategoriesService {
|
||||
await this.ensureExists(dto.parentId);
|
||||
}
|
||||
|
||||
const slug = await this.ensureUniqueSlug(dto.slug?.trim() || slugify(dto.nameEn));
|
||||
|
||||
return this.prisma.category.create({
|
||||
data: {
|
||||
nameFa: dto.nameFa,
|
||||
nameEn: dto.nameEn,
|
||||
slug,
|
||||
parentId: dto.parentId ?? null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
@@ -46,11 +50,19 @@ export class CategoriesService {
|
||||
await this.ensureExists(dto.parentId);
|
||||
}
|
||||
|
||||
const slug =
|
||||
dto.slug !== undefined
|
||||
? await this.ensureUniqueSlug(dto.slug.trim() || slugify(dto.nameEn || ''), id)
|
||||
: dto.nameEn !== undefined
|
||||
? await this.ensureUniqueSlug(slugify(dto.nameEn), id)
|
||||
: undefined;
|
||||
|
||||
return this.prisma.category.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
|
||||
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
|
||||
...(slug !== undefined ? { slug } : {}),
|
||||
...(dto.parentId !== undefined ? { parentId: dto.parentId } : {}),
|
||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||
},
|
||||
@@ -134,4 +146,21 @@ export class CategoriesService {
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(base: string, excludeId?: string) {
|
||||
const normalized = slugify(base);
|
||||
let candidate = normalized;
|
||||
let suffix = 2;
|
||||
while (true) {
|
||||
const existing = await this.prisma.category.findUnique({
|
||||
where: { slug: candidate },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing || existing.id === excludeId) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = `${normalized}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsInt, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { IsInt, IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@IsString()
|
||||
@@ -9,6 +9,13 @@ export class CreateCategoryDto {
|
||||
@MinLength(1)
|
||||
nameEn!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'slug باید فقط شامل حروف انگلیسی کوچک، عدد و خط تیره باشد',
|
||||
})
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsInt, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { IsInt, IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateCategoryDto {
|
||||
@IsOptional()
|
||||
@@ -11,6 +11,13 @@ export class UpdateCategoryDto {
|
||||
@MinLength(1)
|
||||
nameEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'slug باید فقط شامل حروف انگلیسی کوچک، عدد و خط تیره باشد',
|
||||
})
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Build a URL-safe slug from English (or latin) text. */
|
||||
export function slugify(input: string) {
|
||||
const base = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['']/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return base || 'category'
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 { CreateDiscountDto } from './dto/create-discount.dto';
|
||||
import { ListDiscountsQueryDto } from './dto/list-discounts-query.dto';
|
||||
import { UpdateDiscountDto } from './dto/update-discount.dto';
|
||||
import { DiscountsService } from './discounts.service';
|
||||
|
||||
@Controller('discounts')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
export class DiscountsController {
|
||||
constructor(private readonly discounts: DiscountsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: ListDiscountsQueryDto) {
|
||||
return this.discounts.list(query);
|
||||
}
|
||||
|
||||
@Get('mine')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
listMine(
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Query() query: ListDiscountsQueryDto,
|
||||
) {
|
||||
return this.discounts.listMine(actor.id, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() actor: AuthUser, @Body() dto: CreateDiscountDto) {
|
||||
return this.discounts.create(actor.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateDiscountDto) {
|
||||
return this.discounts.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.discounts.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DiscountsController } from './discounts.controller';
|
||||
import { DiscountsService } from './discounts.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DiscountsController],
|
||||
providers: [DiscountsService],
|
||||
exports: [DiscountsService],
|
||||
})
|
||||
export class DiscountsModule {}
|
||||
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { displayName } from '../auth/auth.types';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateDiscountDto } from './dto/create-discount.dto';
|
||||
import { ListDiscountsQueryDto } from './dto/list-discounts-query.dto';
|
||||
import { UpdateDiscountDto } from './dto/update-discount.dto';
|
||||
|
||||
const discountInclude = {
|
||||
category: { select: { id: true, nameFa: true, nameEn: true } },
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
cellNumber: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.DiscountInclude;
|
||||
|
||||
type DiscountRow = Prisma.DiscountGetPayload<{ include: typeof discountInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class DiscountsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: ListDiscountsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const code = query.code?.trim() || query.q?.trim();
|
||||
const userFilter = query.user?.trim();
|
||||
const expiresRange = this.expiresOnRange(query.expiresOn);
|
||||
|
||||
const where: Prisma.DiscountWhereInput = {
|
||||
...(query.generalOnly ? { userId: null } : {}),
|
||||
...(query.userId ? { userId: query.userId } : {}),
|
||||
...(query.active !== undefined ? { active: query.active } : {}),
|
||||
...(code
|
||||
? { code: { contains: code, mode: 'insensitive' as const } }
|
||||
: {}),
|
||||
...(expiresRange ? { expiresAt: expiresRange } : {}),
|
||||
...(userFilter
|
||||
? userFilter === 'عمومی'
|
||||
? { userId: null }
|
||||
: {
|
||||
user: {
|
||||
OR: [
|
||||
{
|
||||
firstName: {
|
||||
contains: userFilter,
|
||||
mode: 'insensitive' as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
lastName: {
|
||||
contains: userFilter,
|
||||
mode: 'insensitive' as const,
|
||||
},
|
||||
},
|
||||
{ cellNumber: { contains: userFilter } },
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.discount.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: discountInclude,
|
||||
}),
|
||||
this.prisma.discount.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.serialize(row)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async listMine(userId: string, query: ListDiscountsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.DiscountWhereInput = {
|
||||
OR: [{ userId: null }, { userId }],
|
||||
...(query.active !== undefined ? { active: query.active } : {}),
|
||||
...(query.q
|
||||
? {
|
||||
code: { contains: query.q, mode: 'insensitive' as const },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.discount.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: discountInclude,
|
||||
}),
|
||||
this.prisma.discount.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.serialize(row)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async create(actorId: string, dto: CreateDiscountDto) {
|
||||
const userId = dto.userId?.trim() ? dto.userId.trim() : null;
|
||||
if (userId) {
|
||||
await this.ensureUser(userId);
|
||||
}
|
||||
if (dto.categoryId) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
}
|
||||
|
||||
const code = dto.code.trim().toUpperCase();
|
||||
await this.ensureCodeAvailable(code);
|
||||
this.assertExpiresAt(dto.expiresAt);
|
||||
|
||||
try {
|
||||
const row = await this.prisma.discount.create({
|
||||
data: {
|
||||
code,
|
||||
userId,
|
||||
categoryId: dto.categoryId ?? null,
|
||||
minOrderAmount: dto.minOrderAmount,
|
||||
expiresAt: new Date(dto.expiresAt),
|
||||
percent: dto.percent,
|
||||
maxValue: dto.maxValue,
|
||||
active: dto.active ?? true,
|
||||
createdByAdminId: actorId,
|
||||
},
|
||||
include: discountInclude,
|
||||
});
|
||||
return this.serialize(row);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('این کد تخفیف قبلاً ثبت شده است');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateDiscountDto) {
|
||||
await this.ensureExists(id);
|
||||
|
||||
const nextUserId =
|
||||
dto.userId === undefined
|
||||
? undefined
|
||||
: dto.userId?.trim()
|
||||
? dto.userId.trim()
|
||||
: null;
|
||||
|
||||
if (nextUserId) {
|
||||
await this.ensureUser(nextUserId);
|
||||
}
|
||||
if (dto.categoryId) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
}
|
||||
if (dto.expiresAt) {
|
||||
this.assertExpiresAt(dto.expiresAt);
|
||||
}
|
||||
|
||||
const code = dto.code?.trim().toUpperCase();
|
||||
if (code) {
|
||||
await this.ensureCodeAvailable(code, id);
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await this.prisma.discount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(code ? { code } : {}),
|
||||
...(nextUserId !== undefined ? { userId: nextUserId } : {}),
|
||||
...(dto.categoryId !== undefined
|
||||
? { categoryId: dto.categoryId }
|
||||
: {}),
|
||||
...(dto.minOrderAmount !== undefined
|
||||
? { minOrderAmount: dto.minOrderAmount }
|
||||
: {}),
|
||||
...(dto.expiresAt !== undefined
|
||||
? { expiresAt: new Date(dto.expiresAt) }
|
||||
: {}),
|
||||
...(dto.percent !== undefined ? { percent: dto.percent } : {}),
|
||||
...(dto.maxValue !== undefined ? { maxValue: dto.maxValue } : {}),
|
||||
...(dto.active !== undefined ? { active: dto.active } : {}),
|
||||
},
|
||||
include: discountInclude,
|
||||
});
|
||||
return this.serialize(row);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('این کد تخفیف قبلاً ثبت شده است');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.ensureExists(id);
|
||||
await this.prisma.discount.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private serialize(row: DiscountRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
categoryId: row.categoryId,
|
||||
category: row.category
|
||||
? {
|
||||
id: row.category.id,
|
||||
nameFa: row.category.nameFa,
|
||||
nameEn: row.category.nameEn,
|
||||
}
|
||||
: null,
|
||||
userId: row.userId,
|
||||
user: row.user
|
||||
? {
|
||||
id: row.user.id,
|
||||
title: row.user.title,
|
||||
firstName: row.user.firstName,
|
||||
lastName: row.user.lastName,
|
||||
cellNumber: row.user.cellNumber,
|
||||
role: row.user.role,
|
||||
name: displayName(row.user),
|
||||
}
|
||||
: null,
|
||||
minOrderAmount: row.minOrderAmount,
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
percent: row.percent,
|
||||
maxValue: row.maxValue,
|
||||
active: row.active,
|
||||
createdByAdminId: row.createdByAdminId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
expired: row.expiresAt.getTime() < Date.now(),
|
||||
general: row.userId === null,
|
||||
};
|
||||
}
|
||||
|
||||
private expiresOnRange(value?: string) {
|
||||
if (!value?.trim()) return undefined;
|
||||
const day = new Date(value);
|
||||
if (Number.isNaN(day.getTime())) return undefined;
|
||||
const start = new Date(day);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(day);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return { gte: start, lte: end };
|
||||
}
|
||||
|
||||
private assertExpiresAt(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException('تاریخ انقضا نامعتبر است');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCodeAvailable(code: string, excludeId?: string) {
|
||||
const existing = await this.prisma.discount.findFirst({
|
||||
where: {
|
||||
code,
|
||||
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('این کد تخفیف قبلاً ثبت شده است');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUser(id: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('کاربر یافت نشد');
|
||||
}
|
||||
if (user.disabled) {
|
||||
throw new BadRequestException('کاربر غیرفعال است');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async ensureCategory(id: string) {
|
||||
const category = await this.prisma.category.findUnique({ where: { id } });
|
||||
if (!category) {
|
||||
throw new NotFoundException('دستهبندی یافت نشد');
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
private async ensureExists(id: string) {
|
||||
const discount = await this.prisma.discount.findUnique({ where: { id } });
|
||||
if (!discount) {
|
||||
throw new NotFoundException('کد تخفیف یافت نشد');
|
||||
}
|
||||
return discount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateDiscountDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@Matches(/^[A-Za-z0-9_-]+$/, {
|
||||
message: 'کد تخفیف فقط میتواند شامل حروف، عدد، - و _ باشد',
|
||||
})
|
||||
code!: string;
|
||||
|
||||
/** null/omitted = general code usable by all users */
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null && value !== undefined && value !== '')
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
userId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null && value !== undefined)
|
||||
@IsString()
|
||||
categoryId?: string | null;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minOrderAmount!: number;
|
||||
|
||||
@IsDateString()
|
||||
expiresAt!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
percent!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxValue!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListDiscountsQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
/** Match discounts expiring on this day (ISO date or datetime). */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresOn?: string;
|
||||
|
||||
/** User name/phone search, or «عمومی» for general codes. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
user?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
/** When true, only discounts with no assigned user (usable by everyone). */
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === 'true' || value === true) return true;
|
||||
if (value === 'false' || value === false) return false;
|
||||
return undefined;
|
||||
})
|
||||
@IsBoolean()
|
||||
generalOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === 'true' || value === true) return true;
|
||||
if (value === 'false' || value === false) return false;
|
||||
return undefined;
|
||||
})
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number = 20;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class UpdateDiscountDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@Matches(/^[A-Za-z0-9_-]+$/, {
|
||||
message: 'کد تخفیف فقط میتواند شامل حروف، عدد، - و _ باشد',
|
||||
})
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null && value !== undefined && value !== '')
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
userId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null && value !== undefined)
|
||||
@IsString()
|
||||
categoryId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minOrderAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
percent?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxValue?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { DeliveryType } from '@prisma/client';
|
||||
import { CreateOrderItemDto } from './create-order.dto';
|
||||
|
||||
/** Storefront checkout — customerId comes from JWT. */
|
||||
export class CreateMyOrderDto {
|
||||
@IsEnum(DeliveryType)
|
||||
deliveryType!: DeliveryType;
|
||||
|
||||
@ValidateIf((dto: CreateMyOrderDto) => dto.deliveryType === DeliveryType.pickup)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
branchId?: string;
|
||||
|
||||
@ValidateIf(
|
||||
(dto: CreateMyOrderDto) => dto.deliveryType === DeliveryType.shipping,
|
||||
)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
shippingAddressId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
discountCode?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateOrderItemDto)
|
||||
items!: CreateOrderItemDto[];
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
@@ -18,8 +18,9 @@ export class CreateOrderItemDto {
|
||||
@MinLength(1)
|
||||
productId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 3 })
|
||||
@Min(0.1)
|
||||
quantity!: number;
|
||||
|
||||
@IsOptional()
|
||||
@@ -52,6 +53,10 @@ export class CreateOrderDto {
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
discountCode?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
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 { CreateMyOrderDto } from './dto/create-my-order.dto';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
import { ListOrdersQueryDto } from './dto/list-orders-query.dto';
|
||||
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
||||
@@ -28,6 +31,38 @@ export class OrdersController {
|
||||
return this.orders.list(query);
|
||||
}
|
||||
|
||||
@Get('mine')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
listMine(
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Query() query: ListOrdersQueryDto,
|
||||
) {
|
||||
return this.orders.list({
|
||||
...query,
|
||||
customerId: actor.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('mine')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
createMine(@CurrentUser() actor: AuthUser, @Body() dto: CreateMyOrderDto) {
|
||||
return this.orders.create({
|
||||
customerId: actor.id,
|
||||
deliveryType: dto.deliveryType,
|
||||
branchId: dto.branchId,
|
||||
shippingAddressId: dto.shippingAddressId,
|
||||
note: dto.note?.trim() || undefined,
|
||||
discountCode: dto.discountCode?.trim() || undefined,
|
||||
items: dto.items,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('mine/:id')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
findMine(@CurrentUser() actor: AuthUser, @Param('id') id: string) {
|
||||
return this.orders.findOneForCustomer(actor.id, id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.orders.findOne(id);
|
||||
|
||||
@@ -95,6 +95,24 @@ export class OrdersService {
|
||||
return this.serializeOrder(order);
|
||||
}
|
||||
|
||||
async findOneForCustomer(customerId: string, id: string) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id, customerId },
|
||||
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 },
|
||||
@@ -110,10 +128,18 @@ export class OrdersService {
|
||||
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 itemsSubtotal = Math.round(
|
||||
lineInputs.reduce(
|
||||
(sum, item) => sum + item.quantity * item.unitTotal,
|
||||
0,
|
||||
),
|
||||
);
|
||||
const discount = await this.resolveDiscount(
|
||||
dto.discountCode,
|
||||
customer.id,
|
||||
itemsSubtotal,
|
||||
);
|
||||
const totalPrice = Math.max(0, itemsSubtotal - discount.discountAmount);
|
||||
|
||||
const order = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
@@ -130,6 +156,8 @@ export class OrdersService {
|
||||
shippingAddressLine: delivery.shippingAddressLine,
|
||||
shippingLandline: delivery.shippingLandline,
|
||||
note: dto.note?.trim() ?? '',
|
||||
discountCode: discount.discountCode,
|
||||
discountAmount: discount.discountAmount,
|
||||
itemCount,
|
||||
totalPrice,
|
||||
items: {
|
||||
@@ -252,6 +280,18 @@ export class OrdersService {
|
||||
throw new NotFoundException(`محصول یافت نشد: ${item.productId}`);
|
||||
}
|
||||
|
||||
if (product.sellUnit === 'unit') {
|
||||
if (!Number.isInteger(item.quantity) || item.quantity < 1) {
|
||||
throw new BadRequestException(
|
||||
`تعداد «${product.nameFa}» باید عدد صحیح و حداقل ۱ باشد`,
|
||||
);
|
||||
}
|
||||
} else if (!(item.quantity >= 0.1)) {
|
||||
throw new BadRequestException(
|
||||
`وزن «${product.nameFa}» باید حداقل ۰٫۱ کیلو باشد`,
|
||||
);
|
||||
}
|
||||
|
||||
const selectedIds = item.optionIds ?? [];
|
||||
const optionsById = new Map(
|
||||
product.options.map((option) => [option.id, option]),
|
||||
@@ -314,6 +354,8 @@ export class OrdersService {
|
||||
totalPrice: order.totalPrice,
|
||||
status: order.status as OrderStatus,
|
||||
note: order.note || undefined,
|
||||
discountCode: order.discountCode || undefined,
|
||||
discountAmount: order.discountAmount || 0,
|
||||
delivery,
|
||||
items: order.items.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -333,4 +375,37 @@ export class OrdersService {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveDiscount(
|
||||
code: string | undefined,
|
||||
customerId: string,
|
||||
itemsSubtotal: number,
|
||||
) {
|
||||
const trimmed = code?.trim();
|
||||
if (!trimmed) {
|
||||
return { discountCode: null as string | null, discountAmount: 0 };
|
||||
}
|
||||
|
||||
const discount = await this.prisma.discount.findFirst({
|
||||
where: {
|
||||
code: { equals: trimmed, mode: 'insensitive' },
|
||||
active: true,
|
||||
expiresAt: { gt: new Date() },
|
||||
OR: [{ userId: null }, { userId: customerId }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!discount) {
|
||||
throw new BadRequestException('کد تخفیف معتبر نیست');
|
||||
}
|
||||
if (itemsSubtotal < discount.minOrderAmount) {
|
||||
throw new BadRequestException(
|
||||
`حداقل مبلغ سفارش برای این کد ${discount.minOrderAmount.toLocaleString('fa-IR')} تومان است`,
|
||||
);
|
||||
}
|
||||
|
||||
const raw = Math.round((itemsSubtotal * discount.percent) / 100);
|
||||
const discountAmount = Math.min(raw, discount.maxValue);
|
||||
return { discountCode: discount.code, discountAmount };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ export class ListProductsQueryDto {
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categorySlug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
|
||||
@@ -19,32 +19,38 @@ 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) {}
|
||||
|
||||
/** Public storefront + admin catalog list */
|
||||
@Get()
|
||||
list(@Query() query: ListProductsQueryDto) {
|
||||
return this.products.list(query);
|
||||
}
|
||||
|
||||
/** Public storefront + admin product detail */
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.products.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
create(@Body() dto: CreateProductDto) {
|
||||
return this.products.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
||||
return this.products.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.products.remove(id);
|
||||
}
|
||||
|
||||
@@ -28,8 +28,13 @@ export class ProductsService {
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const categoryIds = await this.resolveCategoryFilter(
|
||||
query.categoryId,
|
||||
query.categorySlug,
|
||||
);
|
||||
|
||||
const where = {
|
||||
...(query.categoryId ? { categoryId: query.categoryId } : {}),
|
||||
...(categoryIds ? { categoryId: { in: categoryIds } } : {}),
|
||||
...(query.minPrice !== undefined || query.maxPrice !== undefined
|
||||
? {
|
||||
price: {
|
||||
@@ -54,7 +59,10 @@ export class ProductsService {
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { category: true },
|
||||
include: {
|
||||
category: true,
|
||||
options: { include: { flavor: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
@@ -242,6 +250,58 @@ export class ProductsService {
|
||||
return category;
|
||||
}
|
||||
|
||||
private async resolveCategoryFilter(
|
||||
categoryId?: string,
|
||||
categorySlug?: string,
|
||||
) {
|
||||
if (categoryId) {
|
||||
return this.collectCategoryIds(categoryId);
|
||||
}
|
||||
if (!categorySlug?.trim()) {
|
||||
return null;
|
||||
}
|
||||
const category = await this.prisma.category.findUnique({
|
||||
where: { slug: categorySlug.trim() },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!category) {
|
||||
throw new NotFoundException('دستهبندی یافت نشد');
|
||||
}
|
||||
return this.collectCategoryIds(category.id);
|
||||
}
|
||||
|
||||
/** Include the category and all nested descendants for storefront filters. */
|
||||
private async collectCategoryIds(rootId: string) {
|
||||
const rows = await this.prisma.category.findMany({
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
const childrenByParent = new Map<string | null, string[]>();
|
||||
for (const row of rows) {
|
||||
const list = childrenByParent.get(row.parentId) ?? [];
|
||||
list.push(row.id);
|
||||
childrenByParent.set(row.parentId, list);
|
||||
}
|
||||
|
||||
const ids: string[] = [];
|
||||
const stack = [rootId];
|
||||
const seen = new Set<string>();
|
||||
while (stack.length) {
|
||||
const current = stack.pop()!;
|
||||
if (seen.has(current)) continue;
|
||||
seen.add(current);
|
||||
ids.push(current);
|
||||
for (const childId of childrenByParent.get(current) ?? []) {
|
||||
stack.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!seen.has(rootId) && !rows.some((row) => row.id === rootId)) {
|
||||
throw new NotFoundException('دستهبندی یافت نشد');
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async validateProductOptions(
|
||||
categoryId: string,
|
||||
options: ProductOptionInput[] | undefined,
|
||||
|
||||
@@ -25,11 +25,13 @@ export class SettingsController {
|
||||
constructor(private readonly settings: SettingsService) {}
|
||||
|
||||
@Get('districts')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
getDistricts() {
|
||||
return this.settings.getDistricts();
|
||||
}
|
||||
|
||||
@Get('shipping')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
listShipping() {
|
||||
return this.settings.listShippingExceptions();
|
||||
}
|
||||
@@ -40,6 +42,7 @@ export class SettingsController {
|
||||
}
|
||||
|
||||
@Get('branches')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
listBranches() {
|
||||
return this.settings.listBranches();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@Module({
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SendSmsInput, SendSmsResult } from './sms.types';
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
private readonly apiUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly domain: string;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.apiUrl = this.config.getOrThrow<string>('MESHKEE_SMS_URL');
|
||||
this.apiKey = this.config.getOrThrow<string>('MESHKEE_SMS_API_KEY');
|
||||
this.domain = this.config.getOrThrow<string>('MESHKEE_SMS_DOMAIN');
|
||||
}
|
||||
|
||||
async send(input: SendSmsInput): Promise<SendSmsResult> {
|
||||
const to = input.to.trim();
|
||||
const message = input.message.trim();
|
||||
|
||||
if (!/^09\d{9}$/.test(to)) {
|
||||
throw new InternalServerErrorException('Invalid SMS destination');
|
||||
}
|
||||
if (!message) {
|
||||
throw new InternalServerErrorException('SMS message is empty');
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': this.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain: this.domain,
|
||||
to,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error('Meshkee SMS request failed', err);
|
||||
throw new ServiceUnavailableException('SMS provider unreachable');
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| { success?: boolean; serverId?: string; message?: string }
|
||||
| null;
|
||||
|
||||
if (!response.ok || !body?.success || !body.serverId) {
|
||||
this.logger.error(
|
||||
`Meshkee SMS rejected: status=${response.status} body=${JSON.stringify(body)}`,
|
||||
);
|
||||
throw new ServiceUnavailableException('SMS send failed');
|
||||
}
|
||||
|
||||
return { success: true, serverId: body.serverId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type SendSmsInput = {
|
||||
to: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SendSmsResult = {
|
||||
success: true;
|
||||
serverId: string;
|
||||
};
|
||||
@@ -30,6 +30,52 @@ import { UsersService } from './users.service';
|
||||
export class UsersController {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
me(@CurrentUser() actor: AuthUser) {
|
||||
return this.users.findOne(actor.id);
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
updateMe(@CurrentUser() actor: AuthUser, @Body() dto: UpdateUserDto) {
|
||||
return this.users.updateMe(actor, dto);
|
||||
}
|
||||
|
||||
@Get('me/addresses')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
listMyAddresses(@CurrentUser() actor: AuthUser) {
|
||||
return this.users.listAddresses(actor.id);
|
||||
}
|
||||
|
||||
@Post('me/addresses')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
createMyAddress(
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Body() dto: CreateUserAddressDto,
|
||||
) {
|
||||
return this.users.createAddress(actor.id, dto);
|
||||
}
|
||||
|
||||
@Patch('me/addresses/:addressId')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
updateMyAddress(
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Param('addressId') addressId: string,
|
||||
@Body() dto: UpdateUserAddressDto,
|
||||
) {
|
||||
return this.users.updateAddress(actor.id, addressId, dto);
|
||||
}
|
||||
|
||||
@Delete('me/addresses/:addressId')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
removeMyAddress(
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Param('addressId') addressId: string,
|
||||
) {
|
||||
return this.users.removeAddress(actor.id, addressId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: ListUsersQueryDto) {
|
||||
return this.users.list(query);
|
||||
@@ -88,11 +134,13 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Patch(':id/password')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
updatePassword(
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserPasswordDto,
|
||||
) {
|
||||
return this.users.updatePassword(id, dto);
|
||||
return this.users.updatePassword(actor, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
@@ -122,6 +122,17 @@ export class UsersService {
|
||||
return this.serializeUser(user);
|
||||
}
|
||||
|
||||
async updateMe(actor: AuthUser, dto: UpdateUserDto) {
|
||||
// Self-service: never allow flipping disabled via /me.
|
||||
return this.update(actor.id, {
|
||||
title: dto.title,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
category: dto.category,
|
||||
cellNumber: dto.cellNumber,
|
||||
});
|
||||
}
|
||||
|
||||
async updateRole(actor: AuthUser, id: string, dto: UpdateUserRoleDto) {
|
||||
if (actor.role !== UserRole.superAdmin) {
|
||||
throw new ForbiddenException('فقط سوپرادمین میتواند نقش را تغییر دهد');
|
||||
@@ -137,7 +148,15 @@ export class UsersService {
|
||||
return this.serializeUser(user);
|
||||
}
|
||||
|
||||
async updatePassword(id: string, dto: UpdateUserPasswordDto) {
|
||||
async updatePassword(
|
||||
actor: AuthUser,
|
||||
id: string,
|
||||
dto: UpdateUserPasswordDto,
|
||||
) {
|
||||
if (actor.role === UserRole.customer && actor.id !== id) {
|
||||
throw new ForbiddenException('فقط میتوانید رمز عبور خود را تغییر دهید');
|
||||
}
|
||||
|
||||
await this.ensureExists(id);
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
|
||||
Reference in New Issue
Block a user