Files
tehran_tala/apps/api/src/modules/sms/sms.service.ts
T
s.esmaeiliandClaude Opus 4.6 19d490e790 feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- 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>
2026-10-06 16:48:36 +03:30

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)}`);
}
}
}