Add SMS OTP auth, discounts, customer orders, and category slugs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-05 15:15:10 +03:30
co-authored by Cursor
parent 58380ab81d
commit d09eca8702
48 changed files with 1719 additions and 38 deletions
+4
View File
@@ -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,
],
+41 -5
View File
@@ -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);
}
+3 -1
View File
@@ -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
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
import { IsString, Matches } from 'class-validator';
export class CellNumberDto {
@IsString()
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
cellNumber!: string;
}
+16
View File
@@ -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;
}
+12
View File
@@ -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;
}
+19
View File
@@ -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;
}
+12
View File
@@ -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;
}
+2 -2
View File
@@ -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('نشست نامعتبر است');
}
+11 -2
View File
@@ -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,
+29
View File
@@ -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;
}
}
}
+8 -1
View File
@@ -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;
+8 -1
View File
@@ -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;
+10
View File
@@ -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'
}
+57
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+326
View File
@@ -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;
}
}
+57
View File
@@ -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;
}
+61
View File
@@ -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;
}
+45
View File
@@ -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[];
}
+8 -3
View File
@@ -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 })
+35
View File
@@ -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);
+78 -3
View File
@@ -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()
+8 -2
View File
@@ -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);
}
+62 -2
View File
@@ -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,
+3
View File
@@ -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();
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}
+66
View File
@@ -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 };
}
}
+9
View File
@@ -0,0 +1,9 @@
export type SendSmsInput = {
to: string;
message: string;
};
export type SendSmsResult = {
success: true;
serverId: string;
};
+49 -1
View File
@@ -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')
+20 -1
View File
@@ -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);