Found by running the delivery flow against the actual built container rather
than a local dev process.
DEPLOY-02 (Critical) — ADMIN LOGIN RETURNED 500 FOR EVERY USER IN DOCKER.
docker-compose.yml passes each optional variable as ${X:-}, so an unconfigured
one arrives as "" rather than undefined. The code read them with
`process.env.X ?? 'default'`, which only falls back on undefined, so the empty
string reached jsonwebtoken and it threw
"expiresIn" should be a number of seconds or string representing a timespan
on every sign. Invisible to every existing suite, all of which run against a
local process where the variable is genuinely absent.
Added src/common/env.ts (envOr / envOrNull), which treat blank as unset, and
applied it to every auth, port, upload and SMS read. Three were not cosmetic:
a blank UPLOAD_DIR would have served static files from the FILESYSTEM ROOT, a
blank PORT would have bound a random ephemeral port (Number('') === 0), and a
blank JWT_SECRET would have become the verifying key. Recorded as D25, with a
standing rule to use these readers instead of `??`.
Pinned by src/common/env.spec.ts (9 tests), which reproduces the original throw
against the real jsonwebtoken and asserts the reader can no longer produce it.
DEPLOY-03 (process, documented) — the API image is `COPY release/ ./`, so it
ships a bundle pre-built on the developer machine. `docker compose build` will
happily produce an image from a STALE release/app.js and report success; at the
start of this run that bundle was four days old and the rebuilt container was
missing every route added since. DELIVERY.md's deploy section now leads with
`pnpm build:release`.
New: scripts/test-compose-e2e.cjs — the whole delivery flow against the built
container, over HTTP only, as a black box: a sales expert and an ordinary
customer both sign in with the master code, both place an order, both orders
appear in the admin grid and open there, and the three token boundaries still
hold in the deployed image. 32/32.
Verified: compose e2e 32/32 · jest 54/54 · build 5/5 ·
against the CONTAINER: complete-profile 38/38, report 40/40, expert login 36/36 ·
locally: apps smoke 43/43, admin smoke 43/43, B2B scan 34 ok/0 broken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
6.7 KiB
TypeScript
198 lines
6.7 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { MessageAudience } from '@prisma/client';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { envOr } from '../../common/env';
|
|
|
|
/**
|
|
* 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: envOr('SMS_PROVIDER', 'trez').toLowerCase(),
|
|
url: envOr('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. 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 c = smsConfig();
|
|
|
|
if (c.provider === 'none') {
|
|
logger.warn('SMS_PROVIDER=none — no SMS will be sent.');
|
|
return;
|
|
}
|
|
|
|
if (c.provider !== 'trez') {
|
|
logger.warn(
|
|
`Unknown SMS_PROVIDER "${c.provider}" (supported: "trez", "none"). ` +
|
|
'No SMS will be sent.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
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;
|
|
|
|
logger.warn(
|
|
`SMS provider "trez" 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();
|
|
|
|
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)}`);
|
|
}
|
|
}
|
|
}
|