parity(api,admin): split customer name, add making-charge offset, wire SMS
Applies the two approved additive migrations and fixes every consumer.
D0 — Customer.firstName / lastName
The reference has had two columns since 2020
(2020_10_01_120430_customers_table.php:18-19); the port collapsed them, so
the edit form showed the whole name in the "first name" box and saved an
empty last name. Adds both columns, backfills first-token/rest, and keeps
fullName in sync with a customer_sync_full_name trigger. Nothing dropped.
The trigger is SQL, so Prisma cannot see it: writing the parts rebuilds
fullName, writing fullName re-derives the parts, and writing both in one
statement lets THE PARTS WIN. Documented in the migration header, the
Customer model doc comment and DECISIONS.md §D14, because a write can now
store something other than what was passed.
D9 — Customer.makingChargeOffsetPercent
Legacy customers.percent, the per-customer making-charge offset that the
app adds to the variant's making charge (henza_pwa
Mixins/product/index.ts:69-75). toRow() was returning the referring
AGENT's commission rate instead (CUST-02). DB default 0 reproduces
today's behaviour exactly, so no existing customer's price moves; the
reference's application-layer default of 3 for NEW customers
(AdminController.php:338) lives in the create form, and the field is
required on create with the reference's own message (:354).
update() and updateCredit() both accepted percent and silently discarded
it; both now persist it. Sort/filter aliases fname/lname pointed at
fullName and now point at the real columns.
SMS — Trez provider (Sms.php:57-82)
New global SmsModule/SmsService: form-urlencoded POST, 20s timeout, body
trimmed, every message persisted. send() never throws, matching the
reference, which discarded Sms::Send's result so a gateway outage never
aborted the order or approval that triggered it. The OTP text is
byte-identical, including the Android SMS Retriever markers. Credentials
come from env only; production refuses to boot without them rather than
silently locking every customer out. .env.example carries names, no values.
Admin panel: the split(' ') fallback is removed from singleCustomer.vue,
and bulkCustomer.vue defaults percent to 3, requires it, coerces the
Decimal on load so diff-send works, and only re-sends `name` when a name
part changed. ReagentCustomer.vue is untouched — its «درصد کمیسیون» is
AdminUser.wagePercent, a different field.
Tests: 32 jest (incl. 14 new SMS config/template), 9 trigger assertions,
53 auth smoke, and the full D12 round-trip on the customer entity —
«محمد علی» + «احمدی» survives create, edit, a byte-identical no-op save,
and single-field edits. Typecheck clean on api and admin-panel.
Note: migrate dev is interactive-only and could not run here, so the
migrations were applied with migrate deploy after a read-only drift check.
The only drift is the pre-existing _goldbank_migrations table, which
Prisma would DROP; it was NOT dropped (DEAD-07).
[ref: henza_server/app/Models/Sms.php, app/Http/Controllers/Adm/AdminController.php]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7b598e9e8e
commit
8133a2bdf4
@@ -97,6 +97,9 @@ export class AdminCustomersService {
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'fullName',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'makingChargeOffsetPercent',
|
||||
'mobile',
|
||||
'nationalCode',
|
||||
'cityId',
|
||||
@@ -120,8 +123,11 @@ export class AdminCustomersService {
|
||||
/** Translates legacy filter field names onto real schema paths. */
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
name: 'fullName',
|
||||
fname: 'fullName',
|
||||
lname: 'fullName',
|
||||
// D0 — these now have real columns to point at. Sorting or filtering on
|
||||
// the last name used to silently act on the whole name.
|
||||
fname: 'firstName',
|
||||
lname: 'lastName',
|
||||
percent: 'makingChargeOffsetPercent',
|
||||
city_id: 'cityId',
|
||||
active: 'isActive',
|
||||
gram_level: 'goldCreditLimitGrams',
|
||||
@@ -196,7 +202,10 @@ export class AdminCustomersService {
|
||||
const aliased = this.applyAliases(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['fullName', 'mobile', 'nationalCode'],
|
||||
// D0 — `fullName` is kept in sync by the trigger so it still matches, but
|
||||
// the parts are searched too: a surname-only query is the common case and
|
||||
// it must not depend on how the whole name happens to be spaced.
|
||||
searchable: ['fullName', 'firstName', 'lastName', 'mobile', 'nationalCode'],
|
||||
allowed: AdminCustomersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
@@ -227,10 +236,15 @@ export class AdminCustomersService {
|
||||
) {
|
||||
return {
|
||||
id: c.id,
|
||||
// Legacy stored fname/lname separately; the new schema has one fullName.
|
||||
// D0 — the schema now has the real parts, as the reference always did
|
||||
// (2020_10_01_120430_customers_table.php:18-19). Before this, the edit
|
||||
// form showed the WHOLE name in the "first name" box and saved an empty
|
||||
// last name. `firstName`/`lastName` are backfilled and kept in sync with
|
||||
// `fullName` by the customer_sync_full_name trigger, so the fallbacks
|
||||
// below only matter for a row written before the migration ran.
|
||||
name: c.fullName,
|
||||
fname: c.fullName,
|
||||
lname: '',
|
||||
fname: c.firstName ?? c.fullName,
|
||||
lname: c.lastName ?? '',
|
||||
mobile: c.mobile,
|
||||
national_code: c.nationalCode,
|
||||
city_id: c.cityId,
|
||||
@@ -241,8 +255,12 @@ export class AdminCustomersService {
|
||||
gram_level: c.goldCreditLimitGrams,
|
||||
remain: c.goldDebtGrams,
|
||||
rial_remain: c.fiatDebtRial,
|
||||
// legacy `percent` came from the referring agent's commission rate
|
||||
percent: c.referredByAgent?.wagePercent ?? null,
|
||||
// D9 — legacy `percent` is a column ON THE CUSTOMER
|
||||
// (customer_add.php:32), the per-customer making-charge offset. The port
|
||||
// read the referring AGENT's commission rate instead, which is a
|
||||
// different number entirely (CUST-02). The legacy key is kept so the
|
||||
// ported table models and forms bind unchanged.
|
||||
percent: c.makingChargeOffsetPercent,
|
||||
// legacy kept these as two INDEPENDENT switches — see toggleSts/toggleActive
|
||||
active: c.isActive ? 1 : -1,
|
||||
sts: c.canPlaceOrders ? 1 : -1,
|
||||
@@ -516,7 +534,13 @@ export class AdminCustomersService {
|
||||
* is generic enough to patch any Customer row, agent or otherwise.
|
||||
*/
|
||||
async createSingle(data: Record<string, unknown>) {
|
||||
const fullName = String(data.name ?? data.fname ?? '').trim();
|
||||
// D0 — store the parts when the caller sends them. This is the retail
|
||||
// (storefront) form, which D13 puts out of scope for parity work; the name
|
||||
// handling is corrected only so the shared Customer columns stay
|
||||
// consistent, and no storefront behaviour changes.
|
||||
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 ?? data.phone ?? '').trim();
|
||||
|
||||
if (!fullName || !mobile) {
|
||||
@@ -526,6 +550,8 @@ export class AdminCustomersService {
|
||||
return this.prisma.customer.create({
|
||||
data: {
|
||||
fullName,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
mobile,
|
||||
nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode
|
||||
passwordHash: '',
|
||||
@@ -545,7 +571,11 @@ export class AdminCustomersService {
|
||||
* (e.g. reg.vue, or a stale client) still sending type=1.
|
||||
*/
|
||||
async create(data: Record<string, unknown>) {
|
||||
const fullName = String(data.name ?? data.fname ?? '').trim();
|
||||
// D0 — the form posts fname and lname separately; store them separately.
|
||||
// `name` is still accepted for the callers that only send a whole name.
|
||||
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();
|
||||
const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim();
|
||||
|
||||
@@ -553,6 +583,14 @@ export class AdminCustomersService {
|
||||
throw new BadRequestException('نام و موبایل الزامی است');
|
||||
}
|
||||
|
||||
// D9 — the reference makes `percent` required on create
|
||||
// (AdminController.php:354) with the stock Laravel message, and its
|
||||
// attribute has no Persian translation, so the text is verbatim what the
|
||||
// reference produces.
|
||||
if (data.percent === undefined || data.percent === null || data.percent === '') {
|
||||
throw new BadRequestException('وارد کردن percent الزامی است');
|
||||
}
|
||||
|
||||
const kind = legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER;
|
||||
if (kind === CustomerKind.SALES_AGENT) {
|
||||
throw new BadRequestException(
|
||||
@@ -563,6 +601,12 @@ export class AdminCustomersService {
|
||||
return this.prisma.customer.create({
|
||||
data: {
|
||||
fullName,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
// D9 — per-customer making-charge offset. Required above, so this is
|
||||
// always a real value; the reference's own form default is 3
|
||||
// (AdminController.php:338) and is applied client-side.
|
||||
makingChargeOffsetPercent: new Prisma.Decimal(String(data.percent)),
|
||||
mobile,
|
||||
nationalCode: nationalCode || mobile, // schema requires it and it is unique
|
||||
// The form collects a password (bulkCustomer.vue / ReagentCustomer.vue).
|
||||
@@ -603,7 +647,25 @@ export class AdminCustomersService {
|
||||
if (!existing) throw new NotFoundException('مشتری یافت نشد');
|
||||
|
||||
const patch: Prisma.CustomerUpdateInput = {};
|
||||
if (data.name !== undefined) patch.fullName = String(data.name);
|
||||
|
||||
// D0 — write the parts, not a collapsed whole name. The
|
||||
// customer_sync_full_name trigger rebuilds `fullName` from them, and when
|
||||
// a caller sends both the parts win (see the migration's header).
|
||||
if (data.fname !== undefined || data.lname !== undefined) {
|
||||
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
|
||||
const lastName = String(data.lname ?? existing.lastName ?? '').trim();
|
||||
patch.firstName = firstName || null;
|
||||
patch.lastName = lastName || null;
|
||||
} else if (data.name !== undefined) {
|
||||
// Whole-name-only callers: the trigger derives the parts.
|
||||
patch.fullName = String(data.name);
|
||||
}
|
||||
|
||||
// D9 — this was simply not handled, so editing it silently did nothing.
|
||||
if (data.percent !== undefined && data.percent !== null && data.percent !== '') {
|
||||
patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent));
|
||||
}
|
||||
|
||||
if (data.mobile !== undefined) patch.mobile = String(data.mobile);
|
||||
if (data.national_code !== undefined) patch.nationalCode = String(data.national_code);
|
||||
if (data.city_id !== undefined) {
|
||||
@@ -857,9 +919,15 @@ export class AdminCustomersService {
|
||||
throw new BadRequestException('مقدار سطح اعتبار ارسال نشده است');
|
||||
}
|
||||
|
||||
return this.prisma.customer.update({
|
||||
where: { id },
|
||||
data: { goldCreditLimitGrams: new Prisma.Decimal(data.gram_level) },
|
||||
});
|
||||
// D9 — `percent` was in this method's signature but was never written, so
|
||||
// editing it from the row menu silently did nothing.
|
||||
const patch: Prisma.CustomerUpdateInput = {
|
||||
goldCreditLimitGrams: new Prisma.Decimal(data.gram_level),
|
||||
};
|
||||
if (data.percent !== undefined && data.percent !== null && String(data.percent) !== '') {
|
||||
patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent));
|
||||
}
|
||||
|
||||
return this.prisma.customer.update({ where: { id }, data: patch });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CUSTOMER_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
||||
import { faToEn } from './digits';
|
||||
import { SmsService, smsConfigured } from '../sms/sms.service';
|
||||
|
||||
/** Nest 10 has no TooManyRequestsException; 429 with a Persian message. */
|
||||
class TooManyRequestsException extends HttpException {
|
||||
@@ -32,6 +33,7 @@ export class CustomerAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -201,28 +203,34 @@ export class CustomerAuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the code to the SMS gateway.
|
||||
* Hands the code to the SMS gateway (SmsService -> Trez, per Sms.php:57-82).
|
||||
*
|
||||
* No gateway is wired up in this project yet — SmsMessage is persistence only
|
||||
* (logged as STUB-02 in docs/parity/ISSUES.md), exactly as the reference's
|
||||
* Sms::Send would have been the integration point. Until one is connected the
|
||||
* code is logged, and only outside production, so a deployment cannot print
|
||||
* live codes into its logs.
|
||||
* When no credentials are configured, SmsService does not send and the code
|
||||
* is logged instead — development only; production refuses to boot without
|
||||
* credentials (assertSmsConfigured in main.ts).
|
||||
*
|
||||
* The reference's message text is kept verbatim for when the gateway lands
|
||||
* (ApiMobileController.php:47).
|
||||
* A failed send does NOT throw. The reference ignored Sms::Send's result, so
|
||||
* a gateway outage never blocked the flow that triggered it; the customer
|
||||
* simply retries, and the throttle in assertSendAllowed still applies.
|
||||
*/
|
||||
private async deliver(mobile: string, code: string) {
|
||||
// The exact text the reference sent (ApiMobileController.php:47), kept for
|
||||
// whenever a gateway is wired up. It is built here and handed to the
|
||||
// gateway; it is deliberately NOT what gets persisted (see below).
|
||||
// BYTE-IDENTICAL to the reference (ApiMobileController.php:47). 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" this string.
|
||||
const smsText =
|
||||
`<#> code is ${code}\n` +
|
||||
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد ${code} می باشد\n` +
|
||||
`aMo6iFyUQaY`;
|
||||
void smsText; // TODO: hand to the SMS provider once one exists (STUB-02)
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// persist:false — the real text carries the live code, and the admin SMS
|
||||
// grid lists and searches `body` (SEC-11). The redacted audit row below
|
||||
// takes its place.
|
||||
await this.sms.send(mobile, smsText, { persist: false });
|
||||
|
||||
// Only outside production, and only when no gateway is configured, so a
|
||||
// deployment can never print live codes into its logs.
|
||||
if (process.env.NODE_ENV !== 'production' && !smsConfigured()) {
|
||||
this.logger.log(`[dev] OTP for ${mobile}: ${code}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
/**
|
||||
* Global so any module can send an SMS without re-importing it — the twelve
|
||||
* reference notification points are spread across orders, documents and
|
||||
* customer management.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { assertSmsConfigured, smsConfig, smsConfigured } from './sms.service';
|
||||
|
||||
/**
|
||||
* Covers the boot-time contract the owner specified:
|
||||
* * while no key is set, development behaviour is unchanged;
|
||||
* * in production a missing key is a clear, explicit boot failure.
|
||||
*/
|
||||
describe('SMS configuration', () => {
|
||||
const saved = { ...process.env };
|
||||
// Silence the warnings these tests deliberately provoke.
|
||||
const logger = { warn: jest.fn(), log: jest.fn(), error: jest.fn() } as unknown as Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of [
|
||||
'SMS_PROVIDER',
|
||||
'SMS_TREZ_URL',
|
||||
'SMS_TREZ_USERNAME',
|
||||
'SMS_TREZ_PASSWORD',
|
||||
'SMS_TREZ_SENDER_LINE',
|
||||
'NODE_ENV',
|
||||
]) {
|
||||
delete process.env[key];
|
||||
}
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = { ...saved };
|
||||
});
|
||||
|
||||
const configureFully = () => {
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
||||
};
|
||||
|
||||
describe('smsConfig', () => {
|
||||
it('defaults the provider to trez and the URL to the reference endpoint', () => {
|
||||
const c = smsConfig();
|
||||
expect(c.provider).toBe('trez');
|
||||
expect(c.url).toBe('https://smspanel.trez.ir/SendMessageWithCode.ashx');
|
||||
});
|
||||
|
||||
it('lets the URL be overridden for staging', () => {
|
||||
process.env.SMS_TREZ_URL = 'https://staging.example/send';
|
||||
expect(smsConfig().url).toBe('https://staging.example/send');
|
||||
});
|
||||
});
|
||||
|
||||
describe('smsConfigured', () => {
|
||||
it('is false when nothing is set', () => {
|
||||
expect(smsConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when only some credentials are set', () => {
|
||||
process.env.SMS_TREZ_USERNAME = 'u';
|
||||
process.env.SMS_TREZ_PASSWORD = 'p';
|
||||
expect(smsConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once all three are set', () => {
|
||||
configureFully();
|
||||
expect(smsConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the provider is explicitly disabled', () => {
|
||||
configureFully();
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
expect(smsConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertSmsConfigured', () => {
|
||||
it('only warns outside production when keys are missing', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('THROWS in production when keys are missing, naming them', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_USERNAME/);
|
||||
expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_PASSWORD/);
|
||||
expect(() => assertSmsConfigured(logger)).toThrow(/SMS_TREZ_SENDER_LINE/);
|
||||
});
|
||||
|
||||
it('passes in production once everything is set', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
configureFully();
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
});
|
||||
|
||||
it('THROWS in production when SMS is explicitly disabled', () => {
|
||||
// Disabling SMS in production means nobody can log in; that must be a
|
||||
// deliberate, visible failure rather than a silent one.
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
expect(() => assertSmsConfigured(logger)).toThrow(/Refusing to start in production/);
|
||||
});
|
||||
|
||||
it('allows SMS to be disabled outside production', () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.SMS_PROVIDER = 'none';
|
||||
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unknown provider name outright', () => {
|
||||
process.env.SMS_PROVIDER = 'kavenegar';
|
||||
expect(() => assertSmsConfigured(logger)).toThrow(/Unknown SMS_PROVIDER/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The OTP text must stay byte-identical to the reference
|
||||
* (ApiMobileController.php:47) — 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` +
|
||||
`aMo6iFyUQaY`;
|
||||
|
||||
it('matches the reference byte for byte', () => {
|
||||
expect(build('1234')).toBe(
|
||||
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد 1234 می باشد\naMo6iFyUQaY',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the SMS Retriever markers', () => {
|
||||
const text = build('9876');
|
||||
expect(text.startsWith('<#> code is ')).toBe(true);
|
||||
expect(text.endsWith('\naMo6iFyUQaY')).toBe(true);
|
||||
expect(text.split('\n')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { MessageAudience } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* SMS delivery — port of henza_server/app/Models/Sms.php.
|
||||
*
|
||||
* Reference behaviour (`Sms::Send`, `Sms.php:57-82`):
|
||||
* * POST, form-urlencoded, to the Trez panel, 20s timeout
|
||||
* * fields: PhoneNumber (sender line), Username, Password, Mobile, Message
|
||||
* * the message is trim()ed before sending
|
||||
* * EVERY message is persisted, via `Sms::set()` (`:80`)
|
||||
* * the response body is returned and the callers ignore it
|
||||
*
|
||||
* Two things differ, both deliberate:
|
||||
* 1. Credentials come from the environment. The reference hardcodes live
|
||||
* production credentials in the source (SEC-12); they are not copied here.
|
||||
* 2. The OTP message body is NOT persisted in readable form — see
|
||||
* CustomerAuthService.deliver() and SEC-11.
|
||||
*
|
||||
* Failure handling matches the reference: `Sms::Send` is called inline and its
|
||||
* result is discarded, so a failed send never aborted the order, the document
|
||||
* approval or the customer creation that triggered it. `send()` therefore
|
||||
* never throws — it logs and reports failure in its return value.
|
||||
*/
|
||||
|
||||
/** Reference endpoint (`Sms.php:69`). Overridable for staging. */
|
||||
const DEFAULT_TREZ_URL = 'https://smspanel.trez.ir/SendMessageWithCode.ashx';
|
||||
|
||||
/** `Http::timeout(20)` in the reference (`Sms.php:72`). */
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
export interface SmsSendResult {
|
||||
ok: boolean;
|
||||
/** Provider response body, when one was received. */
|
||||
response?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SmsSendOptions {
|
||||
/**
|
||||
* Persist the message body. Default true, matching `Sms::set()`.
|
||||
* OTP delivery passes false and writes its own redacted row (SEC-11).
|
||||
*/
|
||||
persist?: boolean;
|
||||
audience?: MessageAudience;
|
||||
}
|
||||
|
||||
/** Reads the provider config. Exported so main.ts can validate it at boot. */
|
||||
export function smsConfig() {
|
||||
return {
|
||||
provider: (process.env.SMS_PROVIDER ?? 'trez').toLowerCase(),
|
||||
url: process.env.SMS_TREZ_URL ?? DEFAULT_TREZ_URL,
|
||||
username: process.env.SMS_TREZ_USERNAME ?? '',
|
||||
password: process.env.SMS_TREZ_PASSWORD ?? '',
|
||||
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a real gateway is configured. When false the service stays in
|
||||
* development mode: nothing is sent, and OTP codes are logged locally.
|
||||
*/
|
||||
export function smsConfigured(): boolean {
|
||||
const c = smsConfig();
|
||||
if (c.provider === 'none') return false;
|
||||
return Boolean(c.username && c.password && c.senderLine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from main.ts before the app listens. In production a missing key is
|
||||
* fatal: without it no customer can log in, and a silent no-op gives no clue
|
||||
* why. Outside production it is only a warning, so local development works
|
||||
* with no credentials at all.
|
||||
*/
|
||||
export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): void {
|
||||
const c = smsConfig();
|
||||
|
||||
if (c.provider === 'none') {
|
||||
const message = 'SMS_PROVIDER=none — no SMS will be sent.';
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
`${message} Refusing to start in production: customers could not log in. ` +
|
||||
'Set SMS_PROVIDER=trez and the SMS_TREZ_* variables.',
|
||||
);
|
||||
}
|
||||
logger.warn(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (c.provider !== 'trez') {
|
||||
throw new Error(
|
||||
`Unknown SMS_PROVIDER "${c.provider}". Supported values: "trez", "none".`,
|
||||
);
|
||||
}
|
||||
|
||||
const missing = (
|
||||
[
|
||||
['SMS_TREZ_USERNAME', c.username],
|
||||
['SMS_TREZ_PASSWORD', c.password],
|
||||
['SMS_TREZ_SENDER_LINE', c.senderLine],
|
||||
] as const
|
||||
)
|
||||
.filter(([, v]) => !v)
|
||||
.map(([name]) => name);
|
||||
|
||||
if (missing.length === 0) return;
|
||||
|
||||
const message = `SMS provider "trez" is selected but these are not set: ${missing.join(', ')}.`;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
`${message} Refusing to start in production: no customer could receive a login code. ` +
|
||||
'Set them, or set SMS_PROVIDER=none to acknowledge that SMS is disabled.',
|
||||
);
|
||||
}
|
||||
logger.warn(`${message} Running in development mode — messages are logged, not sent.`);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Sends one message. Never throws — a failed SMS must not roll back the
|
||||
* business operation that triggered it, exactly as in the reference.
|
||||
*/
|
||||
async send(mobile: string, body: string, options: SmsSendOptions = {}): Promise<SmsSendResult> {
|
||||
const { persist = true, audience = MessageAudience.SINGLE_NUMBER } = options;
|
||||
// The reference trims before sending (`Sms.php:77`).
|
||||
const text = body.trim();
|
||||
|
||||
let result: SmsSendResult;
|
||||
|
||||
if (!smsConfigured()) {
|
||||
this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`);
|
||||
result = { ok: false, error: 'sms-not-configured' };
|
||||
} else {
|
||||
result = await this.deliverViaTrez(mobile, text);
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
// `Sms::set()` records every message, delivered or not.
|
||||
await this.record(mobile, text, audience);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** POST to the Trez panel, shaped exactly as the reference's form post. */
|
||||
private async deliverViaTrez(mobile: string, text: string): Promise<SmsSendResult> {
|
||||
const c = smsConfig();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(c.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
PhoneNumber: c.senderLine,
|
||||
Username: c.username,
|
||||
Password: c.password,
|
||||
Mobile: mobile,
|
||||
Message: text,
|
||||
}).toString(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
// Logged, not thrown — see the class comment.
|
||||
this.logger.error(`SMS to ${mobile} failed: HTTP ${response.status} ${responseBody}`);
|
||||
return { ok: false, response: responseBody, error: `http-${response.status}` };
|
||||
}
|
||||
|
||||
return { ok: true, response: responseBody };
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`SMS to ${mobile} failed: ${error}`);
|
||||
return { ok: false, error };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** The `messages` row the reference writes for every send (`Sms.php:120-129`). */
|
||||
private async record(mobile: string, body: string, audience: MessageAudience) {
|
||||
try {
|
||||
await this.prisma.smsMessage.create({
|
||||
data: { audience, recipients: mobile, body, sentCount: 1 },
|
||||
});
|
||||
} catch (e) {
|
||||
// Never let the audit write break the caller either.
|
||||
this.logger.error(`Could not record the SMS to ${mobile}: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user