Add SMS OTP, harden auth, and fix order/checkout server bugs.

Ship 4-digit OTP with rate-limit attempts, Tehran-safe expiry/rass math, larger JSON body limit for check images, and safer order pricing/status guards. Include rebuilt dist for prebuilt deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-08 14:55:26 +03:30
co-authored by Cursor
parent b121797263
commit 345a4a4708
100 changed files with 1476 additions and 353 deletions
+8 -2
View File
@@ -56,7 +56,10 @@ export class AdminUsersService {
});
return this.map(user);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('شماره یا ایمیل تکراری است');
}
throw e;
@@ -91,7 +94,10 @@ export class AdminUsersService {
});
return this.map(user);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('شماره یا ایمیل تکراری است');
}
throw e;
+5 -2
View File
@@ -3,16 +3,19 @@ import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { SmsModule } from '../sms/sms.module';
import { resolveJwtSecret } from '../common/utils/jwt-secret';
@Module({
imports: [
SmsModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET', 'fabric-trade-dev-secret'),
secret: resolveJwtSecret(config),
signOptions: {
expiresIn: config.get('JWT_EXPIRES_IN', '7d') as `${number}d`,
expiresIn: config.get('JWT_EXPIRES_IN', '7d'),
},
}),
}),
+70 -17
View File
@@ -1,19 +1,28 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { randomInt } from 'node:crypto';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import {
AdminLoginDto,
RequestOtpDto,
UpdateProfileDto,
VerifyOtpDto,
} from './dto/auth.dto';
import { isValidPhone, normalizePhone, toEnglishDigits } from '../common/utils/phone';
import {
isValidPhone,
normalizePhone,
toEnglishDigits,
} from '../common/utils/phone';
const MAX_OTP_ATTEMPTS = 5;
@Injectable()
export class AuthService {
@@ -21,6 +30,7 @@ export class AuthService {
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly sms: SmsService,
) {}
async requestOtp(dto: RequestOtpDto) {
@@ -29,7 +39,9 @@ export class AuthService {
throw new BadRequestException('شماره موبایل نامعتبر است');
}
const existing = await this.prisma.customer.findUnique({ where: { phone } });
const existing = await this.prisma.customer.findUnique({
where: { phone },
});
const isRegister = Boolean(dto.isRegister);
if (isRegister) {
@@ -41,33 +53,58 @@ export class AuthService {
}
} else if (!existing) {
throw new BadRequestException('کاربری با این شماره یافت نشد');
} else if (!existing.isActive) {
throw new UnauthorizedException('حساب کاربری شما غیرفعال است');
}
const code = this.config.get<string>('MOCK_OTP_CODE', '123456');
const smsEnabled = this.sms.isEnabled();
const code = smsEnabled
? String(randomInt(1000, 10000))
: this.config.get<string>('MOCK_OTP_CODE', '1234');
const ttl = Number(this.config.get('OTP_TTL_MINUTES', 5));
const expiresAt = new Date(Date.now() + ttl * 60_000);
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
await this.prisma.otpChallenge.create({
data: {
phone,
code,
firstName: dto.firstName?.trim(),
lastName: dto.lastName?.trim(),
isRegister,
expiresAt,
},
const challenge = await this.prisma.$transaction(async (tx) => {
await tx.otpChallenge.deleteMany({ where: { phone } });
return tx.otpChallenge.create({
data: {
phone,
code,
firstName: dto.firstName?.trim(),
lastName: dto.lastName?.trim(),
isRegister,
expiresAt,
},
});
});
if (smsEnabled) {
const sent = await this.sms.send(phone, this.sms.buildOtpMessage(code));
if (!sent.success) {
// چالش ناموفق را پاک می‌کنیم تا کاربر با کدی که هرگز نرسیده گیر نکند
await this.prisma.otpChallenge.deleteMany({
where: { id: challenge.id },
});
throw new ServiceUnavailableException(
'ارسال پیامک با خطا مواجه شد؛ دوباره تلاش کنید',
);
}
}
return {
success: true,
message: 'کد تأیید ارسال شد',
expiresAt,
// فقط برای محیط توسعه
debugCode: code,
...(this.exposesDebugCode(smsEnabled) ? { debugCode: code } : {}),
};
}
/** کد فقط در محیط توسعه و وقتی پیامک خاموش است برگردانده می‌شود. */
private exposesDebugCode(smsEnabled: boolean): boolean {
if (smsEnabled) return false;
return this.config.get<string>('NODE_ENV') !== 'production';
}
async verifyOtp(dto: VerifyOtpDto) {
const phone = normalizePhone(toEnglishDigits(dto.phone));
const code = toEnglishDigits(dto.code).trim();
@@ -80,7 +117,17 @@ export class AuthService {
if (!challenge || challenge.expiresAt < new Date()) {
throw new UnauthorizedException('کد منقضی شده است');
}
if (challenge.attempts >= MAX_OTP_ATTEMPTS) {
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
throw new UnauthorizedException(
'تعداد تلاش‌های نادرست زیاد است؛ کد جدید بگیرید',
);
}
if (challenge.code !== code) {
await this.prisma.otpChallenge.update({
where: { id: challenge.id },
data: { attempts: { increment: 1 } },
});
throw new UnauthorizedException('کد تأیید نادرست است');
}
@@ -98,6 +145,8 @@ export class AuthService {
});
} else if (!user) {
throw new UnauthorizedException('کاربر یافت نشد');
} else if (!user.isActive) {
throw new UnauthorizedException('حساب کاربری شما غیرفعال است');
}
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
@@ -147,7 +196,9 @@ export class AuthService {
async me(userId: string, type: 'customer' | 'admin') {
if (type === 'admin') {
const admin = await this.prisma.adminUser.findUnique({ where: { id: userId } });
const admin = await this.prisma.adminUser.findUnique({
where: { id: userId },
});
if (!admin) throw new UnauthorizedException();
return {
id: admin.id,
@@ -158,7 +209,9 @@ export class AuthService {
};
}
const user = await this.prisma.customer.findUnique({ where: { id: userId } });
const user = await this.prisma.customer.findUnique({
where: { id: userId },
});
if (!user) throw new UnauthorizedException();
return this.mapCustomer(user);
}
+8 -2
View File
@@ -1,4 +1,10 @@
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
import {
IsBoolean,
IsOptional,
IsString,
Length,
MinLength,
} from 'class-validator';
export class RequestOtpDto {
@IsString()
@@ -22,7 +28,7 @@ export class VerifyOtpDto {
phone!: string;
@IsString()
@MinLength(4)
@Length(4, 4)
code!: string;
}
+1 -4
View File
@@ -28,10 +28,7 @@ export class CartsController {
}
@Post('items')
addItem(
@CurrentUser() user: AuthPrincipal,
@Body() dto: AddCartItemDto,
) {
addItem(@CurrentUser() user: AuthPrincipal, @Body() dto: AddCartItemDto) {
return this.cartsService.addItem(user.id, dto);
}
+2 -4
View File
@@ -96,8 +96,7 @@ export class CartsService {
where: { id: itemId },
data: {
meterage: dto.meterage,
notes:
dto.notes === undefined ? undefined : dto.notes?.trim() || null,
notes: dto.notes === undefined ? undefined : dto.notes?.trim() || null,
},
});
await this.prisma.cart.update({
@@ -152,8 +151,7 @@ export class CartsService {
`حداقل متراژ برای ${product.name} برابر ${product.minMeterage} است`,
);
}
const rem =
Math.round((meterage / product.orderMultiplier) * 1000) / 1000;
const rem = Math.round((meterage / product.orderMultiplier) * 1000) / 1000;
if (Math.abs(rem - Math.round(rem)) > 1e-6) {
throw new BadRequestException(
`متراژ باید مضرب ${product.orderMultiplier} باشد`,
+23 -6
View File
@@ -4,11 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CashPaymentStatus,
OrderStatus,
SettlementType,
} from '@prisma/client';
import { CashPaymentStatus, OrderStatus, SettlementType } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
SubmitCashReceiptDto,
@@ -16,6 +12,20 @@ import {
} from './dto/cash-payment.dto';
import { joinFullName } from '../common/utils/name';
/** بعد از تأیید پرداخت یا ارسال، رسید جدید نباید وضعیت را به عقب برگرداند. */
const RECEIPT_SUBMITTABLE_STATUSES: OrderStatus[] = [
OrderStatus.awaiting_review,
OrderStatus.pricing,
OrderStatus.needs_correction,
OrderStatus.awaiting_settlement,
OrderStatus.awaiting_settlement_fix,
OrderStatus.cash_in_progress,
OrderStatus.cash_invoice_issued,
OrderStatus.awaiting_cash_payment,
OrderStatus.cash_payment_submitted,
OrderStatus.awaiting_cash_confirmation,
];
@Injectable()
export class CashPaymentsService {
constructor(private readonly prisma: PrismaService) {}
@@ -36,11 +46,18 @@ export class CashPaymentsService {
if (order.settlementType !== SettlementType.cash) {
throw new BadRequestException('این سفارش تسویه نقدی نیست');
}
if (!RECEIPT_SUBMITTABLE_STATUSES.includes(order.status)) {
throw new BadRequestException(
'در وضعیت فعلی سفارش امکان ثبت رسید وجود ندارد',
);
}
if (
order.cashInvoiceExpiresAt &&
order.cashInvoiceExpiresAt.getTime() < Date.now()
) {
throw new BadRequestException('مهلت پرداخت فاکتور نقدی به پایان رسیده است');
throw new BadRequestException(
'مهلت پرداخت فاکتور نقدی به پایان رسیده است',
);
}
const account = await this.prisma.bankAccount.findFirst({
+8 -2
View File
@@ -61,7 +61,10 @@ export class CatalogsService {
});
return this.map(catalog);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('کد کاتالوگ در این شرکت تکراری است');
}
throw e;
@@ -90,7 +93,10 @@ export class CatalogsService {
});
return this.map(catalog);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('کد کاتالوگ در این شرکت تکراری است');
}
throw e;
+15 -17
View File
@@ -1,11 +1,5 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CheckStatus,
OrderStatus,
} from '@prisma/client';
import { Injectable, NotFoundException } from '@nestjs/common';
import { CheckStatus, OrderStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateCheckStatusDto } from './dto/check.dto';
@@ -14,7 +8,9 @@ export class ChecksService {
constructor(private readonly prisma: PrismaService) {}
async findByOrder(orderId: string) {
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
const order = await this.prisma.order.findUnique({
where: { id: orderId },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
const checks = await this.prisma.check.findMany({
@@ -24,11 +20,7 @@ export class ChecksService {
return checks.map((c) => this.map(c));
}
async updateStatus(
id: string,
adminId: string,
dto: UpdateCheckStatusDto,
) {
async updateStatus(id: string, adminId: string, dto: UpdateCheckStatusDto) {
const check = await this.prisma.check.findUnique({
where: { id },
include: { order: { include: { checks: true } } },
@@ -46,9 +38,15 @@ export class ChecksService {
},
});
const siblings = await tx.check.findMany({ where: { orderId: check.orderId } });
const allApproved = siblings.every((c) => c.status === CheckStatus.approved);
const anyRejected = siblings.some((c) => c.status === CheckStatus.rejected);
const siblings = await tx.check.findMany({
where: { orderId: check.orderId },
});
const allApproved = siblings.every(
(c) => c.status === CheckStatus.approved,
);
const anyRejected = siblings.some(
(c) => c.status === CheckStatus.rejected,
);
let nextStatus: OrderStatus | null = null;
if (anyRejected) {
@@ -9,7 +9,7 @@ export interface AuthPrincipal {
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthPrincipal => {
const request = ctx.switchToHttp().getRequest();
const request = ctx.switchToHttp().getRequest<{ user: AuthPrincipal }>();
return request.user;
},
);
+10 -7
View File
@@ -14,6 +14,7 @@ import {
ROLES_KEY,
} from '../decorators/auth.decorators';
import { AuthPrincipal } from '../decorators/current-user.decorator';
import { resolveJwtSecret } from '../utils/jwt-secret';
@Injectable()
export class JwtAuthGuard implements CanActivate {
@@ -30,8 +31,11 @@ export class JwtAuthGuard implements CanActivate {
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization as string | undefined;
const request = context.switchToHttp().getRequest<{
headers: Record<string, string | undefined>;
user?: AuthPrincipal;
}>();
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('توکن احراز هویت الزامی است');
}
@@ -40,7 +44,7 @@ export class JwtAuthGuard implements CanActivate {
let payload: AuthPrincipal & { sub?: string };
try {
payload = await this.jwtService.verifyAsync(token, {
secret: this.config.get<string>('JWT_SECRET'),
secret: resolveJwtSecret(this.config),
});
} catch {
throw new UnauthorizedException('توکن نامعتبر یا منقضی شده است');
@@ -54,10 +58,9 @@ export class JwtAuthGuard implements CanActivate {
};
request.user = user;
const authType = this.reflector.getAllAndOverride<'customer' | 'admin' | 'any'>(
AUTH_TYPE_KEY,
[context.getHandler(), context.getClass()],
);
const authType = this.reflector.getAllAndOverride<
'customer' | 'admin' | 'any'
>(AUTH_TYPE_KEY, [context.getHandler(), context.getClass()]);
if (authType && authType !== 'any' && user.type !== authType) {
throw new ForbiddenException('دسترسی مجاز نیست');
}
+4 -3
View File
@@ -1,3 +1,5 @@
import { addDays, tehranAtHour } from './tehran-time';
export function computeCashInvoiceExpiry(company: {
cashInvoiceValidUntilHour: number | null;
cashInvoiceValidityHours: number;
@@ -7,10 +9,9 @@ export function computeCashInvoiceExpiry(company: {
company.cashInvoiceValidUntilHour != null &&
Number.isFinite(company.cashInvoiceValidUntilHour)
) {
const expires = new Date(now);
expires.setHours(company.cashInvoiceValidUntilHour, 0, 0, 0);
const expires = tehranAtHour(now, company.cashInvoiceValidUntilHour);
if (expires.getTime() <= now.getTime()) {
expires.setDate(expires.getDate() + 1);
return tehranAtHour(addDays(now, 1), company.cashInvoiceValidUntilHour);
}
return expires;
}
+10 -13
View File
@@ -12,11 +12,12 @@ export type CheckRassResult = {
rassDate: string | null;
};
function startOfDay(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
import {
addDays,
calendarDayOf,
diffInDays,
tehranCalendarDay,
} from './tehran-time';
export function calculateCheckRass(
checks: CheckRassInput[],
@@ -33,25 +34,21 @@ export function calculateCheckRass(
};
}
const base = startOfDay(baseDate);
const base = tehranCalendarDay(baseDate);
let totalAmount = 0;
let weightedDays = 0;
const dueDates: Date[] = [];
for (const check of checks) {
const due = startOfDay(new Date(check.dueDate));
const due = calendarDayOf(check.dueDate);
dueDates.push(due);
const days = Math.max(
0,
Math.round((due.getTime() - base.getTime()) / (1000 * 60 * 60 * 24)),
);
const days = Math.max(0, diffInDays(base, due));
totalAmount += check.amount;
weightedDays += check.amount * days;
}
const rassDays = totalAmount > 0 ? Math.round(weightedDays / totalAmount) : 0;
const rassDate = new Date(base);
rassDate.setDate(rassDate.getDate() + rassDays);
const rassDate = addDays(base, rassDays);
dueDates.sort((a, b) => a.getTime() - b.getTime());
return {
+16
View File
@@ -0,0 +1,16 @@
import { ConfigService } from '@nestjs/config';
const DEV_FALLBACK = 'fabric-trade-dev-secret';
/**
* امضا و اعتبارسنجی توکن باید از یک منبع بخوانند؛ در production نبود مقدار خطای راه‌اندازی است.
*/
export function resolveJwtSecret(config: ConfigService): string {
const secret = config.get<string>('JWT_SECRET')?.trim();
if (secret) return secret;
if (config.get<string>('NODE_ENV') === 'production') {
throw new Error('JWT_SECRET تنظیم نشده است');
}
return DEV_FALLBACK;
}
+2 -2
View File
@@ -8,10 +8,10 @@ export function splitFullName(name: string): {
}
const parts = trimmed.split(' ');
if (parts.length === 1) {
return { firstName: parts[0]!, lastName: '' };
return { firstName: parts[0], lastName: '' };
}
return {
firstName: parts[0]!,
firstName: parts[0],
lastName: parts.slice(1).join(' '),
};
}
+29 -4
View File
@@ -2,19 +2,43 @@ import { OrderStatus } from '@prisma/client';
/** Admin UI simplified statuses → full Prisma OrderStatus values */
export const ADMIN_STATUS_MAP: Record<string, OrderStatus[]> = {
pending_review: [OrderStatus.awaiting_review, OrderStatus.pricing],
pending_review: [
OrderStatus.cart,
OrderStatus.awaiting_settlement,
OrderStatus.cash_in_progress,
OrderStatus.check_in_progress,
OrderStatus.submitted,
OrderStatus.awaiting_review,
OrderStatus.pricing,
OrderStatus.cash_invoice_issued,
OrderStatus.awaiting_cash_payment,
OrderStatus.cash_payment_submitted,
OrderStatus.awaiting_cash_confirmation,
],
pending_check: [OrderStatus.awaiting_check_review],
needs_correction: [OrderStatus.needs_correction],
check_correction: [OrderStatus.check_needs_fix],
needs_correction: [
OrderStatus.needs_correction,
OrderStatus.awaiting_settlement_fix,
],
check_correction: [OrderStatus.check_needs_fix, OrderStatus.check_rejected],
final_approval: [
OrderStatus.final_approved,
OrderStatus.checks_approved,
OrderStatus.cash_confirmed,
OrderStatus.preparing,
OrderStatus.ready_to_ship,
],
shipped: [OrderStatus.shipped],
delivered: [OrderStatus.delivered],
};
/** وضعیت‌های پایانی عمداً در تب‌های جریان کاری نمی‌آیند و با برچسب خودشان نمایش داده می‌شوند. */
const TERMINAL_STATUSES: OrderStatus[] = [
OrderStatus.cancelled,
OrderStatus.rejected,
OrderStatus.expired,
];
export function resolveAdminStatusFilter(
status?: string,
): OrderStatus[] | undefined {
@@ -30,7 +54,8 @@ export function toAdminStatus(status: OrderStatus): string {
for (const [admin, list] of Object.entries(ADMIN_STATUS_MAP)) {
if (list.includes(status)) return admin;
}
return status;
if (TERMINAL_STATUSES.includes(status)) return status;
return 'pending_review';
}
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
+42
View File
@@ -0,0 +1,42 @@
/**
* محاسبات تقویمی باید مستقل از تایم‌زون سرور باشد؛ کانتینرها روی UTC اجرا می‌شوند.
* ایران از ۱۴۰۱ تغییر ساعت تابستانی ندارد، پس آفست ثابت +۰۳:۳۰ درست است.
*/
const TEHRAN_OFFSET_MS = (3 * 60 + 30) * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
function utcStartOfDay(date: Date): Date {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0);
return d;
}
/**
* روز تقویمی تهرانِ یک لحظه را به‌صورت نیمه‌شب UTC همان روز برمی‌گرداند.
* خروجی برای اختلاف‌گیری روزانه و سریال‌سازی `YYYY-MM-DD` امن است.
*/
export function tehranCalendarDay(date: Date): Date {
return utcStartOfDay(new Date(date.getTime() + TEHRAN_OFFSET_MS));
}
/** تاریخ سررسید فقط-روز است و نیمه‌شب UTC ذخیره می‌شود. */
export function calendarDayOf(date: Date | string): Date {
return utcStartOfDay(new Date(date));
}
export function addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * DAY_MS);
}
export function diffInDays(from: Date, to: Date): number {
return Math.round((to.getTime() - from.getTime()) / DAY_MS);
}
/** لحظه‌ای که در تهران ساعتِ داده‌شده از همان روزِ تقویمی است. */
export function tehranAtHour(date: Date, hour: number): Date {
return new Date(
tehranCalendarDay(date).getTime() +
hour * 60 * 60 * 1000 -
TEHRAN_OFFSET_MS,
);
}
+39 -26
View File
@@ -21,7 +21,9 @@ export class CompaniesService {
where: activeOnly ? { isActive: true } : undefined,
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
include: {
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
_count: {
select: { catalogs: true, products: true, bankAccounts: true },
},
},
});
return companies.map((c) => this.mapCompany(c));
@@ -32,7 +34,9 @@ export class CompaniesService {
where: { id },
include: {
bankAccounts: { orderBy: { displayOrder: 'asc' } },
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
_count: {
select: { catalogs: true, products: true, bankAccounts: true },
},
},
});
if (!company) throw new NotFoundException('شرکت یافت نشد');
@@ -57,12 +61,17 @@ export class CompaniesService {
isActive: dto.isActive ?? true,
},
include: {
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
_count: {
select: { catalogs: true, products: true, bankAccounts: true },
},
},
});
return this.mapCompany(company);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('اسلاگ شرکت تکراری است');
}
throw e;
@@ -90,12 +99,17 @@ export class CompaniesService {
isActive: dto.isActive,
},
include: {
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
_count: {
select: { catalogs: true, products: true, bankAccounts: true },
},
},
});
return this.mapCompany(company);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('اسلاگ شرکت تکراری است');
}
throw e;
@@ -108,7 +122,9 @@ export class CompaniesService {
where: { id },
data: { isActive },
include: {
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
_count: {
select: { catalogs: true, products: true, bankAccounts: true },
},
},
});
return this.mapCompany(company);
@@ -151,8 +167,7 @@ export class CompaniesService {
accountNumber: dto.accountNumber?.trim(),
cardNumber: dto.cardNumber?.trim(),
iban: dto.iban?.trim(),
notes:
dto.notes === undefined ? undefined : dto.notes?.trim() || null,
notes: dto.notes === undefined ? undefined : dto.notes?.trim() || null,
displayOrder: dto.displayOrder,
isActive: dto.isActive,
},
@@ -181,23 +196,21 @@ export class CompaniesService {
return account;
}
private mapCompany(
company: {
id: string;
name: string;
slug: string;
description: string | null;
logoUrl: string | null;
isActive: boolean;
displayOrder: number;
maxMeteragePerInvoice: number;
cashInvoiceValidityHours: number;
cashInvoiceValidUntilHour: number | null;
createdAt: Date;
updatedAt: Date;
_count?: { catalogs: number; products: number; bankAccounts: number };
},
) {
private mapCompany(company: {
id: string;
name: string;
slug: string;
description: string | null;
logoUrl: string | null;
isActive: boolean;
displayOrder: number;
maxMeteragePerInvoice: number;
cashInvoiceValidityHours: number;
cashInvoiceValidUntilHour: number | null;
createdAt: Date;
updatedAt: Date;
_count?: { catalogs: number; products: number; bankAccounts: number };
}) {
return {
id: company.id,
name: company.name,
+13 -3
View File
@@ -83,7 +83,10 @@ export class CustomersService {
});
return this.map(customer);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('این شماره قبلاً ثبت شده است');
}
throw e;
@@ -102,7 +105,11 @@ export class CustomersService {
let firstName: string | undefined;
let lastName: string | undefined;
if (dto.name !== undefined || dto.firstName !== undefined || dto.lastName !== undefined) {
if (
dto.name !== undefined ||
dto.firstName !== undefined ||
dto.lastName !== undefined
) {
const names = this.resolveNames(dto);
firstName = names.firstName;
lastName = names.lastName;
@@ -131,7 +138,10 @@ export class CustomersService {
});
return this.map(customer);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('این شماره قبلاً ثبت شده است');
}
throw e;
+14 -8
View File
@@ -107,11 +107,7 @@ export class DashboardService {
const sumAmounts = (
rows: { finalAmount: number | null; totalAmount: number | null }[],
) =>
rows.reduce(
(s, o) => s + (o.finalAmount ?? o.totalAmount ?? 0),
0,
);
) => rows.reduce((s, o) => s + (o.finalAmount ?? o.totalAmount ?? 0), 0);
const todaySales = sumAmounts(todayOrders);
const yesterdaySales = sumAmounts(yesterdayOrders);
@@ -130,7 +126,7 @@ export class DashboardService {
(24 * 60 * 60 * 1000),
);
if (dayIndex >= 0 && dayIndex < 28) {
salesByDay[dayIndex]! += order.finalAmount ?? order.totalAmount ?? 0;
salesByDay[dayIndex] += order.finalAmount ?? order.totalAmount ?? 0;
}
}
const maxSale = Math.max(...salesByDay, 1);
@@ -189,7 +185,12 @@ export class DashboardService {
const byCompany = new Map<
string,
{ companyName: string; orderCount: number; totalAmount: number; meterage: number }
{
companyName: string;
orderCount: number;
totalAmount: number;
meterage: number;
}
>();
const byStatus = new Map<string, number>();
@@ -228,7 +229,12 @@ export class DashboardService {
{ id: 'cd-today', label: 'امروز', tone: 'danger' as const, days: 0 },
{ id: 'cd-3', label: '۳ روز آینده', tone: 'warning' as const, days: 3 },
{ id: 'cd-7', label: '۷ روز آینده', tone: 'warning' as const, days: 7 },
{ id: 'cd-30', label: '۳۰ روز آینده', tone: 'neutral' as const, days: 30 },
{
id: 'cd-30',
label: '۳۰ روز آینده',
tone: 'neutral' as const,
days: 30,
},
];
return buckets.map((bucket) => {
+10 -2
View File
@@ -1,11 +1,17 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { json, urlencoded } from 'express';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// تصاویر چک و رسید به‌صورت base64 داخل JSON می‌آیند و از سقف پیش‌فرض ۱۰۰KB رد می‌شوند
const bodyLimit = process.env.BODY_LIMIT || '25mb';
app.use(json({ limit: bodyLimit }));
app.use(urlencoded({ extended: true, limit: bodyLimit }));
// پشت LiteSpeed روی /server → GLOBAL_PREFIX=server/api
const globalPrefix = process.env.GLOBAL_PREFIX || 'api';
app.setGlobalPrefix(globalPrefix);
@@ -33,7 +39,9 @@ async function bootstrap() {
const port = process.env.PORT ?? 3000;
await app.listen(port);
console.log(`Fabric Trade API listening on http://localhost:${port}/${globalPrefix}`);
console.log(
`Fabric Trade API listening on http://localhost:${port}/${globalPrefix}`,
);
console.log(`Swagger docs at http://localhost:${port}/${globalPrefix}/docs`);
}
bootstrap();
void bootstrap();
+11
View File
@@ -25,6 +25,12 @@ export class SubmitOrderItemDto {
@Min(0.001)
meterage!: number;
/** Alias پذیرفته‌شده از کلاینت‌های قدیمی؛ نادیده گرفته می‌شود */
@IsOptional()
@Type(() => Number)
@IsNumber()
requestedMeterage?: number;
@IsOptional()
@IsString()
notes?: string;
@@ -91,6 +97,11 @@ export class SubmitOrderDto {
@IsEnum(SettlementType)
settlementType!: SettlementType;
/** Alias پذیرفته‌شده از کلاینت‌های قدیمی؛ نادیده گرفته می‌شود */
@IsOptional()
@IsEnum(SettlementType)
settlementMethod?: SettlementType;
@IsOptional()
@IsEnum(CheckRegistrationMode)
checkMode?: CheckRegistrationMode;
+3 -12
View File
@@ -31,19 +31,13 @@ export class CustomerOrdersController {
@AuthType('customer')
@Get(':id')
findOne(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
) {
findOne(@CurrentUser() user: AuthPrincipal, @Param('id') id: string) {
return this.ordersService.findMyOrder(user.id, id);
}
@AuthType('customer')
@Post()
submit(
@CurrentUser() user: AuthPrincipal,
@Body() dto: SubmitOrderDto,
) {
submit(@CurrentUser() user: AuthPrincipal, @Body() dto: SubmitOrderDto) {
return this.ordersService.submitOrder(user.id, dto);
}
@@ -74,10 +68,7 @@ export class AdminOrdersController {
}
@Post()
create(
@CurrentUser() user: AuthPrincipal,
@Body() dto: AdminCreateOrderDto,
) {
create(@CurrentUser() user: AuthPrincipal, @Body() dto: AdminCreateOrderDto) {
return this.ordersService.adminCreate(user.id, dto);
}
+150 -97
View File
@@ -45,6 +45,23 @@ const orderInclude = {
type OrderFull = Prisma.OrderGetPayload<{ include: typeof orderInclude }>;
/** مبالغ ریالی روی Float ذخیره می‌شوند؛ گرد کردن از انباشت خطای اعشاری جلوگیری می‌کند. */
function roundMoney(value: number): number {
return Math.round(value * 100) / 100;
}
/** بعد از تأیید چک‌ها یا ارسال سفارش، مشتری نباید بتواند چک‌ها را بازنویسی کند. */
const CHECK_EDITABLE_STATUSES: OrderStatus[] = [
OrderStatus.awaiting_review,
OrderStatus.pricing,
OrderStatus.needs_correction,
OrderStatus.awaiting_settlement_fix,
OrderStatus.check_in_progress,
OrderStatus.awaiting_check_review,
OrderStatus.check_needs_fix,
OrderStatus.check_rejected,
];
@Injectable()
export class OrdersService {
constructor(private readonly prisma: PrismaService) {}
@@ -180,62 +197,62 @@ export class OrdersService {
? computeCashInvoiceExpiry(company)
: null;
const orderNumber = await this.nextOrderNumber();
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
orderNumber,
userId,
companyId: company.id,
companyName: company.name,
settlementType,
checkMode,
status: OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
checkRassDays: rass?.rassDays ?? null,
checkRassDate: rass?.rassDate ? new Date(rass.rassDate) : null,
cashInvoiceExpiresAt,
items: { create: itemsData },
checks:
settlementType === SettlementType.check && dto.checks
? {
create: dto.checks.map((c, index) =>
this.mapCheckCreate(c, index + 1, checkMode!),
),
}
: undefined,
statusHistory: {
create: [
{
fromStatus: null,
toStatus: OrderStatus.submitted,
changedBy: 'customer',
note: 'سفارش ثبت شد',
},
{
fromStatus: OrderStatus.submitted,
toStatus: OrderStatus.awaiting_review,
changedBy: 'customer',
note: 'ارسال برای بررسی مدیریت',
},
],
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
orderNumber,
userId,
companyId: company.id,
companyName: company.name,
settlementType,
checkMode,
status: OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
checkRassDays: rass?.rassDays ?? null,
checkRassDate: rass?.rassDate ? new Date(rass.rassDate) : null,
cashInvoiceExpiresAt,
items: { create: itemsData },
checks:
settlementType === SettlementType.check && dto.checks
? {
create: dto.checks.map((c, index) =>
this.mapCheckCreate(c, index + 1, checkMode!),
),
}
: undefined,
statusHistory: {
create: [
{
fromStatus: null,
toStatus: OrderStatus.submitted,
changedBy: 'customer',
note: 'سفارش ثبت شد',
},
{
fromStatus: OrderStatus.submitted,
toStatus: OrderStatus.awaiting_review,
changedBy: 'customer',
note: 'ارسال برای بررسی مدیریت',
},
],
},
},
},
include: orderInclude,
});
// Clear cart items for this company after successful submit
const cart = await tx.cart.findFirst({ where: { userId } });
if (cart) {
await tx.cartItem.deleteMany({
where: { cartId: cart.id, companyId: company.id },
include: orderInclude,
});
}
return created;
});
// Clear cart items for this company after successful submit
const cart = await tx.cart.findFirst({ where: { userId } });
if (cart) {
await tx.cartItem.deleteMany({
where: { cartId: cart.id, companyId: company.id },
});
}
return created;
}),
);
return this.mapOrder(order);
}
@@ -257,7 +274,9 @@ export class OrdersService {
if (!isValidPhone(phone)) {
throw new BadRequestException('شماره موبایل نامعتبر است');
}
let customer = await this.prisma.customer.findUnique({ where: { phone } });
let customer = await this.prisma.customer.findUnique({
where: { phone },
});
if (!customer) {
const names = splitFullName(dto.customerName || 'مشتری جدید');
customer = await this.prisma.customer.create({
@@ -274,67 +293,79 @@ export class OrdersService {
throw new BadRequestException('شناسه یا شماره مشتری الزامی است');
}
const orderNumber = await this.nextOrderNumber();
const totalMeterage = dto.meterage ?? 0;
const ownerId = userId;
const order = await this.prisma.order.create({
data: {
orderNumber,
userId,
companyId: company.id,
companyName: company.name,
settlementType,
status: OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
statusHistory: {
create: {
fromStatus: null,
toStatus: OrderStatus.awaiting_review,
changedBy: `admin:${adminId}`,
note: 'سفارش توسط ادمین ثبت شد',
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
this.prisma.order.create({
data: {
orderNumber,
userId: ownerId,
companyId: company.id,
companyName: company.name,
settlementType,
status: OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
statusHistory: {
create: {
fromStatus: null,
toStatus: OrderStatus.awaiting_review,
changedBy: `admin:${adminId}`,
note: 'سفارش توسط ادمین ثبت شد',
},
},
},
},
include: orderInclude,
});
include: orderInclude,
}),
);
return this.mapOrder(order, true);
}
async updatePricing(
id: string,
adminId: string,
dto: UpdateOrderPricingDto,
) {
async updatePricing(id: string, adminId: string, dto: UpdateOrderPricingDto) {
const order = await this.getOrder(id);
const unknown = dto.items.find(
(item) => !order.items.some((i) => i.id === item.id),
);
if (unknown) {
throw new BadRequestException(`آیتم ${unknown.id} یافت نشد`);
}
await this.prisma.$transaction(async (tx) => {
let totalAmount = 0;
let totalMeterage = 0;
for (const item of dto.items) {
const existing = order.items.find((i) => i.id === item.id);
if (!existing) {
throw new BadRequestException(`آیتم ${item.id} یافت نشد`);
}
// جمع کل باید روی همه آیتم‌های سفارش حساب شود، نه فقط آیتم‌های ارسالی
for (const existing of order.items) {
const item = dto.items.find((i) => i.id === existing.id);
const unitPrice =
item.unitPrice !== undefined
item?.unitPrice !== undefined
? item.unitPrice
: item.supplyPrice !== undefined
: item?.supplyPrice !== undefined
? item.supplyPrice
: (existing.unitPrice ?? 0);
const unitDiscount = item.unitDiscount ?? existing.unitDiscount ?? 0;
const unitDiscount = item?.unitDiscount ?? existing.unitDiscount ?? 0;
if (unitDiscount > unitPrice) {
throw new BadRequestException(
'تخفیف واحد نمی‌تواند از قیمت واحد بیشتر باشد',
);
}
const confirmed =
item.confirmedMeterage ??
item?.confirmedMeterage ??
existing.confirmedMeterage ??
existing.requestedMeterage;
const lineTotal = Math.max(0, (unitPrice - unitDiscount) * confirmed);
const lineTotal = roundMoney(
Math.max(0, (unitPrice - unitDiscount) * confirmed),
);
totalAmount += lineTotal;
totalMeterage += confirmed;
if (!item) continue;
await tx.orderItem.update({
where: { id: item.id },
where: { id: existing.id },
data: {
confirmedMeterage: confirmed,
unitPrice,
@@ -343,10 +374,13 @@ export class OrdersService {
},
});
}
totalAmount = roundMoney(totalAmount);
const shippingCost = dto.shippingCost ?? order.shippingCost ?? 0;
const totalDiscount = dto.totalDiscount ?? order.totalDiscount ?? 0;
const finalAmount = Math.max(0, totalAmount + shippingCost - totalDiscount);
const finalAmount = roundMoney(
Math.max(0, totalAmount + shippingCost - totalDiscount),
);
const nextStatus =
order.status === OrderStatus.awaiting_review ||
@@ -382,11 +416,7 @@ export class OrdersService {
return this.findAdminOrder(id);
}
async updateStatus(
id: string,
adminId: string,
dto: UpdateOrderStatusDto,
) {
async updateStatus(id: string, adminId: string, dto: UpdateOrderStatusDto) {
const order = await this.getOrder(id);
if (order.status === dto.status) {
return this.mapOrder(order, true);
@@ -435,8 +465,14 @@ export class OrdersService {
if (order.settlementType !== SettlementType.check) {
throw new BadRequestException('این سفارش تسویه چکی نیست');
}
if (!CHECK_EDITABLE_STATUSES.includes(order.status)) {
throw new BadRequestException(
'در وضعیت فعلی سفارش امکان ویرایش چک‌ها وجود ندارد',
);
}
const checkMode = dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
const checkMode =
dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
const rass = calculateCheckRass(
dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate })),
order.createdAt,
@@ -512,6 +548,23 @@ export class OrdersService {
return `${generateOrderNumber()}-${Date.now().toString().slice(-3)}`;
}
/** بین انتخاب شماره و درج، امکان تصاحب شماره توسط درخواست هم‌زمان هست. */
private async createWithUniqueOrderNumber<T>(
create: (orderNumber: string) => Promise<T>,
): Promise<T> {
for (let attempt = 0; ; attempt++) {
const orderNumber = await this.nextOrderNumber();
try {
return await create(orderNumber);
} catch (e) {
const isDuplicate =
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002';
if (!isDuplicate || attempt >= 4) throw e;
}
}
}
private async getOrder(id: string): Promise<OrderFull> {
const order = await this.prisma.order.findUnique({
where: { id },
+8 -2
View File
@@ -81,7 +81,10 @@ export class ProductsService {
});
return this.map(product);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('کد محصول در این شرکت تکراری است');
}
throw e;
@@ -135,7 +138,10 @@ export class ProductsService {
});
return this.map(product);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('کد محصول در این شرکت تکراری است');
}
throw e;
+1 -4
View File
@@ -34,10 +34,7 @@ export class ShipmentsController {
}
@Post()
create(
@CurrentUser() user: AuthPrincipal,
@Body() dto: CreateShipmentDto,
) {
create(@CurrentUser() user: AuthPrincipal, @Body() dto: CreateShipmentDto) {
return this.shipmentsService.create(user.id, dto);
}
+1 -1
View File
@@ -158,7 +158,7 @@ export class ShipmentsService {
include: { order: { include: { user: true } } },
});
const nextOrderStatus = SHIPMENT_TO_ORDER[nextStatus!];
const nextOrderStatus = SHIPMENT_TO_ORDER[nextStatus];
if (shipment.order.status !== nextOrderStatus) {
await tx.order.update({
where: { id: shipment.orderId },
+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 {}
+109
View File
@@ -0,0 +1,109 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export type SmsSendResult = {
success: boolean;
raw: unknown;
};
function maskPhone(phone: string): string {
return phone.length <= 4
? '****'
: `${phone.slice(0, 4)}****${phone.slice(-2)}`;
}
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly config: ConfigService) {}
isEnabled(): boolean {
const raw = (
this.config.get<string>('SMS_ENABLED', 'true') ?? 'true'
).toLowerCase();
return raw === '1' || raw === 'true' || raw === 'yes';
}
async send(to: string, message: string): Promise<SmsSendResult> {
const url = this.config.get<string>(
'SMS_API_URL',
'http://185.231.112.101/api/createsms',
);
const secretKey = this.config.get<string>('SMS_SECRET_KEY')?.trim();
if (!secretKey) {
this.logger.error('SMS_SECRET_KEY تنظیم نشده است');
return { success: false, raw: 'SMS_SECRET_KEY missing' };
}
const platform = this.config.get<string>('SMS_PLATFORM', 'rayegansms');
const host = this.resolveHost();
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
secret_key: secretKey,
},
body: JSON.stringify({
phone: to,
host,
text: message.trim(),
platform,
}),
signal: AbortSignal.timeout(15_000),
});
const text = await response.text();
let body: unknown = text;
try {
body = text ? JSON.parse(text) : null;
} catch {
/* keep raw text */
}
let success = response.ok;
if (success && body && typeof body === 'object') {
const obj = body as Record<string, unknown>;
if ('success' in obj && obj.success === false) success = false;
else if ('ok' in obj && obj.ok === false) success = false;
else if (
typeof obj.status === 'string' &&
['error', 'failed', 'fail'].includes(obj.status.toLowerCase())
) {
success = false;
}
}
if (!success) {
this.logger.warn(
`SMS failed to ${maskPhone(to)}: ${JSON.stringify(body)}`,
);
}
return { success, raw: body };
} catch (err) {
this.logger.error(
`SMS request error to ${maskPhone(to)}`,
err instanceof Error ? err.stack : err,
);
return {
success: false,
raw: err instanceof Error ? err.message : String(err),
};
}
}
buildOtpMessage(code: string): string {
const appName = this.config.get<string>('SMS_APP_NAME', 'پارچه');
return `رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن ${appName} ${code} می باشد`;
}
private resolveHost(): string {
const fromEnv =
this.config.get<string>('SMS_HOST')?.trim() ||
this.config.get<string>('SMS_APP_NAME')?.trim();
if (fromEnv) return fromEnv;
return 'fabric';
}
}