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:
co-authored by
Claude Haiku 5.5
parent
5d2a1efb0c
commit
532ceee189
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user