diff --git a/src/auth/auth-services/user.auth.service.ts b/src/auth/auth-services/user.auth.service.ts index 542a959..77f1e39 100644 --- a/src/auth/auth-services/user.auth.service.ts +++ b/src/auth/auth-services/user.auth.service.ts @@ -14,6 +14,7 @@ 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"; // TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE @Injectable() @@ -117,25 +118,26 @@ export class UserAuthService { } private async smsSender(otp: string, mobile: string) { - return this.smsManagerService - .verifyLookUp({ + try { + await this.smsManagerService.verifyLookUp({ token: otp, template: process.env.AUTH_SMS_TEMPLATE, 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, + ); + } } } diff --git a/src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts b/src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts new file mode 100644 index 0000000..1e01288 --- /dev/null +++ b/src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts @@ -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[]; +} + diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 530b936..210954b 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -36,6 +36,7 @@ import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/clai import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto"; import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.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 { 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"; @@ -1969,4 +1970,77 @@ export class ExpertClaimService { 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.", + }; + } } diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index ca146a9..cbc6c57 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -15,6 +15,7 @@ import { CurrentUser } from "src/decorators/user.decorator"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { ExpertClaimService } from "./expert-claim.service"; import { SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto"; +import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto"; class InPersonVisitV2Dto { @ApiPropertyOptional({ example: 'Paint damage requires physical inspection' }) @@ -107,4 +108,24 @@ export class ExpertClaimV2Controller { 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, + ); + } } diff --git a/src/request-management/dto/expert-complete-car-body-form.dto.ts b/src/request-management/dto/expert-complete-car-body-form.dto.ts index 9bebef5..a05bf36 100644 --- a/src/request-management/dto/expert-complete-car-body-form.dto.ts +++ b/src/request-management/dto/expert-complete-car-body-form.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; -import { LocationDto } from "./create-request-management.dto"; import { DescriptionDto } from "./create-request-management.dto"; import { InitialFormDto, CarBodyFormDto } from "./create-request-management.dto"; @@ -24,9 +23,6 @@ export class ExpertCompleteCarBodyFormDto { @ApiProperty({ type: AddPlateDto }) firstPartyPlate: AddPlateDto; - @ApiProperty({ type: LocationDto }) - firstPartyLocation: LocationDto; - @ApiProperty({ type: DescriptionDto }) firstPartyDescription: DescriptionDto; } diff --git a/src/request-management/dto/expert-complete-locations.dto.ts b/src/request-management/dto/expert-complete-locations.dto.ts new file mode 100644 index 0000000..6dcd28c --- /dev/null +++ b/src/request-management/dto/expert-complete-locations.dto.ts @@ -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; +} + diff --git a/src/request-management/dto/expert-complete-third-party-form.dto.ts b/src/request-management/dto/expert-complete-third-party-form.dto.ts index 8957f82..1c5ad02 100644 --- a/src/request-management/dto/expert-complete-third-party-form.dto.ts +++ b/src/request-management/dto/expert-complete-third-party-form.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; -import { LocationDto } from "./create-request-management.dto"; import { DescriptionDto } from "./create-request-management.dto"; import { InitialFormDto } from "./create-request-management.dto"; @@ -20,9 +19,6 @@ export class ExpertSecondPartyInfoDto { @ApiProperty({ type: AddPlateDto }) plate: AddPlateDto; - @ApiProperty({ type: LocationDto }) - location: LocationDto; - @ApiProperty({ type: DescriptionDto }) description: DescriptionDto; } @@ -44,9 +40,6 @@ export class ExpertCompleteThirdPartyFormDto { @ApiProperty({ type: AddPlateDto }) firstPartyPlate: AddPlateDto; - @ApiProperty({ type: LocationDto }) - firstPartyLocation: LocationDto; - @ApiProperty({ type: DescriptionDto }) firstPartyDescription: DescriptionDto; diff --git a/src/request-management/expert-initiated.v2.controller.ts b/src/request-management/expert-initiated.v2.controller.ts index 4b7a392..060915b 100644 --- a/src/request-management/expert-initiated.v2.controller.ts +++ b/src/request-management/expert-initiated.v2.controller.ts @@ -34,6 +34,7 @@ import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto"; import { VerifyPartyOtpsDto } from "./dto/verify-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 { PartyRole } from "./entities/schema/partyRole.enum"; @@ -224,7 +225,7 @@ export class ExpertInitiatedV2Controller { @ApiOperation({ summary: "[V2] Submit all blame data (IN_PERSON)", 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" }) @ApiBody({ @@ -252,7 +253,6 @@ export class ExpertInitiatedV2Controller { insurerBirthday: 13770624, driverBirthday: "1370-01-01", }, - firstPartyLocation: { lat: 35.6892, lon: 51.389 }, firstPartyDescription: { desc: "توضیح حادثه طرف اول" }, secondParty: { phoneNumber: "09187654321", @@ -273,7 +273,6 @@ export class ExpertInitiatedV2Controller { insurerBirthday: 13700720, driverBirthday: "1370-01-01", }, - location: { lat: 35.6892, lon: 51.389 }, description: { desc: "توضیح حادثه طرف دوم" }, }, guiltyPartyPhoneNumber: "09123456789", @@ -303,7 +302,6 @@ export class ExpertInitiatedV2Controller { insurerBirthday: 1370, driverBirthday: "1370-01-01", }, - firstPartyLocation: { lat: 35.6892, lon: 51.389 }, firstPartyDescription: { desc: "توضیح حادثه", accidentDate: "2025-01-15", @@ -317,7 +315,7 @@ export class ExpertInitiatedV2Controller { }, 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( @CurrentUser() expert: any, @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") @ApiOperation({ summary: "[V2] Expert uploads video for expert-initiated BlameRequest", diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index bb59f06..5bdd838 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -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 { 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 { DescriptionDto, LocationDto, @@ -1190,7 +1191,7 @@ export class RequestManagementService { ); } catch (err) { this.logger.error( - `[SMS] Failed to send invitation to ${secondPartyPhone}: ${err?.message || err}`, + `[SMS] Failed to send invitation to ${secondPartyPhone}: ${describeSmsError(err)}`, ); } return { @@ -1247,7 +1248,7 @@ export class RequestManagementService { ); } catch (err) { 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 } @@ -2170,14 +2171,18 @@ export class RequestManagementService { // Send the SMS after the database update is complete. const URL = `${process.env.URL}/${frontendRoutes}?token=${requestId}`; try { - const smsRes = await this.smsManagerService.verifyLookUp({ + await this.smsManagerService.verifyLookUp({ token: URL, template: "yara724-invite-link", receptor: phoneNumber, }); - this.logger.log(smsRes); + this.logger.log( + `[SMS] Invite link verifyLookUp ok receptor=${phoneNumber}`, + ); } catch (er) { - this.logger.error("SMS sending failed:", er); + this.logger.error( + `[SMS] Invite link failed receptor=${phoneNumber}: ${describeSmsError(er)}`, + ); } return { url: URL }; @@ -2708,10 +2713,16 @@ export class RequestManagementService { // TODO SMS Sending if (phoneNumberToNotify) { const message = `متاسفانه طرف مقابل با نظر کارشناس مخالفت کرد. فرآیند آنلاین این پرونده بسته شده است و جهت پیگیری حضوری اقدام نمایید.`; - await this.smsManagerService.sendMessage({ - receptor: phoneNumberToNotify, - message, - }); + try { + await this.smsManagerService.sendMessage({ + receptor: phoneNumberToNotify, + message, + }); + } catch (err) { + this.logger.error( + `[SMS] PartiesDisagree notify failed receptor=${phoneNumberToNotify}: ${describeSmsError(err)}`, + ); + } } } @@ -2892,10 +2903,16 @@ export class RequestManagementService { if (phoneNumber) { // TODO FIX SMS SENDING FOR PARTIES - await this.smsManagerService.sendMessage({ - receptor: phoneNumber, - message, - }); + try { + await this.smsManagerService.sendMessage({ + receptor: phoneNumber, + message, + }); + } catch (err) { + this.logger.error( + `[SMS] One-party-replied notify failed receptor=${phoneNumber}: ${describeSmsError(err)}`, + ); + } } await this.autoCloseRequestService.scheduleAutoClose({ @@ -4100,7 +4117,7 @@ export class RequestManagementService { roadCondition: formData.firstPartyDescription?.roadCondition, lightCondition: formData.firstPartyDescription?.lightCondition, }, - location: formData.firstPartyLocation, + location: req.parties?.[0]?.location, carBodyFirstForm: { car: !!formData.carBodyForm.car, object: !!formData.carBodyForm.object, @@ -4110,20 +4127,19 @@ export class RequestManagementService { req.parties = [firstParty]; req.workflow = { - currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any, - nextStep: undefined, + currentStep: WorkflowStep.FIRST_LOCATION as any, + nextStep: WorkflowStep.WAITING_FOR_SIGNATURES as any, completedSteps: [ WorkflowStep.CREATED, WorkflowStep.CAR_BODY_ACCIDENT_TYPE, WorkflowStep.FIRST_VIDEO, WorkflowStep.FIRST_INITIAL_FORM, - WorkflowStep.FIRST_LOCATION, WorkflowStep.FIRST_VOICE, WorkflowStep.FIRST_DESCRIPTION, ].filter((s) => s), locked: false, }; - req.status = CaseStatus.WAITING_FOR_SIGNATURES; + req.status = CaseStatus.OPEN; req.blameStatus = BlameStatus.AGREED; (req as any).filledBy = FilledBy.EXPERT; @@ -4181,7 +4197,6 @@ export class RequestManagementService { userId: Types.ObjectId, initialForm: any, plateDto: any, - locationDto: any, desc: string, role: PartyRole, ) => { @@ -4229,7 +4244,7 @@ export class RequestManagementService { financialCeiling: sandHubReport?.FinancialCvrCptl || sandHubReport?.FinancialCommitment, }, statement: { description: desc }, - location: locationDto, + location: undefined, evidence: {}, }; }; @@ -4239,7 +4254,6 @@ export class RequestManagementService { firstPartyUserId, formData.firstPartyInitialForm, formData.firstPartyPlate, - formData.firstPartyLocation, formData.firstPartyDescription?.desc || "", PartyRole.FIRST, ); @@ -4248,7 +4262,6 @@ export class RequestManagementService { secondPartyUserId, formData.secondParty.initialForm, formData.secondParty.plate, - formData.secondParty.location, formData.secondParty.description?.desc || "", PartyRole.SECOND, ); @@ -4261,24 +4274,22 @@ export class RequestManagementService { if (!req.expert) req.expert = {} as any; req.expert.decision = { guiltyPartyId: new Types.ObjectId(guiltyPartyId) } as any; req.workflow = { - currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any, - nextStep: undefined, + currentStep: WorkflowStep.FIRST_LOCATION as any, + nextStep: WorkflowStep.SECOND_LOCATION as any, completedSteps: [ WorkflowStep.CREATED, WorkflowStep.FIRST_BLAME_CONFESSION, WorkflowStep.FIRST_VIDEO, WorkflowStep.FIRST_INITIAL_FORM, - WorkflowStep.FIRST_LOCATION, WorkflowStep.FIRST_VOICE, WorkflowStep.FIRST_DESCRIPTION, WorkflowStep.SECOND_INITIAL_FORM, - WorkflowStep.SECOND_LOCATION, WorkflowStep.SECOND_VOICE, WorkflowStep.SECOND_DESCRIPTION, ].filter((s) => s), locked: false, }; - req.status = CaseStatus.WAITING_FOR_SIGNATURES; + req.status = CaseStatus.OPEN; req.blameStatus = BlameStatus.AGREED; (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 { + 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). */ diff --git a/src/utils/sms-manager/kavenegar-response.ts b/src/utils/sms-manager/kavenegar-response.ts new file mode 100644 index 0000000..a164e2f --- /dev/null +++ b/src/utils/sms-manager/kavenegar-response.ts @@ -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; +} diff --git a/src/utils/sms-manager/sms-manager.service.ts b/src/utils/sms-manager/sms-manager.service.ts index 6dd8b3a..dbc96fe 100644 --- a/src/utils/sms-manager/sms-manager.service.ts +++ b/src/utils/sms-manager/sms-manager.service.ts @@ -1,5 +1,16 @@ 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 { message: string; @@ -15,16 +26,84 @@ export interface VerifyLookUpMessage { @Injectable() export class SmsManagerService { + private readonly logger = new Logger(SmsManagerService.name); + constructor(private readonly sender: KavenegarService) {} async sendMessage(data: SendMessage) { - return await this.sender.Send({ - sender: "10008663", - ...data, - }); + 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) { - 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}`, + ); } } diff --git a/src/utils/sms-manager/sms-provider.exception.ts b/src/utils/sms-manager/sms-provider.exception.ts new file mode 100644 index 0000000..ba6a26e --- /dev/null +++ b/src/utils/sms-manager/sms-provider.exception.ts @@ -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); +}