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

49
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@nestjs-modules/mailer": "^1.8.1",
"@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "*",
@@ -4651,6 +4652,39 @@
}
}
},
"node_modules/@nestjs/config": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz",
"integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==",
"license": "MIT",
"dependencies": {
"dotenv": "17.4.1",
"dotenv-expand": "12.0.3",
"lodash": "4.18.1"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/config/node_modules/dotenv": {
"version": "17.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@nestjs/config/node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/@nestjs/core": {
"version": "10.4.15",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.15.tgz",
@@ -8441,6 +8475,21 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {
"version": "12.0.3",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
"integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@@ -25,6 +25,7 @@
"@nestjs-modules/mailer": "^1.8.1",
"@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "*",

View File

@@ -13,8 +13,7 @@ import { LoginDtoRs } from "src/auth/dto/user/login.dto";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { HashService } from "src/utils/hash/hash.service";
import { OtpService } from "src/utils/otp/otp.service";
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
import { describeSmsError } from "src/utils/sms-manager/sms-provider.exception";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
@Injectable()
@@ -26,7 +25,7 @@ export class UserAuthService {
private readonly userDbService: UserDbService,
private readonly hashService: HashService,
private readonly otpCreator: OtpService,
private readonly smsManagerService: SmsManagerService,
private readonly smsOrchestrationService: SmsOrchestrationService,
) {}
async validateUser(username: string, pass: string): Promise<any> {
@@ -118,26 +117,19 @@ export class UserAuthService {
}
private async smsSender(otp: string, mobile: string) {
try {
await this.smsManagerService.verifyLookUp({
token: otp,
template: process.env.AUTH_SMS_TEMPLATE,
receptor: mobile,
});
this.logger.log(
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
const ok = await this.smsOrchestrationService.sendAuthOtp(
mobile,
otp,
process.env.AUTH_SMS_TEMPLATE,
);
} catch (err) {
this.logger.error(
`Auth OTP SMS failed phone=${mobile} otp=${otp} ${describeSmsError(err)}`,
);
if (err instanceof HttpException) {
throw err;
}
if (!ok) {
throw new HttpException(
"auth sms send failed",
HttpStatus.BAD_GATEWAY,
);
}
this.logger.log(
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
);
}
}

View File

@@ -12,7 +12,7 @@ import { UsersModule } from "src/users/users.module";
import { HashModule } from "src/utils/hash/hash.module";
// import { MailModule } from "src/utils/mail/mail.module";
import { OtpModule } from "src/utils/otp/otp.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
@Module({
imports: [
@@ -22,7 +22,7 @@ import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
HashModule,
OtpModule,
PassportModule,
SmsManagerModule,
SmsOrchestrationModule,
JwtModule.register({
signOptions: { expiresIn: "1h" },
global: true,

View File

@@ -35,9 +35,14 @@ export class ResendRequestDto {
description: "Array of resend requests (can include first party, second party, or both)",
example: [
{
partyId: "507f1f77bcf86cd799439011",
requestedItems: ["drivingLicense", "carCertificate"],
description: "Please resend with better quality",
partyId: "681b3b3fb237e5856429e444",
requestedItems: ["drivingLicense"],
description: "DESC TEST1",
},
{
partyId: "68b6ba552a6e897df34e0f90",
requestedItems: ["carCertificate"],
description: "DESC TEST2",
},
],
})

View File

@@ -10,9 +10,9 @@ import { BlameRequestDbService } from "src/request-management/entities/db-servic
import { SandHubModule } from "src/sand-hub/sand-hub.module";
import { UsersModule } from "src/users/users.module";
import { CronModule } from "src/utils/cron/cron.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
import { PublicIdModule } from "src/utils/public-id/public-id.module";
import { HashModule } from "src/utils/hash/hash.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
import { AuthModule } from "src/auth/auth.module";
import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service";
@@ -45,7 +45,7 @@ import { RegistrarInitiatedController } from "./registrar-initiated.controller";
UsersModule,
ClientModule,
SandHubModule,
SmsManagerModule,
SmsOrchestrationModule,
PublicIdModule,
HashModule,
WorkflowStepManagementModule,

View File

@@ -39,8 +39,7 @@ import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { AutoCloseRequestService } from "src/utils/cron/cron.service";
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
import { describeSmsError } from "src/utils/sms-manager/sms-provider.exception";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import {
DescriptionDto,
LocationDto,
@@ -308,7 +307,7 @@ export class RequestManagementService {
private readonly clientService: ClientService,
private readonly blameVoiceDbService: BlameVoiceDbService,
private readonly userSign: UserSignDbService,
private readonly smsManagerService: SmsManagerService,
private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertDbService: ExpertDbService,
private readonly autoCloseRequestService: AutoCloseRequestService,
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
@@ -1305,21 +1304,14 @@ export class RequestManagementService {
metadata: { secondPartyPhone, expertInitiatedLink: true },
} as any);
await (req as any).save();
const url = `${process.env.URL}/${frontendRoute}?token=${requestId}`;
try {
await this.smsManagerService.verifyLookUp({
token: url,
template: "yara724-invite-link",
receptor: secondPartyPhone,
});
this.logger.log(
`[SMS] Invitation resent to ${secondPartyPhone} for expert-initiated request ${req.publicId}`,
const url = this.smsOrchestrationService.buildInviteLink(
frontendRoute,
requestId,
);
} catch (err) {
this.logger.error(
`[SMS] Failed to send invitation to ${secondPartyPhone}: ${describeSmsError(err)}`,
await this.smsOrchestrationService.sendInviteLink(
secondPartyPhone,
url,
);
}
return {
requestId: req._id,
publicId: req.publicId,
@@ -1362,22 +1354,11 @@ export class RequestManagementService {
await (req as any).save();
// Send SMS invitation
const url = `${process.env.URL}/${frontendRoute}?token=${requestId}`;
try {
await this.smsManagerService.verifyLookUp({
token: url,
template: "yara724-invite-link",
receptor: phoneNumber,
});
this.logger.log(
`[SMS] Invitation sent to ${phoneNumber} for request ${req.publicId}`,
const url = this.smsOrchestrationService.buildInviteLink(
frontendRoute,
requestId,
);
} catch (err) {
this.logger.error(
`[SMS] Failed to send invitation to ${phoneNumber}: ${describeSmsError(err)}`,
);
// Don't block the request flow if SMS fails
}
await this.smsOrchestrationService.sendInviteLink(phoneNumber, url);
return {
requestId: req._id,
@@ -2295,21 +2276,11 @@ export class RequestManagementService {
}
// Send the SMS after the database update is complete.
const URL = `${process.env.URL}/${frontendRoutes}?token=${requestId}`;
try {
await this.smsManagerService.verifyLookUp({
token: URL,
template: "yara724-invite-link",
receptor: phoneNumber,
});
this.logger.log(
`[SMS] Invite link verifyLookUp ok receptor=${phoneNumber}`,
const URL = this.smsOrchestrationService.buildInviteLink(
frontendRoutes,
requestId,
);
} catch (er) {
this.logger.error(
`[SMS] Invite link failed receptor=${phoneNumber}: ${describeSmsError(er)}`,
);
}
await this.smsOrchestrationService.sendInviteLink(phoneNumber, URL);
return { url: URL };
}
@@ -2838,19 +2809,13 @@ export class RequestManagementService {
// TODO SMS Sending
if (phoneNumberToNotify) {
const message = `متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.`;
try {
await this.smsManagerService.sendMessage({
receptor: phoneNumberToNotify,
message,
});
} catch (err) {
this.logger.error(
`[SMS] PartiesDisagree notify failed receptor=${phoneNumberToNotify}: ${describeSmsError(err)}`,
await this.smsOrchestrationService.sendTextByKey(
phoneNumberToNotify,
"parties_disagree_notify",
"متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.",
);
}
}
}
private async handlePostReplyStateChanges(
updatedReq: any,
@@ -3028,18 +2993,16 @@ export class RequestManagementService {
: originalReq.firstPartyDetails?.firstPartyPhoneNumber;
if (phoneNumber) {
// TODO FIX SMS SENDING FOR PARTIES
try {
await this.smsManagerService.sendMessage({
receptor: phoneNumber,
const key =
message.includes("24 ساعت")
? "one_party_accepted_wait_24h"
: "one_party_signed_wait_signature";
await this.smsOrchestrationService.sendTextByKey(
phoneNumber,
key,
message,
});
} catch (err) {
this.logger.error(
`[SMS] One-party-replied notify failed receptor=${phoneNumber}: ${describeSmsError(err)}`,
);
}
}
await this.autoCloseRequestService.scheduleAutoClose({
requestId: updatedReq._id.toString(),
@@ -3764,33 +3727,21 @@ export class RequestManagementService {
"URL environment variable is not configured",
);
}
const linkUrl = `${process.env.URL}/${dto.frontendRoute}?token=${requestId}`;
const typeToken =
req.type === BlameRequestType.CAR_BODY ? "بدنه" : "ثالث";
const linkUrl = this.smsOrchestrationService.buildInviteLink(
dto.frontendRoute,
requestId,
);
const expertName =
`${expert?.lastName || ""}`.trim() || "کارشناس";
const sentTo: { role: string; phoneNumber: string; smsSent: boolean }[] = [];
const template = "yara-field-expert-link";
const sendSms = async (phone: string, role: PartyRole): Promise<boolean> => {
try {
await this.smsManagerService.verifyLookUp({
template,
return this.smsOrchestrationService.sendFieldExpertLink({
receptor: phone,
token: typeToken,
token2: expertName,
token3: linkUrl,
type: req.type,
expertLastName: expertName,
link: linkUrl,
});
this.logger.log(
`[SMS] Field-expert link sent role=${role} receptor=${phone} template=${template}`,
);
return true;
} catch (er) {
this.logger.error(
`[SMS] Field-expert link failed role=${role} receptor=${phone}: ${describeSmsError(er)}`,
);
return false;
}
};
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
@@ -3816,7 +3767,12 @@ export class RequestManagementService {
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: { linkUrl, sentTo, template, typeToken, frontendRoute: dto.frontendRoute },
metadata: {
linkUrl,
sentTo,
template: "yara-field-expert-link",
frontendRoute: dto.frontendRoute,
},
});
await (req as any).save();

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,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

@@ -14,7 +14,6 @@ import {
export interface SendMessage {
message: string;
// sender: string;
receptor: string;
}
@@ -27,8 +26,8 @@ export interface VerifyLookUpMessage {
}
@Injectable()
export class SmsManagerService {
private readonly logger = new Logger(SmsManagerService.name);
export class SmsGatewayService {
private readonly logger = new Logger(SmsGatewayService.name);
constructor(private readonly sender: KavenegarService) {}

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

View File

@@ -1,15 +0,0 @@
import { KavenegarModule } from "@fraybabak/kavenegar_nest";
import { Module } from "@nestjs/common";
import { SmsManagerService } from "./sms-manager.service";
@Module({
imports: [
KavenegarModule.forRoot({
apikey:
"75776C717969412B4B52306A5956462F4A714E6F6C65544D6A2B654B7566786E",
}),
],
providers: [SmsManagerService],
exports: [SmsManagerService],
})
export class SmsManagerModule {}