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
@@ -0,0 +1,16 @@
|
||||
import { envOr } from './env';
|
||||
|
||||
/**
|
||||
* The brand name and public site address used in every SMS and message the
|
||||
* server sends. Read on each call, so changing BRAND_NAME / BRAND_SITE_URL in
|
||||
* the environment and restarting renames every outgoing text at once — no code
|
||||
* change. The frontends read the same values from their own brand files; see
|
||||
* docs/BRANDING.md for the full list.
|
||||
*/
|
||||
export function brandName(): string {
|
||||
return envOr('BRAND_NAME', 'زرتاج');
|
||||
}
|
||||
|
||||
export function brandSiteUrl(): string {
|
||||
return envOr('BRAND_SITE_URL', 'https://bnk.montzar.ir');
|
||||
}
|
||||
@@ -29,7 +29,7 @@ describe('AdminCustomersService', () => {
|
||||
const prisma = { ...tx, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)) };
|
||||
const accounting = { customerVerified: jest.fn(), customerChanged: jest.fn() };
|
||||
const sms = { send: jest.fn(async () => undefined) };
|
||||
const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never);
|
||||
const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never, {} as never);
|
||||
return { service, writes, accounting };
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ describe('AdminCustomersService', () => {
|
||||
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
|
||||
verificationRejectionReason: null,
|
||||
verificationRejectedAt: null,
|
||||
// Accepting step one activates the account (an app self-registration
|
||||
// is created inactive).
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||
import { agentSalesWhere, customerSalesWhere } from '../sales-agent/agent-orders';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
import { AccountingConfigService } from '../accounting-sync/accounting-config.service';
|
||||
import { brandName, brandSiteUrl } from '../../common/brand';
|
||||
|
||||
/**
|
||||
* Wholesale ("عمده") customer lists — legacy views/customer/omde/*.
|
||||
@@ -137,12 +139,13 @@ export class AdminCustomersService {
|
||||
private readonly table: TableService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
private readonly accountingConfig: AccountingConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The welcome SMS the reference sends after every new customer is created
|
||||
* (AdminController.php:378-387), with the brand and the PWA link changed to
|
||||
* منتزر; same line order. The commented-out Android/iOS lines are not sent
|
||||
* BRAND_NAME / BRAND_SITE_URL (common/brand.ts); same line order. The commented-out Android/iOS lines are not sent
|
||||
* there and are not sent here.
|
||||
*
|
||||
* D20 — awaiting it would let a slow or unconfigured gateway hold up the
|
||||
@@ -153,11 +156,11 @@ export class AdminCustomersService {
|
||||
*/
|
||||
private sendWelcomeSms(mobile: string, firstName: string, lastName: string) {
|
||||
const body =
|
||||
'منتزر\n' +
|
||||
`${brandName()}\n` +
|
||||
`همکار گرامی ${firstName} ${lastName}\n` +
|
||||
`نام کاربری : ${mobile}\n` +
|
||||
'لینک pwa \n' +
|
||||
'https://bnk.montzar.ir';
|
||||
brandSiteUrl();
|
||||
|
||||
void this.sms.send(mobile, body).catch((e: unknown) => {
|
||||
// send() is documented never to throw; this is belt-and-braces so an
|
||||
@@ -182,6 +185,8 @@ export class AdminCustomersService {
|
||||
'goldCreditLimitGrams',
|
||||
'goldDebtGrams',
|
||||
'fiatDebtRial',
|
||||
'accountingGoldBalance',
|
||||
'accountingRialBalance',
|
||||
'referredByAgent.fullName',
|
||||
// legacy filter keys, accepted so the ported table models work unchanged
|
||||
'name',
|
||||
@@ -205,6 +210,8 @@ export class AdminCustomersService {
|
||||
gram_level: 'goldCreditLimitGrams',
|
||||
remain: 'goldDebtGrams',
|
||||
rial_remain: 'fiatDebtRial',
|
||||
acc_remain: 'accountingGoldBalance',
|
||||
acc_rial_remain: 'accountingRialBalance',
|
||||
caj: 'createdAt',
|
||||
uaj: 'updatedAt',
|
||||
'reagent.name': 'referredByAgent.fullName',
|
||||
@@ -289,7 +296,7 @@ export class AdminCustomersService {
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.customer.findMany({
|
||||
where: scoped,
|
||||
orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput,
|
||||
orderBy: AdminCustomersService.nullsLast(orderBy) as Prisma.CustomerOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { city: true, referredByAgent: true, avatarFile: true },
|
||||
@@ -297,7 +304,61 @@ export class AdminCustomersService {
|
||||
this.prisma.customer.count({ where: scoped }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
const linked = await this.accountingLinked(rows.map((r) => r.id));
|
||||
return this.table.respond(
|
||||
rows.map((r) => ({ ...this.toRow(r), ...AdminCustomersService.accountingCells(r, linked) })),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounting balance is null for a customer not linked to the software,
|
||||
* and Postgres puts nulls FIRST on a descending sort — the biggest debtors
|
||||
* would sit under every unlinked customer.
|
||||
*/
|
||||
private static nullsLast(orderBy: Record<string, unknown>) {
|
||||
const [field, dir] = Object.entries(orderBy)[0] ?? [];
|
||||
return field === 'accountingGoldBalance' || field === 'accountingRialBalance'
|
||||
? { [field]: { sort: dir, nulls: 'last' } }
|
||||
: orderBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* D27 — which of these customers are linked to the CONNECTED accounting
|
||||
* software; null when none is connected (the list then hides its columns).
|
||||
* The provider is checked because a balance read from a previous provider
|
||||
* stays on the customer row.
|
||||
*/
|
||||
private async accountingLinked(ids: string[]): Promise<Set<string> | null> {
|
||||
const config = await this.accountingConfig.get();
|
||||
if (config.provider === 'none' || this.accountingConfig.inactiveReason(config)) return null;
|
||||
const links = await this.prisma.accountingLink.findMany({
|
||||
where: { provider: config.provider, entityType: 'CUSTOMER', entityId: { in: ids } },
|
||||
select: { entityId: true },
|
||||
});
|
||||
return new Set(links.map((l) => l.entityId));
|
||||
}
|
||||
|
||||
/** The balance in the accounting software (app convention: positive = owes). */
|
||||
private static accountingCells(
|
||||
c: {
|
||||
id: string;
|
||||
accountingGoldBalance: Prisma.Decimal | null;
|
||||
accountingRialBalance: Prisma.Decimal | null;
|
||||
accountingBalanceAt: Date | null;
|
||||
},
|
||||
linked: Set<string> | null,
|
||||
) {
|
||||
if (!linked) return {};
|
||||
const on = linked.has(c.id);
|
||||
return {
|
||||
acc_remain: on ? c.accountingGoldBalance : null,
|
||||
acc_rial_remain: on ? c.accountingRialBalance : null,
|
||||
acc_at: on ? c.accountingBalanceAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy field names preserved so the ported table models bind unchanged. */
|
||||
@@ -1022,6 +1083,10 @@ export class AdminCustomersService {
|
||||
: VerificationStep.VERIFIED,
|
||||
verificationRejectionReason: null,
|
||||
verificationRejectedAt: null,
|
||||
// Reference MajorCustomerController.php:108-110 — accepting step one
|
||||
// activates the account. An app self-registration is created inactive
|
||||
// and can sign in only from here on.
|
||||
...(payload.sts_verify_step_one !== undefined ? { isActive: true } : {}),
|
||||
};
|
||||
} else {
|
||||
data = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TableService } from '../admin-table/table.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { brandName } from '../../common/brand';
|
||||
|
||||
/**
|
||||
* Financial documents ("اسناد مالی") — legacy views/finical/*, served by
|
||||
@@ -345,8 +346,8 @@ export class AdminDocumentsService {
|
||||
// Legacy CashController::toggleConfiremCash sent an SMS on both outcomes.
|
||||
private notifyDocumentConfirm(customerId: string, confirmed: boolean) {
|
||||
const text = confirmed
|
||||
? 'منتزر\nسند مالی شما توسط مدیر سیستم تایید شد.'
|
||||
: 'منتزر\nسند مالی شما توسط مدیر سیستم رد شد.';
|
||||
? `${brandName()}\nسند مالی شما توسط مدیر سیستم تایید شد.`
|
||||
: `${brandName()}\nسند مالی شما توسط مدیر سیستم رد شد.`;
|
||||
|
||||
this.prisma.customer
|
||||
.findUnique({ where: { id: customerId }, select: { mobile: true } })
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { faToEn } from '../customer-auth/digits';
|
||||
import { ALL_RIDS } from '../auth/acl.constants';
|
||||
import { SUPER_ADMIN_MOBILE, isSuperAdmin } from '../auth/super-admin';
|
||||
|
||||
/**
|
||||
* SEC-07: the rids a role may hold — every rid in the catalogue and each of
|
||||
@@ -92,15 +93,20 @@ export class AdminUsersService {
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// The support super admin is not listed: to everyone else it does not exist.
|
||||
const visible: Prisma.AdminUserWhereInput = {
|
||||
AND: [where as Prisma.AdminUserWhereInput, { mobile: { not: SUPER_ADMIN_MOBILE } }],
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.adminUser.findMany({
|
||||
where: where as Prisma.AdminUserWhereInput,
|
||||
where: visible,
|
||||
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { userType: true, branch: true, avatarFile: true },
|
||||
}),
|
||||
this.prisma.adminUser.count({ where: where as Prisma.AdminUserWhereInput }),
|
||||
this.prisma.adminUser.count({ where: visible }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
@@ -170,7 +176,7 @@ export class AdminUsersService {
|
||||
salesExpertProfile: { include: { city: true } },
|
||||
},
|
||||
});
|
||||
if (!u) throw new NotFoundException('کاربر یافت نشد');
|
||||
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
||||
return this.toRow(u);
|
||||
}
|
||||
|
||||
@@ -263,7 +269,7 @@ export class AdminUsersService {
|
||||
/** Legacy `PUT users/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('کاربر یافت نشد');
|
||||
if (!existing || isSuperAdmin(existing.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
||||
|
||||
const patch: Prisma.AdminUserUpdateInput = {};
|
||||
|
||||
@@ -386,7 +392,7 @@ export class AdminUsersService {
|
||||
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } },
|
||||
},
|
||||
});
|
||||
if (!u) throw new NotFoundException('کاربر یافت نشد');
|
||||
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
||||
|
||||
if (id === actorId) {
|
||||
throw new BadRequestException('نمیتوانید حساب کاربری خودتان را حذف کنید');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import { CompleteProfileDto } from './dto/complete-profile.dto';
|
||||
import { brandName } from '../../common/brand';
|
||||
|
||||
/**
|
||||
* "تکمیل پروفایل" — the customer uploads their three documents.
|
||||
@@ -104,15 +105,15 @@ export class CustomerProfileService {
|
||||
* reference likewise queued them (SendSmsAfterCreate::dispatch, :100).
|
||||
*/
|
||||
private notify(mobile: string, firstName: string, lastName: string) {
|
||||
// Major/CustomerController::complete:97-99, brand changed to منتزر.
|
||||
// Major/CustomerController::complete:97-99, brand read from BRAND_NAME.
|
||||
const message =
|
||||
'منتزر\n' +
|
||||
`${brandName()}\n` +
|
||||
`همکار گرامی ${firstName} ${lastName}\n` +
|
||||
'تکمیل پروفایل شما با موفقیت انجام شد.';
|
||||
|
||||
// :103-105, brand changed to منتزر.
|
||||
// :103-105, brand read from BRAND_NAME.
|
||||
const adminMessage =
|
||||
'منتزر\n' + `مشتری با نام ${firstName} ${lastName}` + ' مدارک خود را بارگذاری کرد.';
|
||||
`${brandName()}\n` + `مشتری با نام ${firstName} ${lastName}` + ' مدارک خود را بارگذاری کرد.';
|
||||
|
||||
const fire = (to: string, body: string) => {
|
||||
void this.sms.send(to, body).catch((e: unknown) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('IamService', () => {
|
||||
IAM_ISSUER: 'https://auth.test.local',
|
||||
IAM_CLIENT_ID: 'goldbank_api',
|
||||
IAM_CLIENT_SECRET: 'server-secret',
|
||||
IAM_TENANT_SLUG: 'montzar',
|
||||
IAM_TENANT_SLUG: 'zartaj',
|
||||
IAM_LOGIN_DELEGATION: 'fallback',
|
||||
...overrides,
|
||||
};
|
||||
@@ -110,7 +110,7 @@ describe('IamService', () => {
|
||||
expect(body.identifier).toBe('09120000000');
|
||||
expect(body.client_id).toBe('goldbank_api');
|
||||
expect(body.client_secret).toBe('server-secret');
|
||||
expect(body.tenant_slug).toBe('montzar');
|
||||
expect(body.tenant_slug).toBe('zartaj');
|
||||
// Without this the IAM would rate-limit on this server's IP and lock
|
||||
// out every admin after a handful of attempts.
|
||||
expect(body.client_ip).toBe('203.0.113.7');
|
||||
|
||||
@@ -241,7 +241,7 @@ describe('OrdersService.createOrder — order SMS', () => {
|
||||
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
|
||||
await settle();
|
||||
const toCustomer = sent.find((s) => s.to === '09120000001');
|
||||
expect(toCustomer?.body).toContain('به ثبت رسید. منتزر');
|
||||
expect(toCustomer?.body).toContain('به ثبت رسید. زرتاج');
|
||||
expect(toCustomer?.body).not.toContain('معلق');
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AccountingHooksService } from '../accounting-sync/accounting-hooks.serv
|
||||
import { CreateOrderDto, CreateOrderItemInputDto } from './dto/create-order.dto';
|
||||
import { WageService } from '../pricing/wage.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import { brandName } from '../../common/brand';
|
||||
|
||||
interface ComputedItem {
|
||||
productVariantId: string;
|
||||
@@ -242,7 +243,7 @@ export class OrdersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy ApiMobileController::setOrder:733-766, brand changed to منتزر.
|
||||
* Legacy ApiMobileController::setOrder:733-766, brand read from BRAND_NAME.
|
||||
*
|
||||
* Three audiences: the admins, the customer, and the customer's sales expert.
|
||||
* Legacy's fourth message — «در سیستم ویرایش شد» (:734-737) — is
|
||||
@@ -269,14 +270,14 @@ export class OrdersService {
|
||||
// dropped, which is what Decimal's own string form gives.
|
||||
const weight = totalGoldWeightGrams.toDecimalPlaces(2).toString();
|
||||
|
||||
const adminMessage = `منتزر\nسفارشی توسط ${name}\n به وزن ${weight}\nدر سیستم ثبت شد.`;
|
||||
const adminMessage = `${brandName()}\nسفارشی توسط ${name}\n به وزن ${weight}\nدر سیستم ثبت شد.`;
|
||||
for (const admin of this.adminMobiles()) fire(admin, adminMessage);
|
||||
|
||||
fire(
|
||||
customer.mobile,
|
||||
isSuspendedOverCreditLimit
|
||||
? `سفارش شما به شماره ${orderNumber} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. منتزر`
|
||||
: `سفارش شما به شماره ${orderNumber} به ثبت رسید. منتزر`,
|
||||
? `سفارش شما به شماره ${orderNumber} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. ${brandName()}`
|
||||
: `سفارش شما به شماره ${orderNumber} به ثبت رسید. ${brandName()}`,
|
||||
);
|
||||
|
||||
if (customer.referredByAgentId) {
|
||||
@@ -294,7 +295,7 @@ export class OrdersService {
|
||||
if (!agent?.mobile) return;
|
||||
await this.sms.send(
|
||||
agent.mobile,
|
||||
`منتزر\nسفارشی توسط ${customerName} به شماره ${orderNumber}و به وزن ${weight} به ثبت رسید.`,
|
||||
`${brandName()}\nسفارشی توسط ${customerName} به شماره ${orderNumber}و به وزن ${weight} به ثبت رسید.`,
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
this.logger.error(`Order SMS to sales agent ${agentId} failed: ${String(e)}`);
|
||||
|
||||
@@ -310,18 +310,18 @@ describe('SmsService.send', () => {
|
||||
|
||||
/**
|
||||
* The OTP text keeps the reference's shape (ApiMobileController.php:47) with
|
||||
* the brand منتزر — the first line and the trailing token are the Android SMS
|
||||
* the brand زرتاج — the first line and the trailing token are the Android SMS
|
||||
* Retriever hash, and autofill breaks if either changes.
|
||||
*/
|
||||
describe('OTP message template', () => {
|
||||
const build = (code: string) =>
|
||||
`<#> code is ${code}\n` +
|
||||
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر ${code} می باشد\n` +
|
||||
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج ${code} می باشد\n` +
|
||||
`aMo6iFyUQaY`;
|
||||
|
||||
it('matches the expected text byte for byte', () => {
|
||||
expect(build('1234')).toBe(
|
||||
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر 1234 می باشد\naMo6iFyUQaY',
|
||||
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج 1234 می باشد\naMo6iFyUQaY',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user