- Add Tahesab/Zarrin accounting sync module with admin panel UI - Add SMS relay driver as default provider, refactor SMS into driver pattern - Update customer services, orders, products, and catalog queries - Rebuild user-app and user-app-v2 dist assets - Add Prisma migration for accounting tables - Update docker-compose and env examples - Update parity docs (DECISIONS, ISSUES, PROGRESS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
158 lines
5.5 KiB
TypeScript
158 lines
5.5 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { MessageAudience } from '@prisma/client';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { envOr } from '../../common/env';
|
|
import { DEFAULT_SMS_PROVIDER, SMS_DRIVERS, SmsDriver, SmsSendResult } from './drivers';
|
|
|
|
/**
|
|
* SMS delivery — port of henza_server/app/Models/Sms.php, with the gateway
|
|
* pulled out into drivers (`./drivers`) selected by `SMS_PROVIDER`:
|
|
* * `relay` (default, D26) — the owner's createsms gateway
|
|
* * `trez` — the reference's Trez panel
|
|
* * `none` — send nothing
|
|
*
|
|
* Reference behaviour kept for every driver:
|
|
* * the message is trim()ed before sending (`Sms.php:77`)
|
|
* * EVERY message is persisted, via `Sms::set()` (`:80`)
|
|
* * the response body is returned and the callers ignore it
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
export type { SmsSendResult } from './drivers';
|
|
|
|
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;
|
|
}
|
|
|
|
/** The selected `SMS_PROVIDER`, lower-cased. */
|
|
export function smsProvider(): string {
|
|
return envOr('SMS_PROVIDER', DEFAULT_SMS_PROVIDER).toLowerCase();
|
|
}
|
|
|
|
/** The selected driver, or null for `none` and unknown names. */
|
|
export function smsDriver(): SmsDriver | null {
|
|
return SMS_DRIVERS[smsProvider()] ?? null;
|
|
}
|
|
|
|
/**
|
|
* 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 driver = smsDriver();
|
|
return driver !== null && driver.missingConfig().length === 0;
|
|
}
|
|
|
|
/**
|
|
* Called from main.ts before the app listens. Reports the SMS configuration
|
|
* and NEVER throws.
|
|
*
|
|
* D20 reversed the earlier rule (D14) that a missing credential should abort
|
|
* the boot in production. The server now always starts: a missing or unknown
|
|
* provider is a warning, `send()` declines silently and logs, and no message —
|
|
* including the welcome SMS — can stop an operation. The trade-off is
|
|
* deliberate and accepted: with SMS unconfigured, customers cannot receive a
|
|
* login code, which is why `OTP_MASTER_CODE` exists for the delivery.
|
|
*/
|
|
export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): void {
|
|
const provider = smsProvider();
|
|
|
|
if (provider === 'none') {
|
|
logger.warn('SMS_PROVIDER=none — no SMS will be sent.');
|
|
return;
|
|
}
|
|
|
|
const driver = smsDriver();
|
|
if (!driver) {
|
|
const supported = [...Object.keys(SMS_DRIVERS), 'none'].map((n) => `"${n}"`).join(', ');
|
|
logger.warn(
|
|
`Unknown SMS_PROVIDER "${provider}" (supported: ${supported}). No SMS will be sent.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const missing = driver.missingConfig();
|
|
if (missing.length === 0) {
|
|
logger.log(`SMS provider: ${driver.name}`);
|
|
return;
|
|
}
|
|
|
|
logger.warn(
|
|
`SMS provider "${driver.name}" is selected but these are not set: ${missing.join(', ')}. ` +
|
|
'Messages are logged, not sent. Customers cannot receive a login code.',
|
|
);
|
|
}
|
|
|
|
@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();
|
|
|
|
console.log("mobile ==>" ,mobile)
|
|
const result = await this.deliver(mobile, text);
|
|
|
|
if (persist) {
|
|
// `Sms::set()` records every message, delivered or not.
|
|
await this.record(mobile, text, audience);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async deliver(mobile: string, text: string): Promise<SmsSendResult> {
|
|
const driver = smsDriver();
|
|
if (!driver || driver.missingConfig().length > 0) {
|
|
this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`);
|
|
return { ok: false, error: 'sms-not-configured' };
|
|
}
|
|
|
|
let result: SmsSendResult;
|
|
try {
|
|
result = await driver.deliver(mobile, text);
|
|
} catch (e) {
|
|
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
|
|
if (!result.ok) {
|
|
// Logged, not thrown — see the class comment.
|
|
const detail = result.response ? ` ${result.response}` : '';
|
|
this.logger.error(`SMS to ${mobile} via ${driver.name} failed: ${result.error}${detail}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** 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)}`);
|
|
}
|
|
}
|
|
}
|