forked from Yara724/api
merge upstream
This commit is contained in:
@@ -14,6 +14,7 @@ import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
|||||||
import { HashService } from "src/utils/hash/hash.service";
|
import { HashService } from "src/utils/hash/hash.service";
|
||||||
import { OtpService } from "src/utils/otp/otp.service";
|
import { OtpService } from "src/utils/otp/otp.service";
|
||||||
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
|
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
|
||||||
|
import { describeSmsError } from "src/utils/sms-manager/sms-provider.exception";
|
||||||
|
|
||||||
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -117,25 +118,26 @@ export class UserAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async smsSender(otp: string, mobile: string) {
|
private async smsSender(otp: string, mobile: string) {
|
||||||
return this.smsManagerService
|
try {
|
||||||
.verifyLookUp({
|
await this.smsManagerService.verifyLookUp({
|
||||||
token: otp,
|
token: otp,
|
||||||
template: process.env.AUTH_SMS_TEMPLATE,
|
template: process.env.AUTH_SMS_TEMPLATE,
|
||||||
receptor: mobile,
|
receptor: mobile,
|
||||||
})
|
|
||||||
.then((smsRes) => {
|
|
||||||
this.logger.log(
|
|
||||||
`${"phone : " + mobile + " " + ", status : " + smsRes["return"].status + ", otp : " + otp} `,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((er) => {
|
|
||||||
this.logger.error(
|
|
||||||
`${"phone : " + mobile + " " + ", status : " + er["return"].status + ", otp : " + otp} `,
|
|
||||||
);
|
|
||||||
throw new HttpException(
|
|
||||||
" auth sms send failed",
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Auth OTP SMS failed phone=${mobile} otp=${otp} ${describeSmsError(err)}`,
|
||||||
|
);
|
||||||
|
if (err instanceof HttpException) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new HttpException(
|
||||||
|
"auth sms send failed",
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts
Normal file
25
src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsNotEmpty,
|
||||||
|
} from "class-validator";
|
||||||
|
import { OuterCarPart } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
||||||
|
|
||||||
|
export class UpdateClaimDamagedPartsV2Dto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Updated list of selected damaged outer parts",
|
||||||
|
enum: OuterCarPart,
|
||||||
|
isArray: true,
|
||||||
|
example: ["hood", "front_bumper", "front_left_fender"],
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsEnum(OuterCarPart, { each: true })
|
||||||
|
selectedParts: OuterCarPart[];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/clai
|
|||||||
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
|
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
|
||||||
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
|
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
|
||||||
import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto";
|
import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto";
|
||||||
|
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
|
||||||
import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service";
|
import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service";
|
||||||
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
|
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
|
||||||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||||||
@@ -1969,4 +1970,77 @@ export class ExpertClaimService {
|
|||||||
updatedAt: (claim as any).updatedAt,
|
updatedAt: (claim as any).updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: Damage expert can modify selected damaged parts before final submit.
|
||||||
|
* Allowed only when claim is EXPERT_REVIEWING and locked by this expert.
|
||||||
|
*/
|
||||||
|
async updateClaimDamagedPartsV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
body: UpdateClaimDamagedPartsV2Dto,
|
||||||
|
actor: any,
|
||||||
|
) {
|
||||||
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (!claim) {
|
||||||
|
throw new NotFoundException("Claim request not found");
|
||||||
|
}
|
||||||
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!claim.workflow?.locked) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"You must lock the claim before modifying damaged parts",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
|
||||||
|
throw new ForbiddenException("This claim is locked by another expert");
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = Array.isArray((claim as any).damage?.selectedParts)
|
||||||
|
? [...(claim as any).damage.selectedParts]
|
||||||
|
: [];
|
||||||
|
const selectedParts = body.selectedParts as string[];
|
||||||
|
|
||||||
|
if (!(claim as any).damage) (claim as any).damage = {};
|
||||||
|
(claim as any).damage.selectedParts = selectedParts;
|
||||||
|
|
||||||
|
// Keep damaged-parts captures consistent with updated selection.
|
||||||
|
const damagedPartsMedia = (claim as any).media?.damagedParts;
|
||||||
|
if (damagedPartsMedia) {
|
||||||
|
const selectedSet = new Set(selectedParts);
|
||||||
|
if (damagedPartsMedia instanceof Map) {
|
||||||
|
for (const key of Array.from(damagedPartsMedia.keys())) {
|
||||||
|
if (!selectedSet.has(key)) damagedPartsMedia.delete(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const key of Object.keys(damagedPartsMedia)) {
|
||||||
|
if (!selectedSet.has(key)) delete damagedPartsMedia[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray((claim as any).history)) (claim as any).history = [];
|
||||||
|
(claim as any).history.push({
|
||||||
|
event: "EXPERT_DAMAGED_PARTS_UPDATED",
|
||||||
|
performedBy: actor.sub,
|
||||||
|
performedByName: actor.fullName,
|
||||||
|
performedAt: new Date(),
|
||||||
|
note: "Damage expert edited selected damaged parts",
|
||||||
|
metadata: {
|
||||||
|
previousSelectedParts: previous,
|
||||||
|
selectedParts,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await (claim as any).save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
claimRequestId: claim._id.toString(),
|
||||||
|
selectedParts,
|
||||||
|
previousSelectedParts: previous,
|
||||||
|
message: "Damaged parts updated successfully.",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CurrentUser } from "src/decorators/user.decorator";
|
|||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { ExpertClaimService } from "./expert-claim.service";
|
import { ExpertClaimService } from "./expert-claim.service";
|
||||||
import { SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
|
import { SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
|
||||||
|
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
|
||||||
|
|
||||||
class InPersonVisitV2Dto {
|
class InPersonVisitV2Dto {
|
||||||
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
|
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
|
||||||
@@ -107,4 +108,24 @@ export class ExpertClaimV2Controller {
|
|||||||
body?.note,
|
body?.note,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch("request/:claimRequestId/damaged-parts")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Edit selected damaged parts (V2)",
|
||||||
|
description:
|
||||||
|
"Damage expert can modify selected damaged parts while claim is in EXPERT_REVIEWING and locked by the same expert.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "claimRequestId" })
|
||||||
|
@ApiBody({ type: UpdateClaimDamagedPartsV2Dto })
|
||||||
|
async updateClaimDamagedPartsV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: UpdateClaimDamagedPartsV2Dto,
|
||||||
|
@CurrentUser() actor,
|
||||||
|
) {
|
||||||
|
return await this.expertClaimService.updateClaimDamagedPartsV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||||
import { LocationDto } from "./create-request-management.dto";
|
|
||||||
import { DescriptionDto } from "./create-request-management.dto";
|
import { DescriptionDto } from "./create-request-management.dto";
|
||||||
import { InitialFormDto, CarBodyFormDto } from "./create-request-management.dto";
|
import { InitialFormDto, CarBodyFormDto } from "./create-request-management.dto";
|
||||||
|
|
||||||
@@ -24,9 +23,6 @@ export class ExpertCompleteCarBodyFormDto {
|
|||||||
@ApiProperty({ type: AddPlateDto })
|
@ApiProperty({ type: AddPlateDto })
|
||||||
firstPartyPlate: AddPlateDto;
|
firstPartyPlate: AddPlateDto;
|
||||||
|
|
||||||
@ApiProperty({ type: LocationDto })
|
|
||||||
firstPartyLocation: LocationDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: DescriptionDto })
|
@ApiProperty({ type: DescriptionDto })
|
||||||
firstPartyDescription: DescriptionDto;
|
firstPartyDescription: DescriptionDto;
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/request-management/dto/expert-complete-locations.dto.ts
Normal file
11
src/request-management/dto/expert-complete-locations.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { LocationDto } from "./create-request-management.dto";
|
||||||
|
|
||||||
|
export class ExpertCompleteLocationsDto {
|
||||||
|
@ApiProperty({ type: LocationDto })
|
||||||
|
firstPartyLocation: LocationDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: LocationDto })
|
||||||
|
secondPartyLocation?: LocationDto;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||||
import { LocationDto } from "./create-request-management.dto";
|
|
||||||
import { DescriptionDto } from "./create-request-management.dto";
|
import { DescriptionDto } from "./create-request-management.dto";
|
||||||
import { InitialFormDto } from "./create-request-management.dto";
|
import { InitialFormDto } from "./create-request-management.dto";
|
||||||
|
|
||||||
@@ -20,9 +19,6 @@ export class ExpertSecondPartyInfoDto {
|
|||||||
@ApiProperty({ type: AddPlateDto })
|
@ApiProperty({ type: AddPlateDto })
|
||||||
plate: AddPlateDto;
|
plate: AddPlateDto;
|
||||||
|
|
||||||
@ApiProperty({ type: LocationDto })
|
|
||||||
location: LocationDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: DescriptionDto })
|
@ApiProperty({ type: DescriptionDto })
|
||||||
description: DescriptionDto;
|
description: DescriptionDto;
|
||||||
}
|
}
|
||||||
@@ -44,9 +40,6 @@ export class ExpertCompleteThirdPartyFormDto {
|
|||||||
@ApiProperty({ type: AddPlateDto })
|
@ApiProperty({ type: AddPlateDto })
|
||||||
firstPartyPlate: AddPlateDto;
|
firstPartyPlate: AddPlateDto;
|
||||||
|
|
||||||
@ApiProperty({ type: LocationDto })
|
|
||||||
firstPartyLocation: LocationDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: DescriptionDto })
|
@ApiProperty({ type: DescriptionDto })
|
||||||
firstPartyDescription: DescriptionDto;
|
firstPartyDescription: DescriptionDto;
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto
|
|||||||
import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto";
|
import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto";
|
||||||
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
|
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
|
||||||
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
|
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
|
||||||
|
import { ExpertCompleteLocationsDto } from "./dto/expert-complete-locations.dto";
|
||||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||||
import { PartyRole } from "./entities/schema/partyRole.enum";
|
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||||
|
|
||||||
@@ -224,7 +225,7 @@ export class ExpertInitiatedV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "[V2] Submit all blame data (IN_PERSON)",
|
summary: "[V2] Submit all blame data (IN_PERSON)",
|
||||||
description:
|
description:
|
||||||
"For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form. After this, status becomes WAITING_FOR_SIGNATURES; use upload-party-signature to collect party signatures.",
|
"For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form EXCEPT locations. After this, call add-locations to submit lat/lon and move workflow to WAITING_FOR_SIGNATURES.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
@@ -252,7 +253,6 @@ export class ExpertInitiatedV2Controller {
|
|||||||
insurerBirthday: 13770624,
|
insurerBirthday: 13770624,
|
||||||
driverBirthday: "1370-01-01",
|
driverBirthday: "1370-01-01",
|
||||||
},
|
},
|
||||||
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
|
|
||||||
firstPartyDescription: { desc: "توضیح حادثه طرف اول" },
|
firstPartyDescription: { desc: "توضیح حادثه طرف اول" },
|
||||||
secondParty: {
|
secondParty: {
|
||||||
phoneNumber: "09187654321",
|
phoneNumber: "09187654321",
|
||||||
@@ -273,7 +273,6 @@ export class ExpertInitiatedV2Controller {
|
|||||||
insurerBirthday: 13700720,
|
insurerBirthday: 13700720,
|
||||||
driverBirthday: "1370-01-01",
|
driverBirthday: "1370-01-01",
|
||||||
},
|
},
|
||||||
location: { lat: 35.6892, lon: 51.389 },
|
|
||||||
description: { desc: "توضیح حادثه طرف دوم" },
|
description: { desc: "توضیح حادثه طرف دوم" },
|
||||||
},
|
},
|
||||||
guiltyPartyPhoneNumber: "09123456789",
|
guiltyPartyPhoneNumber: "09123456789",
|
||||||
@@ -303,7 +302,6 @@ export class ExpertInitiatedV2Controller {
|
|||||||
insurerBirthday: 1370,
|
insurerBirthday: 1370,
|
||||||
driverBirthday: "1370-01-01",
|
driverBirthday: "1370-01-01",
|
||||||
},
|
},
|
||||||
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
|
|
||||||
firstPartyDescription: {
|
firstPartyDescription: {
|
||||||
desc: "توضیح حادثه",
|
desc: "توضیح حادثه",
|
||||||
accidentDate: "2025-01-15",
|
accidentDate: "2025-01-15",
|
||||||
@@ -317,7 +315,7 @@ export class ExpertInitiatedV2Controller {
|
|||||||
},
|
},
|
||||||
schema: { type: "object" },
|
schema: { type: "object" },
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: "Blame form completed; next: upload party signature(s)" })
|
@ApiResponse({ status: 200, description: "Blame form completed; next: add-locations" })
|
||||||
async completeBlameDataV2(
|
async completeBlameDataV2(
|
||||||
@CurrentUser() expert: any,
|
@CurrentUser() expert: any,
|
||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
@@ -330,6 +328,44 @@ export class ExpertInitiatedV2Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("add-locations/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Submit location(s) for expert-initiated IN_PERSON blame",
|
||||||
|
description:
|
||||||
|
"Submit first party (and second party for THIRD_PARTY) location after complete-blame-data. This transitions workflow to WAITING_FOR_SIGNATURES.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@ApiBody({
|
||||||
|
type: ExpertCompleteLocationsDto,
|
||||||
|
examples: {
|
||||||
|
THIRD_PARTY: {
|
||||||
|
summary: "THIRD_PARTY locations",
|
||||||
|
value: {
|
||||||
|
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
|
||||||
|
secondPartyLocation: { lat: 35.7001, lon: 51.4102 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CAR_BODY: {
|
||||||
|
summary: "CAR_BODY location",
|
||||||
|
value: {
|
||||||
|
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Locations saved; next: upload party signature(s)" })
|
||||||
|
async addLocationsV2(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() dto: ExpertCompleteLocationsDto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertAddLocationsForBlameV2(
|
||||||
|
expert,
|
||||||
|
requestId,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("upload-video/:requestId")
|
@Post("upload-video/:requestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "[V2] Expert uploads video for expert-initiated BlameRequest",
|
summary: "[V2] Expert uploads video for expert-initiated BlameRequest",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { ExpertDbService } from "src/users/entities/db-service/expert.db.service
|
|||||||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||||
import { AutoCloseRequestService } from "src/utils/cron/cron.service";
|
import { AutoCloseRequestService } from "src/utils/cron/cron.service";
|
||||||
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
|
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
|
||||||
|
import { describeSmsError } from "src/utils/sms-manager/sms-provider.exception";
|
||||||
import {
|
import {
|
||||||
DescriptionDto,
|
DescriptionDto,
|
||||||
LocationDto,
|
LocationDto,
|
||||||
@@ -1190,7 +1191,7 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[SMS] Failed to send invitation to ${secondPartyPhone}: ${err?.message || err}`,
|
`[SMS] Failed to send invitation to ${secondPartyPhone}: ${describeSmsError(err)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1247,7 +1248,7 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[SMS] Failed to send invitation to ${phoneNumber}: ${err?.message || err}`,
|
`[SMS] Failed to send invitation to ${phoneNumber}: ${describeSmsError(err)}`,
|
||||||
);
|
);
|
||||||
// Don't block the request flow if SMS fails
|
// Don't block the request flow if SMS fails
|
||||||
}
|
}
|
||||||
@@ -2170,14 +2171,18 @@ export class RequestManagementService {
|
|||||||
// Send the SMS after the database update is complete.
|
// Send the SMS after the database update is complete.
|
||||||
const URL = `${process.env.URL}/${frontendRoutes}?token=${requestId}`;
|
const URL = `${process.env.URL}/${frontendRoutes}?token=${requestId}`;
|
||||||
try {
|
try {
|
||||||
const smsRes = await this.smsManagerService.verifyLookUp({
|
await this.smsManagerService.verifyLookUp({
|
||||||
token: URL,
|
token: URL,
|
||||||
template: "yara724-invite-link",
|
template: "yara724-invite-link",
|
||||||
receptor: phoneNumber,
|
receptor: phoneNumber,
|
||||||
});
|
});
|
||||||
this.logger.log(smsRes);
|
this.logger.log(
|
||||||
|
`[SMS] Invite link verifyLookUp ok receptor=${phoneNumber}`,
|
||||||
|
);
|
||||||
} catch (er) {
|
} catch (er) {
|
||||||
this.logger.error("SMS sending failed:", er);
|
this.logger.error(
|
||||||
|
`[SMS] Invite link failed receptor=${phoneNumber}: ${describeSmsError(er)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { url: URL };
|
return { url: URL };
|
||||||
@@ -2708,10 +2713,16 @@ export class RequestManagementService {
|
|||||||
// TODO SMS Sending
|
// TODO SMS Sending
|
||||||
if (phoneNumberToNotify) {
|
if (phoneNumberToNotify) {
|
||||||
const message = `متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.`;
|
const message = `متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.`;
|
||||||
|
try {
|
||||||
await this.smsManagerService.sendMessage({
|
await this.smsManagerService.sendMessage({
|
||||||
receptor: phoneNumberToNotify,
|
receptor: phoneNumberToNotify,
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`[SMS] PartiesDisagree notify failed receptor=${phoneNumberToNotify}: ${describeSmsError(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2892,10 +2903,16 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
if (phoneNumber) {
|
if (phoneNumber) {
|
||||||
// TODO FIX SMS SENDING FOR PARTIES
|
// TODO FIX SMS SENDING FOR PARTIES
|
||||||
|
try {
|
||||||
await this.smsManagerService.sendMessage({
|
await this.smsManagerService.sendMessage({
|
||||||
receptor: phoneNumber,
|
receptor: phoneNumber,
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`[SMS] One-party-replied notify failed receptor=${phoneNumber}: ${describeSmsError(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.autoCloseRequestService.scheduleAutoClose({
|
await this.autoCloseRequestService.scheduleAutoClose({
|
||||||
@@ -4100,7 +4117,7 @@ export class RequestManagementService {
|
|||||||
roadCondition: formData.firstPartyDescription?.roadCondition,
|
roadCondition: formData.firstPartyDescription?.roadCondition,
|
||||||
lightCondition: formData.firstPartyDescription?.lightCondition,
|
lightCondition: formData.firstPartyDescription?.lightCondition,
|
||||||
},
|
},
|
||||||
location: formData.firstPartyLocation,
|
location: req.parties?.[0]?.location,
|
||||||
carBodyFirstForm: {
|
carBodyFirstForm: {
|
||||||
car: !!formData.carBodyForm.car,
|
car: !!formData.carBodyForm.car,
|
||||||
object: !!formData.carBodyForm.object,
|
object: !!formData.carBodyForm.object,
|
||||||
@@ -4110,20 +4127,19 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
req.parties = [firstParty];
|
req.parties = [firstParty];
|
||||||
req.workflow = {
|
req.workflow = {
|
||||||
currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
|
currentStep: WorkflowStep.FIRST_LOCATION as any,
|
||||||
nextStep: undefined,
|
nextStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
|
||||||
completedSteps: [
|
completedSteps: [
|
||||||
WorkflowStep.CREATED,
|
WorkflowStep.CREATED,
|
||||||
WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
|
WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
|
||||||
WorkflowStep.FIRST_VIDEO,
|
WorkflowStep.FIRST_VIDEO,
|
||||||
WorkflowStep.FIRST_INITIAL_FORM,
|
WorkflowStep.FIRST_INITIAL_FORM,
|
||||||
WorkflowStep.FIRST_LOCATION,
|
|
||||||
WorkflowStep.FIRST_VOICE,
|
WorkflowStep.FIRST_VOICE,
|
||||||
WorkflowStep.FIRST_DESCRIPTION,
|
WorkflowStep.FIRST_DESCRIPTION,
|
||||||
].filter((s) => s),
|
].filter((s) => s),
|
||||||
locked: false,
|
locked: false,
|
||||||
};
|
};
|
||||||
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
|
req.status = CaseStatus.OPEN;
|
||||||
req.blameStatus = BlameStatus.AGREED;
|
req.blameStatus = BlameStatus.AGREED;
|
||||||
(req as any).filledBy = FilledBy.EXPERT;
|
(req as any).filledBy = FilledBy.EXPERT;
|
||||||
|
|
||||||
@@ -4181,7 +4197,6 @@ export class RequestManagementService {
|
|||||||
userId: Types.ObjectId,
|
userId: Types.ObjectId,
|
||||||
initialForm: any,
|
initialForm: any,
|
||||||
plateDto: any,
|
plateDto: any,
|
||||||
locationDto: any,
|
|
||||||
desc: string,
|
desc: string,
|
||||||
role: PartyRole,
|
role: PartyRole,
|
||||||
) => {
|
) => {
|
||||||
@@ -4229,7 +4244,7 @@ export class RequestManagementService {
|
|||||||
financialCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment,
|
financialCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment,
|
||||||
},
|
},
|
||||||
statement: { description: desc },
|
statement: { description: desc },
|
||||||
location: locationDto,
|
location: undefined,
|
||||||
evidence: {},
|
evidence: {},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4239,7 +4254,6 @@ export class RequestManagementService {
|
|||||||
firstPartyUserId,
|
firstPartyUserId,
|
||||||
formData.firstPartyInitialForm,
|
formData.firstPartyInitialForm,
|
||||||
formData.firstPartyPlate,
|
formData.firstPartyPlate,
|
||||||
formData.firstPartyLocation,
|
|
||||||
formData.firstPartyDescription?.desc || "",
|
formData.firstPartyDescription?.desc || "",
|
||||||
PartyRole.FIRST,
|
PartyRole.FIRST,
|
||||||
);
|
);
|
||||||
@@ -4248,7 +4262,6 @@ export class RequestManagementService {
|
|||||||
secondPartyUserId,
|
secondPartyUserId,
|
||||||
formData.secondParty.initialForm,
|
formData.secondParty.initialForm,
|
||||||
formData.secondParty.plate,
|
formData.secondParty.plate,
|
||||||
formData.secondParty.location,
|
|
||||||
formData.secondParty.description?.desc || "",
|
formData.secondParty.description?.desc || "",
|
||||||
PartyRole.SECOND,
|
PartyRole.SECOND,
|
||||||
);
|
);
|
||||||
@@ -4261,24 +4274,22 @@ export class RequestManagementService {
|
|||||||
if (!req.expert) req.expert = {} as any;
|
if (!req.expert) req.expert = {} as any;
|
||||||
req.expert.decision = { guiltyPartyId: new Types.ObjectId(guiltyPartyId) } as any;
|
req.expert.decision = { guiltyPartyId: new Types.ObjectId(guiltyPartyId) } as any;
|
||||||
req.workflow = {
|
req.workflow = {
|
||||||
currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
|
currentStep: WorkflowStep.FIRST_LOCATION as any,
|
||||||
nextStep: undefined,
|
nextStep: WorkflowStep.SECOND_LOCATION as any,
|
||||||
completedSteps: [
|
completedSteps: [
|
||||||
WorkflowStep.CREATED,
|
WorkflowStep.CREATED,
|
||||||
WorkflowStep.FIRST_BLAME_CONFESSION,
|
WorkflowStep.FIRST_BLAME_CONFESSION,
|
||||||
WorkflowStep.FIRST_VIDEO,
|
WorkflowStep.FIRST_VIDEO,
|
||||||
WorkflowStep.FIRST_INITIAL_FORM,
|
WorkflowStep.FIRST_INITIAL_FORM,
|
||||||
WorkflowStep.FIRST_LOCATION,
|
|
||||||
WorkflowStep.FIRST_VOICE,
|
WorkflowStep.FIRST_VOICE,
|
||||||
WorkflowStep.FIRST_DESCRIPTION,
|
WorkflowStep.FIRST_DESCRIPTION,
|
||||||
WorkflowStep.SECOND_INITIAL_FORM,
|
WorkflowStep.SECOND_INITIAL_FORM,
|
||||||
WorkflowStep.SECOND_LOCATION,
|
|
||||||
WorkflowStep.SECOND_VOICE,
|
WorkflowStep.SECOND_VOICE,
|
||||||
WorkflowStep.SECOND_DESCRIPTION,
|
WorkflowStep.SECOND_DESCRIPTION,
|
||||||
].filter((s) => s),
|
].filter((s) => s),
|
||||||
locked: false,
|
locked: false,
|
||||||
};
|
};
|
||||||
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
|
req.status = CaseStatus.OPEN;
|
||||||
req.blameStatus = BlameStatus.AGREED;
|
req.blameStatus = BlameStatus.AGREED;
|
||||||
(req as any).filledBy = FilledBy.EXPERT;
|
(req as any).filledBy = FilledBy.EXPERT;
|
||||||
|
|
||||||
@@ -4302,6 +4313,90 @@ export class RequestManagementService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: Expert submits location(s) for expert-initiated IN_PERSON blame.
|
||||||
|
* This is separated from complete-blame-data to support dedicated location UI step.
|
||||||
|
*/
|
||||||
|
async expertAddLocationsForBlameV2(
|
||||||
|
expert: any,
|
||||||
|
requestId: string,
|
||||||
|
dto: { firstPartyLocation: LocationDto; secondPartyLocation?: LocationDto },
|
||||||
|
): Promise<any> {
|
||||||
|
const req = await this.blameRequestDbService.findById(requestId);
|
||||||
|
if (!req) throw new NotFoundException("Request not found");
|
||||||
|
if (
|
||||||
|
(!req.expertInitiated && !req.registrarInitiated) ||
|
||||||
|
req.creationMethod !== CreationMethod.IN_PERSON
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.verifyExpertAccessForBlameV2(req, expert);
|
||||||
|
|
||||||
|
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||||
|
if (firstIdx === -1) throw new BadRequestException("First party not found");
|
||||||
|
if (!dto?.firstPartyLocation) {
|
||||||
|
throw new BadRequestException("firstPartyLocation is required");
|
||||||
|
}
|
||||||
|
req.parties[firstIdx].location = dto.firstPartyLocation as any;
|
||||||
|
|
||||||
|
if (req.type === BlameRequestType.THIRD_PARTY) {
|
||||||
|
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
|
||||||
|
if (secondIdx === -1) throw new BadRequestException("Second party not found");
|
||||||
|
if (!dto?.secondPartyLocation) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"secondPartyLocation is required for THIRD_PARTY",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
req.parties[secondIdx].location = dto.secondPartyLocation as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = Array.isArray(req.workflow?.completedSteps)
|
||||||
|
? req.workflow.completedSteps
|
||||||
|
: [];
|
||||||
|
if (!completed.includes(WorkflowStep.FIRST_LOCATION as any)) {
|
||||||
|
completed.push(WorkflowStep.FIRST_LOCATION as any);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
req.type === BlameRequestType.THIRD_PARTY &&
|
||||||
|
!completed.includes(WorkflowStep.SECOND_LOCATION as any)
|
||||||
|
) {
|
||||||
|
completed.push(WorkflowStep.SECOND_LOCATION as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.workflow = {
|
||||||
|
...(req.workflow || {}),
|
||||||
|
currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
|
||||||
|
nextStep: undefined,
|
||||||
|
completedSteps: completed,
|
||||||
|
locked: false,
|
||||||
|
} as any;
|
||||||
|
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
|
||||||
|
|
||||||
|
if (!Array.isArray(req.history)) req.history = [];
|
||||||
|
req.history.push({
|
||||||
|
type: "EXPERT_ADDED_LOCATIONS_V2",
|
||||||
|
actor: {
|
||||||
|
actorId: new Types.ObjectId(expert.sub),
|
||||||
|
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||||||
|
actorType: expert?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
hasFirstLocation: true,
|
||||||
|
hasSecondLocation: req.type === BlameRequestType.THIRD_PARTY,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await (req as any).save();
|
||||||
|
return {
|
||||||
|
requestId: req._id,
|
||||||
|
publicId: req.publicId,
|
||||||
|
workflow: req.workflow,
|
||||||
|
status: req.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Expert uploads video for expert-initiated BlameRequest (first party).
|
* V2: Expert uploads video for expert-initiated BlameRequest (first party).
|
||||||
*/
|
*/
|
||||||
|
|||||||
78
src/utils/sms-manager/kavenegar-response.ts
Normal file
78
src/utils/sms-manager/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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
import { KavenegarService } from "@fraybabak/kavenegar_nest";
|
import { KavenegarService } from "@fraybabak/kavenegar_nest";
|
||||||
import { Injectable } from "@nestjs/common";
|
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 {
|
export interface SendMessage {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -15,16 +26,84 @@ export interface VerifyLookUpMessage {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsManagerService {
|
export class SmsManagerService {
|
||||||
|
private readonly logger = new Logger(SmsManagerService.name);
|
||||||
|
|
||||||
constructor(private readonly sender: KavenegarService) {}
|
constructor(private readonly sender: KavenegarService) {}
|
||||||
|
|
||||||
async sendMessage(data: SendMessage) {
|
async sendMessage(data: SendMessage) {
|
||||||
return await this.sender.Send({
|
try {
|
||||||
|
const body = await this.sender.Send({
|
||||||
sender: "10008663",
|
sender: "10008663",
|
||||||
...data,
|
...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) {
|
async verifyLookUp(data: VerifyLookUpMessage) {
|
||||||
return await this.sender.verifyLookup(data);
|
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/utils/sms-manager/sms-provider.exception.ts
Normal file
69
src/utils/sms-manager/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