parity(api,apps): delivery-mode gaps — master OTP, non-blocking SMS, faToEn, v2 cart cap
D20: OTP_MASTER_CODE login bypass read only from the environment, disabled when unset, audited on every use, with a boot banner while armed. SMS no longer aborts the boot in production — it warns and declines silently, so no message can stop an operation. D19: port Utility::faTOen onto admin-entered mobiles (before the uniqueness lookup, which could previously be bypassed with Persian digits), and the create-only welcome SMS, byte-identical to the reference and fire-and-forget. user-app-v2 was missing the cart stock ceiling entirely: capQty/maxQty, the un-orderable guard and the qty clamp are ported, leaving v2's own design intact. The two cart stores are now byte-identical. isVariantOrderable and the orderable-first default selection ported too. jest 45/45; typecheck clean on api, admin-panel, user-app, user-app-v2. [ref: henza_server/app/Http/Controllers/Adm/AdminController.php:333,348,364,378-387] [ref: henza_server/app/Models/Utility.php:26] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2194b3d4d9
commit
460cf276b6
@@ -1,9 +1,11 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { CustomerKind, Prisma, VerificationStep } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { faToEn } from '../customer-auth/digits';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
|
||||
/**
|
||||
* Wholesale ("عمده") customer lists — legacy views/customer/omde/*.
|
||||
@@ -89,11 +91,41 @@ function verifyStepTwo(step: VerificationStep): number {
|
||||
|
||||
@Injectable()
|
||||
export class AdminCustomersService {
|
||||
private readonly logger = new Logger(AdminCustomersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The welcome SMS the reference sends after every new customer is created
|
||||
* (AdminController.php:378-387). Byte-identical, including the line order and
|
||||
* the trailing PWA link; 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
|
||||
* HTTP response, and `send()` already swallows every error, so the result is
|
||||
* only logged. The reference behaves the same way: `Sms::Send` is fired
|
||||
* inline and its return value discarded, so a failed message never blocked
|
||||
* the creation.
|
||||
*/
|
||||
private sendWelcomeSms(mobile: string, firstName: string, lastName: string) {
|
||||
const body =
|
||||
'هنزا گلد\n' +
|
||||
`همکار گرامی ${firstName} ${lastName}\n` +
|
||||
`نام کاربری : ${mobile}\n` +
|
||||
'لینک pwa \n' +
|
||||
'https://app.henzagold.com';
|
||||
|
||||
void this.sms.send(mobile, body).catch((e: unknown) => {
|
||||
// send() is documented never to throw; this is belt-and-braces so an
|
||||
// unexpected rejection can never become an unhandled promise rejection.
|
||||
this.logger.error(`Welcome SMS to ${mobile} failed: ${String(e)}`);
|
||||
});
|
||||
}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'fullName',
|
||||
@@ -611,7 +643,12 @@ export class AdminCustomersService {
|
||||
const firstName = String(data.fname ?? data.name ?? '').trim();
|
||||
const lastName = String(data.lname ?? '').trim();
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||
const mobile = String(data.mobile ?? '').trim();
|
||||
// D19 gap 2 — the reference normalises the submitted mobile with
|
||||
// Utility::faTOen before storing it (AdminController.php:333,364). Without
|
||||
// this, an operator typing ۰۹۱۲… stores Persian digits and that customer
|
||||
// can never log in: the app's own faToEn turns the typed number into ASCII
|
||||
// and the lookup misses.
|
||||
const mobile = faToEn(String(data.mobile ?? '').trim());
|
||||
const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim();
|
||||
|
||||
if (!fullName || !mobile) {
|
||||
@@ -633,7 +670,7 @@ export class AdminCustomersService {
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.customer.create({
|
||||
const created = await this.prisma.customer.create({
|
||||
data: {
|
||||
fullName,
|
||||
firstName: firstName || null,
|
||||
@@ -675,6 +712,13 @@ export class AdminCustomersService {
|
||||
referredByAgentId: (data.reagent_id as string) || null,
|
||||
},
|
||||
});
|
||||
|
||||
// D19 gap 1 — the reference sends this only on CREATE, guarded by
|
||||
// `if (!isset($input['id']))` (AdminController.php:378). Fire-and-forget,
|
||||
// so the operator's save never waits on the gateway.
|
||||
this.sendWelcomeSms(mobile, firstName, lastName);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
@@ -701,7 +745,11 @@ export class AdminCustomersService {
|
||||
patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent));
|
||||
}
|
||||
|
||||
if (data.mobile !== undefined) patch.mobile = String(data.mobile);
|
||||
// D19 gap 2 — same normalisation as create(). The reference discards the
|
||||
// submitted mobile entirely on edit (AdminController.php:348); D19 ruled
|
||||
// that lock NOT ported, so the field stays editable here and is normalised
|
||||
// instead. Logged in ISSUES as PERS-01.
|
||||
if (data.mobile !== undefined) patch.mobile = faToEn(String(data.mobile));
|
||||
if (data.national_code !== undefined) patch.nationalCode = String(data.national_code);
|
||||
if (data.city_id !== undefined) {
|
||||
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
|
||||
|
||||
Reference in New Issue
Block a user