forked from Yara724/api
Centralized SMS services YARA-834
This commit is contained in:
78
src/sms-orchestration/provider/kavenegar-response.ts
Normal file
78
src/sms-orchestration/provider/kavenegar-response.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
const MAX_RAW_LEN = 8000;
|
||||
|
||||
export interface KavenegarNormalized {
|
||||
httpLikeStatus?: number;
|
||||
message?: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
const s = JSON.stringify(value);
|
||||
return s.length > MAX_RAW_LEN
|
||||
? `${s.slice(0, MAX_RAW_LEN)}...[truncated]`
|
||||
: s;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kavenegar REST responses use `{ return: { status, message }, ... }`.
|
||||
* Transport failures may yield non-JSON strings or malformed bodies.
|
||||
*/
|
||||
export function normalizeKavenegarBody(body: unknown): KavenegarNormalized {
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
return normalizeKavenegarBody(JSON.parse(body) as unknown);
|
||||
} catch {
|
||||
const raw =
|
||||
body.length > MAX_RAW_LEN
|
||||
? `${body.slice(0, MAX_RAW_LEN)}...[truncated]`
|
||||
: body;
|
||||
return { raw };
|
||||
}
|
||||
}
|
||||
|
||||
let httpLikeStatus: number | undefined;
|
||||
let message: string | undefined;
|
||||
|
||||
if (body && typeof body === "object" && !Array.isArray(body)) {
|
||||
const ret = (body as { return?: { status?: unknown; message?: unknown } })
|
||||
.return;
|
||||
if (ret && typeof ret === "object") {
|
||||
if (typeof ret.status === "number" && Number.isFinite(ret.status)) {
|
||||
httpLikeStatus = ret.status;
|
||||
}
|
||||
if (typeof ret.message === "string") {
|
||||
message = ret.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
httpLikeStatus,
|
||||
message,
|
||||
raw: safeJsonStringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
/** Kavenegar uses `return.status === 200` for a successful API call. */
|
||||
export function isKavenegarSuccess(body: unknown): boolean {
|
||||
return normalizeKavenegarBody(body).httpLikeStatus === 200;
|
||||
}
|
||||
|
||||
/** The nest client sometimes rejects with a JSON string `{ "error": "..." }`. */
|
||||
export function unwrapKavenegarTransportError(err: unknown): unknown {
|
||||
if (typeof err === "string") {
|
||||
try {
|
||||
return JSON.parse(err) as unknown;
|
||||
} catch {
|
||||
return { transportMessage: err };
|
||||
}
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { name: err.name, message: err.message };
|
||||
}
|
||||
return err;
|
||||
}
|
||||
18
src/sms-orchestration/provider/sms-gateway.module.ts
Normal file
18
src/sms-orchestration/provider/sms-gateway.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { KavenegarModule } from "@fraybabak/kavenegar_nest";
|
||||
import { Module } from "@nestjs/common";
|
||||
import * as dotenv from "dotenv";
|
||||
import { SmsGatewayService } from "./sms-gateway.service";
|
||||
|
||||
dotenv.config();
|
||||
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
KavenegarModule.forRoot({
|
||||
apikey: process.env.SMS_API_KEY || "",
|
||||
}),
|
||||
],
|
||||
providers: [SmsGatewayService],
|
||||
exports: [SmsGatewayService],
|
||||
})
|
||||
export class SmsGatewayModule {}
|
||||
110
src/sms-orchestration/provider/sms-gateway.service.ts
Normal file
110
src/sms-orchestration/provider/sms-gateway.service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { KavenegarService } from "@fraybabak/kavenegar_nest";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
isKavenegarSuccess,
|
||||
KavenegarNormalized,
|
||||
normalizeKavenegarBody,
|
||||
safeJsonStringify,
|
||||
unwrapKavenegarTransportError,
|
||||
} from "./kavenegar-response";
|
||||
import {
|
||||
SmsProviderException,
|
||||
SmsTransportException,
|
||||
} from "./sms-provider.exception";
|
||||
|
||||
export interface SendMessage {
|
||||
message: string;
|
||||
receptor: string;
|
||||
}
|
||||
|
||||
export interface VerifyLookUpMessage {
|
||||
template: string;
|
||||
token: string;
|
||||
token2?: string;
|
||||
token3?: string;
|
||||
receptor: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsGatewayService {
|
||||
private readonly logger = new Logger(SmsGatewayService.name);
|
||||
|
||||
constructor(private readonly sender: KavenegarService) {}
|
||||
|
||||
async sendMessage(data: SendMessage) {
|
||||
try {
|
||||
const body = await this.sender.Send({
|
||||
sender: "10008663",
|
||||
...data,
|
||||
});
|
||||
if (!isKavenegarSuccess(body)) {
|
||||
const normalized = normalizeKavenegarBody(body);
|
||||
this.logProviderRejection("send", data.receptor, undefined, normalized);
|
||||
throw new SmsProviderException("send", {
|
||||
receptor: data.receptor,
|
||||
providerBody: body,
|
||||
normalized,
|
||||
});
|
||||
}
|
||||
this.logger.log(
|
||||
`Kavenegar Send ok receptor=${data.receptor} status=200`,
|
||||
);
|
||||
return body;
|
||||
} catch (e) {
|
||||
if (e instanceof SmsProviderException) throw e;
|
||||
const detail = safeJsonStringify(unwrapKavenegarTransportError(e));
|
||||
this.logger.error(
|
||||
`Kavenegar Send transport error receptor=${data.receptor} detail=${detail}`,
|
||||
);
|
||||
throw new SmsTransportException("send", { receptor: data.receptor }, e);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyLookUp(data: VerifyLookUpMessage) {
|
||||
try {
|
||||
const body = await this.sender.verifyLookup(data);
|
||||
if (!isKavenegarSuccess(body)) {
|
||||
const normalized = normalizeKavenegarBody(body);
|
||||
this.logProviderRejection(
|
||||
"verifyLookUp",
|
||||
data.receptor,
|
||||
data.template,
|
||||
normalized,
|
||||
);
|
||||
throw new SmsProviderException("verifyLookup", {
|
||||
receptor: data.receptor,
|
||||
template: data.template,
|
||||
providerBody: body,
|
||||
normalized,
|
||||
});
|
||||
}
|
||||
this.logger.log(
|
||||
`Kavenegar verifyLookUp ok receptor=${data.receptor} template=${data.template} status=200`,
|
||||
);
|
||||
return body;
|
||||
} catch (e) {
|
||||
if (e instanceof SmsProviderException) throw e;
|
||||
const detail = safeJsonStringify(unwrapKavenegarTransportError(e));
|
||||
this.logger.error(
|
||||
`Kavenegar verifyLookUp transport error receptor=${data.receptor} template=${data.template} detail=${detail}`,
|
||||
);
|
||||
throw new SmsTransportException(
|
||||
"verifyLookup",
|
||||
{ receptor: data.receptor, template: data.template },
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private logProviderRejection(
|
||||
op: "send" | "verifyLookUp",
|
||||
receptor: string,
|
||||
template: string | undefined,
|
||||
normalized: KavenegarNormalized,
|
||||
) {
|
||||
const t = template != null && template !== "" ? ` template=${template}` : "";
|
||||
this.logger.warn(
|
||||
`Kavenegar ${op} rejected receptor=${receptor}${t} providerStatus=${normalized.httpLikeStatus ?? "unknown"} providerMessage=${normalized.message ?? "n/a"} body=${normalized.raw}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
69
src/sms-orchestration/provider/sms-provider.exception.ts
Normal file
69
src/sms-orchestration/provider/sms-provider.exception.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { HttpException, HttpStatus } from "@nestjs/common";
|
||||
import {
|
||||
KavenegarNormalized,
|
||||
safeJsonStringify,
|
||||
unwrapKavenegarTransportError,
|
||||
} from "./kavenegar-response";
|
||||
|
||||
export type SmsOperation = "verifyLookup" | "send";
|
||||
|
||||
export class SmsProviderException extends HttpException {
|
||||
constructor(
|
||||
public readonly operation: SmsOperation,
|
||||
public readonly meta: {
|
||||
receptor: string;
|
||||
template?: string;
|
||||
providerBody: unknown;
|
||||
normalized: KavenegarNormalized;
|
||||
},
|
||||
) {
|
||||
super(
|
||||
"SMS delivery could not be confirmed by the provider.",
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class SmsTransportException extends HttpException {
|
||||
constructor(
|
||||
public readonly operation: SmsOperation,
|
||||
public readonly meta: { receptor: string; template?: string },
|
||||
public readonly causeUnknown: unknown,
|
||||
) {
|
||||
super(
|
||||
"SMS service failed before a provider response was received.",
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Single-line detail for logs (provider vs transport vs unexpected). */
|
||||
export function describeSmsError(err: unknown): string {
|
||||
if (err instanceof SmsProviderException) {
|
||||
const n = err.meta.normalized;
|
||||
return [
|
||||
`SmsProviderException(${err.operation})`,
|
||||
`receptor=${err.meta.receptor}`,
|
||||
err.meta.template ? `template=${err.meta.template}` : "",
|
||||
`providerStatus=${n.httpLikeStatus ?? "unknown"}`,
|
||||
`providerMessage=${n.message ?? "n/a"}`,
|
||||
`body=${n.raw}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
if (err instanceof SmsTransportException) {
|
||||
return [
|
||||
`SmsTransportException(${err.operation})`,
|
||||
`receptor=${err.meta.receptor}`,
|
||||
err.meta.template ? `template=${err.meta.template}` : "",
|
||||
`detail=${safeJsonStringify(unwrapKavenegarTransportError(err.causeUnknown))}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return `${err.name}: ${err.message}`;
|
||||
}
|
||||
return safeJsonStringify(err);
|
||||
}
|
||||
Reference in New Issue
Block a user