1
0
forked from Yara724/api

Centralized SMS services YARA-834

This commit is contained in:
SepehrYahyaee
2026-04-22 15:02:08 +03:30
parent f01882dc3a
commit d9b1537ee4
16 changed files with 311 additions and 138 deletions

View File

@@ -0,0 +1,24 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { FilterQuery, Model } from "mongoose";
import { SmsText, SmsTextDocument } from "../schema/sms-text.schema";
@Injectable()
export class SmsTextDbService {
constructor(
@InjectModel(SmsText.name)
private readonly smsTextModel: Model<SmsTextDocument>,
) {}
async findOne(filter: FilterQuery<SmsText>): Promise<SmsTextDocument | null> {
return this.smsTextModel.findOne(filter);
}
async upsert(key: string, text: string): Promise<void> {
await this.smsTextModel.updateOne(
{ key },
{ $setOnInsert: { key, text, active: true } },
{ upsert: true },
);
}
}

View File

@@ -0,0 +1,17 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose";
@Schema({ collection: "sms-texts", timestamps: true, versionKey: false })
export class SmsText {
@Prop({ required: true, unique: true, index: true })
key: string;
@Prop({ required: true })
text: string;
@Prop({ default: true })
active: boolean;
}
export type SmsTextDocument = HydratedDocument<SmsText>;
export const SmsTextSchema = SchemaFactory.createForClass(SmsText);

View 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;
}

View 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 {}

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

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

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { SmsTextDbService } from "./entities/db-service/sms-text.db.service";
import { SmsText, SmsTextSchema } from "./entities/schema/sms-text.schema";
import { SmsGatewayModule } from "./provider/sms-gateway.module";
import { SmsOrchestrationService } from "./sms-orchestration.service";
@Module({
imports: [
SmsGatewayModule,
MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]),
],
providers: [SmsTextDbService, SmsOrchestrationService],
exports: [SmsOrchestrationService],
})
export class SmsOrchestrationModule {}

View File

@@ -0,0 +1,111 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { SmsTextDbService } from "./entities/db-service/sms-text.db.service";
import { SmsGatewayService } from "./provider/sms-gateway.service";
import { describeSmsError } from "./provider/sms-provider.exception";
type TemplateArgs = {
template: string;
receptor: string;
token: string;
token2?: string;
token3?: string;
};
@Injectable()
export class SmsOrchestrationService implements OnModuleInit {
private readonly logger = new Logger(SmsOrchestrationService.name);
constructor(
private readonly smsGatewayService: SmsGatewayService,
private readonly smsTextDbService: SmsTextDbService,
) {}
async onModuleInit(): Promise<void> {
await this.smsTextDbService.upsert(
"parties_disagree_notify",
"متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.",
);
await this.smsTextDbService.upsert(
"one_party_signed_wait_signature",
"طرف مقابل توافق را امضا کرده است. لطفا جهت نهایی کردن پرونده، امضای خود را ثبت نمایید.",
);
await this.smsTextDbService.upsert(
"one_party_accepted_wait_24h",
"طرف مقابل نظر کارشناس را قبول و امضا کرده است. در طی 24 ساعت آینده فرصت دارید تا پرونده را تکمیل کرده یا پرونده بسته خواهد شد.",
);
}
buildInviteLink(frontendRoute: string, requestId: string): string {
return `${process.env.URL}/${frontendRoute}?token=${requestId}`;
}
async sendInviteLink(phoneNumber: string, link: string): Promise<boolean> {
return this.sendTemplate({
template: "yara724-invite-link",
receptor: phoneNumber,
token: link,
});
}
async sendAuthOtp(
mobile: string,
otp: string,
template?: string,
): Promise<boolean> {
return this.sendTemplate({
template: template || process.env.AUTH_SMS_TEMPLATE || "",
receptor: mobile,
token: otp,
});
}
async sendFieldExpertLink(params: {
receptor: string;
type: BlameRequestType;
expertLastName: string;
link: string;
}): Promise<boolean> {
return this.sendTemplate({
template: "yara-field-expert-link",
receptor: params.receptor,
token: params.type === BlameRequestType.CAR_BODY ? "بدنه" : "ثالث",
token2: params.expertLastName || "کارشناس",
token3: params.link,
});
}
async sendTextByKey(
receptor: string,
key: string,
fallbackText: string,
): Promise<boolean> {
const row = await this.smsTextDbService.findOne({ key, active: true });
const message = row?.text?.trim() || fallbackText;
try {
await this.smsGatewayService.sendMessage({ receptor, message });
this.logger.log(`[SMS] text sent key=${key} receptor=${receptor}`);
return true;
} catch (err) {
this.logger.error(
`[SMS] text send failed key=${key} receptor=${receptor}: ${describeSmsError(err)}`,
);
return false;
}
}
private async sendTemplate(args: TemplateArgs): Promise<boolean> {
try {
await this.smsGatewayService.verifyLookUp(args);
this.logger.log(
`[SMS] template sent template=${args.template} receptor=${args.receptor}`,
);
return true;
} catch (err) {
this.logger.error(
`[SMS] template send failed template=${args.template} receptor=${args.receptor}: ${describeSmsError(err)}`,
);
return false;
}
}
}