feat: move brand name to one config and redesign the accounting card

اپ: نام برند در همه‌ی صفحه‌ها و پیام‌ها یکسان شده و از یک تنظیم مرکزی خوانده می‌شود.
اپ: کارت حسابداری در صفحه‌ی مشتری بازطراحی شد؛ مانده‌ها و وضعیت لینک واضح‌تر نمایش داده می‌شوند.
پنل: کارت حسابداری در پرونده‌ی مشتری بازطراحی شد؛ مانده‌ها، آخرین ارسال و فرم لینک دستی واضح‌تر شده‌اند.
پنل: ستون «کد» در جدول‌ها به‌طور پیش‌فرض پنهان است و از انتخاب ستون‌ها قابل نمایش است.
پنل: تغییر نمایش ستون‌ها در جدول‌ها بلافاصله اعمال می‌شود.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-14 13:31:20 +03:30
co-authored by Claude Haiku 5.5
parent 5d2a1efb0c
commit 532ceee189
100 changed files with 1870 additions and 579 deletions
@@ -4,6 +4,7 @@ import { CustomerAuthService } from './customer-auth.service';
import { CustomersService } from '../customers/customers.service';
import { RequestOtpDto } from './dto/request-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { RegisterDto } from './dto/register.dto';
import { Public } from '../auth/guards';
import { CurrentCustomer, CustomerAuth } from './customer.guards';
import type { AuthenticatedCustomer } from './customer-jwt.strategy';
@@ -36,6 +37,14 @@ export class CustomerAuthController {
return this.auth.requestOtp(dto.username, ip);
}
/** App self-registration. The new customer waits in the admin's pending list. */
@Public()
@Post('auth/register')
@HttpCode(200)
register(@Body() dto: RegisterDto, @Ip() ip: string) {
return this.auth.register(dto, ip);
}
/** Step 2. Returns the token and that one customer, as the reference did. */
@Public()
@Post('auth/verify-otp')
@@ -1,5 +1,7 @@
import { createHash, createHmac, randomBytes } from 'node:crypto';
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
HttpStatus,
@@ -9,7 +11,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
import { AdminRole, CustomerKind, MessageAudience, Prisma, VerificationStep } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import {
@@ -25,6 +27,8 @@ import { masterCodeAllowed } from '../../common/production-checks';
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
import { faToEn } from './digits';
import { SmsService } from '../sms/sms.service';
import { brandName } from '../../common/brand';
import type { RegisterDto } from './dto/register.dto';
/** Nest 10 has no TooManyRequestsException; 429 with a Persian message. */
class TooManyRequestsException extends HttpException {
@@ -44,6 +48,9 @@ const MAX_SENDS_PER_IP = 20;
const SEND_WINDOW_MS = 10 * 60 * 1000;
/** Minimum gap between two sends to the same number. */
const RESEND_COOLDOWN_MS = 60 * 1000;
/** Self-registrations allowed per source IP inside the window. */
const MAX_REGISTRATIONS_PER_IP = 5;
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
/**
* D20 — the delivery master OTP.
@@ -177,6 +184,15 @@ export class CustomerAuthService {
// caps a single IP at MAX_SENDS_PER_IP per window, which it did not do in
// the reference.
if (!customer) {
// A self-registration still awaiting review is inactive by design; tell
// them so rather than claim the number was never registered.
const pending = await this.prisma.customer.findFirst({
where: { mobile, isActive: false, isArchived: false, verificationStep: VerificationStep.STEP_ONE_SUBMITTED },
select: { id: true },
});
if (pending) {
throw new ForbiddenException('ثبت نام شما در انتظار تایید کارشناسان است. پس از تایید می‌توانید وارد شوید.');
}
this.logger.warn(`OTP requested for unknown or inactive mobile ${mobile}`);
throw new NotFoundException('این شماره در سیستم ثبت نشده است. لطفاً با پشتیبانی تماس بگیرید.');
}
@@ -197,6 +213,89 @@ export class CustomerAuthService {
return { sent: true };
}
/**
* Self-registration from the app — port of Major/CustomerController::register
* (henza_server :19-75). Before this the app's register form was a stub that
* showed "registered" and saved nothing, so new customers never reached the
* admin's «مشتریان در انتظار» list.
*
* Uses the reference's ORIGINAL review flow (its commented-out "نیاز به تایید
* ادمین" block), not the later auto-approve: the row is inactive with step one
* awaiting review, which is exactly what that list shows. Approving step one
* in the admin panel activates it (AdminCustomersService.verify). Credit limit
* and wage offset stay at their defaults for the admin to set on review.
*/
async register(dto: RegisterDto, ip: string | undefined) {
const mobile = faToEn(dto.mobile);
if (!/^09\d{9}$/.test(mobile)) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
this.assertRegisterAllowed(ip);
const fname = dto.fname.trim();
const lname = dto.lname.trim();
const existing = await this.prisma.customer.findUnique({ where: { mobile }, select: { id: true } });
if (existing) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است. از بخش ورود وارد شوید.');
}
try {
await this.prisma.customer.create({
data: {
fullName: `${fname} ${lname}`,
firstName: fname,
lastName: lname,
mobile,
passwordHash: OTP_ONLY_PASSWORD_HASH,
kind: CustomerKind.APP_SHOPKEEPER,
storeName: dto.store_name.trim(),
phone: faToEn(dto.phone),
address: dto.address.trim(),
cityNameRaw: dto.city.trim(),
verificationStep: VerificationStep.STEP_ONE_SUBMITTED,
isActive: false,
},
});
} catch (e) {
// Two submits racing on the same number: the unique index catches the second.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است. از بخش ورود وارد شوید.');
}
throw e;
}
// Reference :57-71 (its "awaiting review" wording). Fire-and-forget: a
// failed SMS never fails the registration (D20).
const brand = brandName();
const fire = (to: string, body: string) => {
void this.sms.send(to, body).catch((err: unknown) => {
this.logger.error(`Registration SMS to ${to} failed: ${String(err)}`);
});
};
fire(
mobile,
`${brand}\nهمکار گرامی ${fname} ${lname}\nثبت نام شما با موفقیت انجام شد و در انتظار تایید کارشناسان است.`,
);
for (const admin of envList('PROFILE_ADMIN_MOBILES')) {
fire(admin, `${brand}\nمشتری با نام ${fname} ${lname} ثبت نام کرد و در انتظار تایید است.`);
}
return { ok: true };
}
/** Public endpoint with an SMS side effect: cap how many accounts one IP can open. */
private readonly registrationsByIp = new Map<string, number[]>();
private assertRegisterAllowed(ip: string | undefined) {
if (!ip) return;
const now = Date.now();
const recent = (this.registrationsByIp.get(ip) ?? []).filter((t) => now - t < REGISTER_WINDOW_MS);
if (recent.length >= MAX_REGISTRATIONS_PER_IP) {
throw new TooManyRequestsException('تعداد ثبت نام‌ها بیش از حد مجاز است. لطفاً بعداً تلاش کنید.');
}
recent.push(now);
this.registrationsByIp.set(ip, recent);
}
/**
* D21 — find an active sales expert by mobile and return their trading
* Customer account, creating it on first sign-in.
@@ -779,12 +878,12 @@ export class CustomerAuthService {
*/
private async deliver(mobile: string, code: string) {
// The reference's text (ApiMobileController.php:47) with the brand changed
// to منتزر. The leading "<#> code is <CODE>" line and the trailing
// to BRAND_NAME. The leading "<#> code is <CODE>" line and the trailing
// "aMo6iFyUQaY" are the Android SMS Retriever app hash — change either and
// autofill stops working on every installed client. Do not "tidy" them.
const smsText =
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر ${code} می باشد\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن ${brandName()} ${code} می باشد\n` +
`aMo6iFyUQaY`;
// persist:false — the real text carries the live code, and the admin SMS
@@ -0,0 +1,44 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
/**
* Body of POST customer/auth/register.
*
* The fields of the reference's Major/CustomerController::register
* (henza_server :23-31), all required there and here.
*/
export class RegisterDto {
@IsString()
@IsNotEmpty({ message: 'نام الزامی است' })
@MaxLength(100)
fname!: string;
@IsString()
@IsNotEmpty({ message: 'نام خانوادگی الزامی است' })
@MaxLength(100)
lname!: string;
@IsString()
@IsNotEmpty({ message: 'شماره موبایل الزامی است' })
@MaxLength(20)
mobile!: string;
@IsString()
@IsNotEmpty({ message: 'نام فروشگاه الزامی است' })
@MaxLength(150)
store_name!: string;
@IsString()
@IsNotEmpty({ message: 'تلفن فروشگاه الزامی است' })
@MaxLength(20)
phone!: string;
@IsString()
@IsNotEmpty({ message: 'آدرس الزامی است' })
@MaxLength(500)
address!: string;
@IsString()
@IsNotEmpty({ message: 'شهر الزامی است' })
@MaxLength(100)
city!: string;
}