diff --git a/scripts/refresh-blame-inquiries.js b/scripts/refresh-blame-inquiries.js index c68d3f5..37428b4 100644 --- a/scripts/refresh-blame-inquiries.js +++ b/scripts/refresh-blame-inquiries.js @@ -3,7 +3,8 @@ /* * One-time script to replace mocked blameCases party vehicle inquiry data. * THIRD_PARTY cases use Tejarat block inquiry; CAR_BODY cases use both - * Tejarat third-party block inquiry and car-body inquiry. + * Tejarat third-party block inquiry and car-body inquiry. All cases also + * refresh available party personal inquiries into inquiries.person. * * Defaults to DRY_RUN=true. Set DRY_RUN=false to write changes. */ @@ -61,10 +62,15 @@ const config = { carBodyUrl: process.env.TEJARAT_CAR_BODY_URL || "http://82.99.202.245:3027/block-inquiry-tejarat/badane", + personUrl: + process.env.TEJARAT_PERSON_URL || + "http://82.99.202.245:3027/personal-inquiry/tejarat-no", thirdPartyToken: process.env.TEJARAT_THIRD_PARTY_TOKEN || process.env.TEJARAT_TOKEN || "", carBodyToken: process.env.TEJARAT_CAR_BODY_TOKEN || process.env.TEJARAT_TOKEN || "", + personToken: + process.env.TEJARAT_PERSON_TOKEN || process.env.TEJARAT_TOKEN || "", rateLimitPerMinute: Number(process.env.RATE_LIMIT_PER_MINUTE || 5), retryEnabled: String(process.env.RETRY_ENABLED ?? "true").toLowerCase() !== "false", retryCount: Number(process.env.RETRY_COUNT || 3), @@ -124,6 +130,7 @@ async function main() { requestNo: 1, type: 1, parties: 1, + inquiries: 1, }, }) .sort({ createdAt: 1, _id: 1 }); @@ -136,6 +143,9 @@ async function main() { partiesInquired: 0, partiesSkipped: 0, partiesFailed: 0, + personInquired: 0, + personSkipped: 0, + personFailed: 0, }; for await (const doc of cursor) { @@ -145,7 +155,9 @@ async function main() { console.log(`currently inquiry for doc with ${label} and requestId ${requestId}`); const parties = Array.isArray(doc.parties) ? clone(doc.parties) : []; + const inquiries = doc.inquiries && typeof doc.inquiries === "object" ? clone(doc.inquiries) : {}; let docChanged = false; + let inquiriesChanged = false; for (let index = 0; index < parties.length; index += 1) { const party = parties[index]; @@ -155,61 +167,101 @@ async function main() { if (!input.ok) { summary.partiesSkipped += 1; console.warn(`[skip] ${partyLabel}: ${input.reason}`); - continue; - } + } else { + let nextParty = party; + let partyChanged = false; - let nextParty = party; - let partyChanged = false; - - for (const request of input.requests) { - console.log( - `[request] ${partyLabel} type=${request.type} body=${JSON.stringify(request.body)}`, - ); - - try { - const inquiryResponse = await inquiryWithRetry(request.type, request.body); - const successful = isInquirySuccessful(request.type, inquiryResponse); + for (const request of input.requests) { console.log( - `[response] ${partyLabel} type=${request.type} successful=${successful} body=${JSON.stringify(inquiryResponse)}`, + `[request] ${partyLabel} type=${request.type} body=${JSON.stringify(request.body)}`, ); - if (!successful) { - summary.partiesFailed += 1; - continue; - } + try { + const inquiryResponse = await inquiryWithRetry(request.type, request.body); + const successful = isInquirySuccessful(request.type, inquiryResponse); + console.log( + `[response] ${partyLabel} type=${request.type} successful=${successful} body=${JSON.stringify(inquiryResponse)}`, + ); - nextParty = applyInquiryToParty(request.type, nextParty, inquiryResponse, input.plate); - summary.partiesInquired += 1; - partyChanged = true; - } catch (error) { - summary.partiesFailed += 1; - console.error(`[error] ${partyLabel} type=${request.type}: ${error.message}`); + if (!successful) { + summary.partiesFailed += 1; + continue; + } + + nextParty = applyInquiryToParty(request.type, nextParty, inquiryResponse, input.plate); + summary.partiesInquired += 1; + partyChanged = true; + } catch (error) { + summary.partiesFailed += 1; + console.error(`[error] ${partyLabel} type=${request.type}: ${error.message}`); + } + } + + if (partyChanged) { + parties[index] = nextParty; + docChanged = true; } } - if (partyChanged) { - parties[index] = nextParty; - docChanged = true; + const personInput = buildPersonInquiryInput(party); + if (!personInput.ok) { + summary.personSkipped += 1; + console.warn(`[skip] ${partyLabel} person: ${personInput.reason}`); + continue; + } + + console.log(`[request] ${partyLabel} type=PERSON body=${JSON.stringify(personInput.body)}`); + try { + const personResponse = await inquiryWithRetry("PERSON", personInput.body); + const successful = isInquirySuccessful("PERSON", personResponse); + console.log( + `[response] ${partyLabel} type=PERSON successful=${successful} body=${JSON.stringify(personResponse)}`, + ); + + if (!successful) { + summary.personFailed += 1; + applyPersonInquiryToCaseInquiries(inquiries, party, index, false, {}, personResponse); + inquiriesChanged = true; + continue; + } + + applyPersonInquiryToCaseInquiries( + inquiries, + party, + index, + true, + normalizePersonResponse(personResponse), + ); + summary.personInquired += 1; + inquiriesChanged = true; + } catch (error) { + summary.personFailed += 1; + applyPersonInquiryToCaseInquiries(inquiries, party, index, false, {}, error); + inquiriesChanged = true; + console.error(`[error] ${partyLabel} type=PERSON: ${error.message}`); } } - if (!docChanged) { + if (!docChanged && !inquiriesChanged) { console.log(`[doc] ${label} no changes`); continue; } if (config.dryRun) { - console.log(`[dry-run] ${label} would update parties array`); + console.log(`[dry-run] ${label} would update parties/inquiries`); continue; } + const updateSet = { + updatedAt: new Date(), + }; + if (docChanged) updateSet.parties = parties; + if (inquiriesChanged) updateSet.inquiries = inquiries; + const result = await collection.updateOne( { _id: doc._id }, { - $set: { - parties, - updatedAt: new Date(), - }, + $set: updateSet, }, ); summary.docsChanged += result.modifiedCount; @@ -285,6 +337,40 @@ function buildCarBodyRequest(plate, serialLetter, nationalCode) { }; } +function buildPersonInquiryInput(party) { + if (!party || typeof party !== "object") return { ok: false, reason: "party is empty" }; + const person = party.person && typeof party.person === "object" ? party.person : null; + if (!person) return { ok: false, reason: "party.person is missing" }; + + const nationalCode = + cleanString(person.nationalCodeOfInsurer) || + cleanString(person.nationalCodeOfDriver); + const birthDate = firstPresent( + person.insurerBirthday, + person.driverBirthday, + person.birthday, + ); + const gregorianBirthdate = jalaliToGregorianDate(birthDate); + + if (!nationalCode) { + return { ok: false, reason: "nationalCodeOfInsurer/nationalCodeOfDriver missing" }; + } + if (!gregorianBirthdate) { + return { + ok: false, + reason: `invalid insurerBirthday/driverBirthday=${cleanString(birthDate)}`, + }; + } + + return { + ok: true, + body: { + nationalCode, + birthdate: gregorianBirthdate, + }, + }; +} + function parsePlateId(plateId) { const value = cleanString(plateId); if (!value) return null; @@ -352,8 +438,10 @@ function extractPlate(party) { } async function inquiryWithRetry(type, body) { - const url = type === "CAR_BODY" ? config.carBodyUrl : config.thirdPartyUrl; - const token = type === "CAR_BODY" ? config.carBodyToken : config.thirdPartyToken; + const endpoint = getInquiryEndpoint(type); + const url = endpoint.url; + const token = endpoint.token; + const accept = endpoint.accept; let lastError; const maxAttempts = config.retryEnabled ? config.retryCount : 1; @@ -362,7 +450,7 @@ async function inquiryWithRetry(type, body) { await waitForRateLimit(); try { console.log("[http] " + type + " attempt=" + attempt + "/" + maxAttempts); - return await postJson(url, token, body); + return await postJson(url, token, body, accept); } catch (error) { lastError = error; console.error(`[retry] ${type} attempt=${attempt} failed: ${error.message}`); @@ -375,11 +463,11 @@ async function inquiryWithRetry(type, body) { throw lastError; } -async function postJson(url, token, body) { +async function postJson(url, token, body, accept = "application/json") { const response = await fetch(url, { method: "POST", headers: { - accept: "application/json", + accept, authorization: `Bearer ${token}`, "content-type": "application/json", }, @@ -395,7 +483,10 @@ async function postJson(url, token, body) { } if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`); + const error = new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`); + error.status = response.status; + error.data = data; + throw error; } return data; @@ -403,9 +494,20 @@ async function postJson(url, token, body) { function isInquirySuccessful(type, response) { if (type === "CAR_BODY") return response && response.isSuccess === true && response.data; + if (type === "PERSON") return response && response.status === 200 && response.data; return response && response.resultStatus === true; } +function getInquiryEndpoint(type) { + if (type === "CAR_BODY") { + return { url: config.carBodyUrl, token: config.carBodyToken, accept: "application/json" }; + } + if (type === "PERSON") { + return { url: config.personUrl, token: config.personToken, accept: "*/*" }; + } + return { url: config.thirdPartyUrl, token: config.thirdPartyToken, accept: "application/json" }; +} + function applyInquiryToParty(type, party, response, originalPlate) { if (type === "CAR_BODY") return applyCarBodyInquiryToParty(party, response, originalPlate); return applyThirdPartyInquiryToParty(party, response, originalPlate); @@ -486,6 +588,51 @@ function applyCarBodyInquiryToParty(party, response, originalPlate) { return next; } +function applyPersonInquiryToCaseInquiries(inquiries, party, index, has, data, error) { + if (!inquiries.person || typeof inquiries.person !== "object") { + inquiries.person = {}; + } + + const existingData = + inquiries.person.data && typeof inquiries.person.data === "object" && !Array.isArray(inquiries.person.data) + ? inquiries.person.data + : {}; + const existingError = + inquiries.person.error && typeof inquiries.person.error === "object" && !Array.isArray(inquiries.person.error) + ? inquiries.person.error + : {}; + const roleKey = party && party.role ? party.role : `party_${index}`; + const nextData = { ...existingData }; + const nextError = { ...existingError }; + + if (has) { + nextData[roleKey] = data || {}; + delete nextError[roleKey]; + } else { + nextError[roleKey] = normalizeInquiryError(error); + } + + inquiries.person = { + has: Object.keys(nextData).length > 0, + data: nextData, + ...(Object.keys(nextError).length > 0 ? { error: nextError } : {}), + updatedAt: new Date(), + }; +} + +function normalizePersonResponse(response) { + return response && response.data ? response.data : response; +} + +function normalizeInquiryError(error) { + if (!error) return undefined; + return { + message: error.message || String(error), + status: error.status, + data: error.data, + }; +} + function buildPlateId(plate) { if (!plate) return ""; const serialLetter = NUMBER_TO_LETTER[String(plate.Plk2)] || cleanString(plate.Plk2); @@ -555,6 +702,111 @@ function normalizeCarBodyResponse(response, originalPlate) { }; } +function jalaliToGregorianDate(input) { + if (input === null || input === undefined) return null; + + const raw = normalizeDigits(typeof input === "number" ? String(input) : String(input).trim()); + if (!raw) return null; + + let year = 0; + let month = 0; + let day = 0; + + const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/); + if (separated) { + year = parseInt(separated[1], 10); + month = parseInt(separated[2], 10); + day = parseInt(separated[3], 10); + } else { + const digits = raw.replace(/\D/g, ""); + if (digits.length !== 8) return null; + year = parseInt(digits.slice(0, 4), 10); + month = parseInt(digits.slice(4, 6), 10); + day = parseInt(digits.slice(6, 8), 10); + } + + if (!year || !month || !day) return null; + + if (year >= 1900) { + const mm = String(month).padStart(2, "0"); + const dd = String(day).padStart(2, "0"); + const result = `${year}-${mm}-${dd}`; + return isNaN(new Date(result).getTime()) ? null : result; + } + + return jalaliPartsToGregorian(year, month, day); +} + +function jalaliPartsToGregorian(jYear, jMonth, jDay) { + const jy = jYear - 979; + const jm = jMonth - 1; + const jd = jDay - 1; + + let jDayNo = + 365 * jy + Math.floor(jy / 33) * 8 + Math.floor(((jy % 33) + 3) / 4); + + const jalaliMonthDays = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29]; + for (let i = 0; i < jm; i += 1) { + jDayNo += jalaliMonthDays[i]; + } + jDayNo += jd; + + let gDayNo = jDayNo + 79; + + let gy = 1600 + 400 * Math.floor(gDayNo / 146097); + gDayNo %= 146097; + + let leap = true; + if (gDayNo >= 36525) { + gDayNo -= 1; + gy += 100 * Math.floor(gDayNo / 36524); + gDayNo %= 36524; + if (gDayNo >= 365) gDayNo += 1; + else leap = false; + } + + gy += 4 * Math.floor(gDayNo / 1461); + gDayNo %= 1461; + + if (gDayNo >= 366) { + leap = false; + gDayNo -= 1; + gy += Math.floor(gDayNo / 365); + gDayNo %= 365; + } + + const gregorianMonthDays = [ + 31, + leap ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + + let gm = 0; + for (let i = 0; i < 12; i += 1) { + if (gDayNo < gregorianMonthDays[i]) { + gm = i + 1; + break; + } + gDayNo -= gregorianMonthDays[i]; + } + + const gd = gDayNo + 1; + const mm = String(gm).padStart(2, "0"); + const dd = String(gd).padStart(2, "0"); + const result = `${gy}-${mm}-${dd}`; + + return isNaN(new Date(result).getTime()) ? null : result; +} + async function waitForRateLimit() { const minDelayMs = Math.ceil(60000 / config.rateLimitPerMinute); const elapsed = Date.now() - lastRequestAt; @@ -580,6 +832,9 @@ function validateConfig() { if (!config.carBodyToken) { throw new Error("TEJARAT_CAR_BODY_TOKEN or TEJARAT_TOKEN is required"); } + if (!config.personToken) { + throw new Error("TEJARAT_PERSON_TOKEN or TEJARAT_TOKEN is required"); + } } function loadEnvFiles(filePath) { diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 60931a5..4ffb03c 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -3973,6 +3973,7 @@ export class ClaimRequestManagementService { blameRequestNo: blameRequest.requestNo, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, claimStatus: ClaimStatus.PENDING, + inquiries: (blameRequest as any).inquiries ?? {}, workflow: { currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, @@ -4108,6 +4109,7 @@ export class ClaimRequestManagementService { blameRequestNo: blameRequest.requestNo, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, claimStatus: ClaimStatus.PENDING, + inquiries: (blameRequest as any).inquiries ?? {}, workflow: { currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, @@ -4258,6 +4260,7 @@ export class ClaimRequestManagementService { blameRequestNo: blameRequest.requestNo, status: ClaimCaseStatus.SELECTING_OUTER_PARTS, claimStatus: ClaimStatus.PENDING, + inquiries: (blameRequest as any).inquiries ?? {}, workflow: { currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS, nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts index 7d7f802..4fb3bfd 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -3,7 +3,10 @@ import { HydratedDocument, Schema as MongooseSchema, Types } from "mongoose"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; -import { HistoryEvent, HistoryEventSchema } from "src/request-management/entities/schema/historyEvent.type"; +import { + HistoryEvent, + HistoryEventSchema, +} from "src/request-management/entities/schema/historyEvent.type"; import { ClaimDamageSelection, ClaimDamageSelectionSchema, @@ -25,8 +28,15 @@ import { ClaimCaseSnapshot, ClaimCaseSnapshotSchema, } from "./claim-case.snapshot.schema"; -import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema"; +import { + ClaimWorkflow, + ClaimWorkflowSchema, +} from "./claim-case.workflow.schema"; import { UserClaimRating } from "./claim-request-management.schema"; +import { + CaseInquiries, + CaseInquiriesSchema, +} from "src/common/schema/case-inquiries.schema"; @Schema({ _id: false }) export class RequiredDocumentRef { @@ -45,7 +55,8 @@ export class RequiredDocumentRef { @Prop({ type: Date }) uploadedAt?: Date; } -export const RequiredDocumentRefSchema = SchemaFactory.createForClass(RequiredDocumentRef); +export const RequiredDocumentRefSchema = + SchemaFactory.createForClass(RequiredDocumentRef); @Schema({ collection: "claimCases", @@ -67,7 +78,12 @@ export class ClaimCase { /** * Overall case status (user flow + expert flow progression) */ - @Prop({ required: true, type: String, enum: ClaimCaseStatus, default: ClaimCaseStatus.CREATED }) + @Prop({ + required: true, + type: String, + enum: ClaimCaseStatus, + default: ClaimCaseStatus.CREATED, + }) status: ClaimCaseStatus; /** @@ -119,6 +135,9 @@ export class ClaimCase { @Prop({ type: ClaimEvaluationSchema, default: () => ({}) }) evaluation?: ClaimEvaluation; + @Prop({ type: CaseInquiriesSchema, default: () => ({}) }) + inquiries?: CaseInquiries; + /** * Optional “read-optimized” copy of fields from blame/request side. * Source of truth remains `blameRequestId`. @@ -160,4 +179,3 @@ export class ClaimCase { export type ClaimCaseDocument = HydratedDocument; export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase); ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 }); - diff --git a/src/common/schema/case-inquiries.schema.ts b/src/common/schema/case-inquiries.schema.ts new file mode 100644 index 0000000..f172b92 --- /dev/null +++ b/src/common/schema/case-inquiries.schema.ts @@ -0,0 +1,34 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Schema as MongooseSchema } from "mongoose"; + +@Schema({ _id: false }) +export class InquiryStatus { + @Prop({ type: Boolean, default: false }) + has?: boolean; + + @Prop({ type: MongooseSchema.Types.Mixed, default: () => ({}) }) + data?: any; + + @Prop({ type: MongooseSchema.Types.Mixed }) + error?: any; + + @Prop({ type: Date }) + updatedAt?: Date; +} +export const InquiryStatusSchema = SchemaFactory.createForClass(InquiryStatus); + +@Schema({ _id: false, strict: false }) +export class CaseInquiries { + @Prop({ type: InquiryStatusSchema }) + thirdParty?: InquiryStatus; + + @Prop({ type: InquiryStatusSchema }) + carBody?: InquiryStatus; + + @Prop({ type: InquiryStatusSchema }) + person?: InquiryStatus; + + @Prop({ type: InquiryStatusSchema }) + drivingLicence?: InquiryStatus; +} +export const CaseInquiriesSchema = SchemaFactory.createForClass(CaseInquiries); diff --git a/src/request-management/entities/schema/blame-cases.schema.ts b/src/request-management/entities/schema/blame-cases.schema.ts index 2475975..2df68eb 100644 --- a/src/request-management/entities/schema/blame-cases.schema.ts +++ b/src/request-management/entities/schema/blame-cases.schema.ts @@ -8,6 +8,10 @@ import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum"; import { CreationMethod, FilledBy } from "./request-management.schema"; +import { + CaseInquiries, + CaseInquiriesSchema, +} from "src/common/schema/case-inquiries.schema"; /** CAR_BODY only: mocked insurance inquiry result (legacy top-level; prefer parties[].insurance.carBodyInsurance) */ @Schema({ _id: false }) @@ -18,86 +22,95 @@ export class CarBodyInsuranceDetail { @Prop() insurerCompany?: string; @Prop({ type: [String] }) coverages?: string[]; } -export const CarBodyInsuranceDetailSchema = SchemaFactory.createForClass(CarBodyInsuranceDetail); +export const CarBodyInsuranceDetailSchema = SchemaFactory.createForClass( + CarBodyInsuranceDetail, +); @Schema({ _id: false }) export class BlameRequestSnapshot { - @Prop({ type: [PartySchema], default: [] }) - parties?: Party[]; + @Prop({ type: [PartySchema], default: [] }) + parties?: Party[]; } export const BlameRequestSnapshotSchema = SchemaFactory.createForClass(BlameRequestSnapshot); - -@Schema({ collection: "blameCases", timestamps: true , id:true }) +@Schema({ collection: "blameCases", timestamps: true, id: true }) export class BlameRequest { - @Prop({ required: true, unique: true, index: true, trim: true }) - requestNo: string; + @Prop({ required: true, unique: true, index: true, trim: true }) + requestNo: string; - /** - * Human-friendly shared id across blame+claim (e.g. A14235). - * Generated once at creation time and reused by claim. - */ - @Prop({ required: true, unique: true, index: true, trim: true }) - publicId: string; + /** + * Human-friendly shared id across blame+claim (e.g. A14235). + * Generated once at creation time and reused by claim. + */ + @Prop({ required: true, unique: true, index: true, trim: true }) + publicId: string; - @Prop({ required: true, type: String, enum: BlameRequestType }) - type: BlameRequestType; + @Prop({ required: true, type: String, enum: BlameRequestType }) + type: BlameRequestType; - @Prop({ required: true, type: String, enum: CaseStatus, default: CaseStatus.OPEN }) - status: CaseStatus; + @Prop({ + required: true, + type: String, + enum: CaseStatus, + default: CaseStatus.OPEN, + }) + status: CaseStatus; - @Prop({ type: String, enum: BlameStatus, default: BlameStatus.UNKNOWN }) - blameStatus: BlameStatus; + @Prop({ type: String, enum: BlameStatus, default: BlameStatus.UNKNOWN }) + blameStatus: BlameStatus; - @Prop({ type: WorkflowSchema }) - workflow?: Workflow; + @Prop({ type: WorkflowSchema }) + workflow?: Workflow; - @Prop({ type: [PartySchema], default: [] }) - parties: Party[]; + @Prop({ type: [PartySchema], default: [] }) + parties: Party[]; - @Prop({ type: ExpertSectionSchema }) - expert?: ExpertSection; + @Prop({ type: CaseInquiriesSchema, default: () => ({}) }) + inquiries?: CaseInquiries; - @Prop({ type: [HistoryEventSchema], default: [] }) - history: HistoryEvent[]; + @Prop({ type: ExpertSectionSchema }) + expert?: ExpertSection; - /** CAR_BODY only: first party car-body insurance (mocked inquiry) – legacy; prefer parties[0].insurance.carBodyInsurance */ - @Prop({ type: CarBodyInsuranceDetailSchema }) - carBodyInsuranceDetail?: CarBodyInsuranceDetail; + @Prop({ type: [HistoryEventSchema], default: [] }) + history: HistoryEvent[]; - /** - * Optional snapshot structure (kept for future use / read-optimized copies). - * Source of truth remains the top-level fields of this document. - */ - @Prop({ type: BlameRequestSnapshotSchema, required: false }) - snapshot?: BlameRequestSnapshot; + /** CAR_BODY only: first party car-body insurance (mocked inquiry) – legacy; prefer parties[0].insurance.carBodyInsurance */ + @Prop({ type: CarBodyInsuranceDetailSchema }) + carBodyInsuranceDetail?: CarBodyInsuranceDetail; - /** True when this blame was created by a field expert (independent expert); only that expert can see/review it. */ - @Prop({ default: false }) - expertInitiated?: boolean; + /** + * Optional snapshot structure (kept for future use / read-optimized copies). + * Source of truth remains the top-level fields of this document. + */ + @Prop({ type: BlameRequestSnapshotSchema, required: false }) + snapshot?: BlameRequestSnapshot; - /** Field expert who created this file (for expert-initiated flows). */ - @Prop({ type: Types.ObjectId }) - initiatedByFieldExpertId?: Types.ObjectId; + /** True when this blame was created by a field expert (independent expert); only that expert can see/review it. */ + @Prop({ default: false }) + expertInitiated?: boolean; - /** True when this blame was created by a registrar. */ - @Prop({ default: false }) - registrarInitiated?: boolean; + /** Field expert who created this file (for expert-initiated flows). */ + @Prop({ type: Types.ObjectId }) + initiatedByFieldExpertId?: Types.ObjectId; - /** Registrar who created this file (for registrar-initiated flows). */ - @Prop({ type: Types.ObjectId }) - initiatedByRegistrarId?: Types.ObjectId; + /** True when this blame was created by a registrar. */ + @Prop({ default: false }) + registrarInitiated?: boolean; - /** LINK = expert sends link to user(s); IN_PERSON = expert fills form on-site. */ - @Prop({ type: String, enum: CreationMethod }) - creationMethod?: CreationMethod; + /** Registrar who created this file (for registrar-initiated flows). */ + @Prop({ type: Types.ObjectId }) + initiatedByRegistrarId?: Types.ObjectId; - /** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */ - @Prop({ type: String, enum: FilledBy }) - filledBy?: FilledBy; + /** LINK = expert sends link to user(s); IN_PERSON = expert fills form on-site. */ + @Prop({ type: String, enum: CreationMethod }) + creationMethod?: CreationMethod; + + /** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */ + @Prop({ type: String, enum: FilledBy }) + filledBy?: FilledBy; } export type BlameRequestDocument = HydratedDocument; export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest); -BlameRequestSchema.index({ status: 1, "workflow.locked": 1 }); \ No newline at end of file +BlameRequestSchema.index({ status: 1, "workflow.locked": 1 }); diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 9f0a4d3..b950191 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -123,6 +123,64 @@ export class RequestManagementService { : -1; } + private normalizeInquiryError(err: any): any { + if (!err) return undefined; + return { + message: err?.message || String(err), + status: err?.response?.status, + data: err?.response?.data, + }; + } + + private recordCaseInquiryStatus( + req: any, + key: "thirdParty" | "carBody" | "person" | "drivingLicence" | string, + has: boolean, + data: any = {}, + error?: any, + ): void { + const existingInquiries = + req.inquiries?.toObject?.() ?? + (req.inquiries && typeof req.inquiries === "object" ? req.inquiries : {}); + req.inquiries = { + ...existingInquiries, + [key]: { + has, + data: data ?? {}, + ...(error ? { error: this.normalizeInquiryError(error) } : {}), + updatedAt: new Date(), + }, + }; + } + + private recordPartyCaseInquiryStatus( + req: any, + key: "thirdParty" | "carBody" | "person" | "drivingLicence" | string, + role: PartyRole, + has: boolean, + data: any = {}, + error?: any, + ): void { + const existingData = + req?.inquiries?.[key]?.data?.toObject?.() ?? req?.inquiries?.[key]?.data; + const dataByRole = + existingData && + typeof existingData === "object" && + !Array.isArray(existingData) + ? existingData + : {}; + this.recordCaseInquiryStatus( + req, + key, + has, + { + ...dataByRole, + [role]: data ?? {}, + }, + error, + ); + } + private assertPartyOwner(party: any, user: any, errMsg: string) { const partyUserId = party?.person?.userId ? String(party.person.userId) @@ -967,6 +1025,11 @@ export class RequestManagementService { this.logger.log( `[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`, ); + this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { + source: "TEJARAT_BLOCK_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }); } catch (err: any) { this.logger.error( `[TEJARAT] block inquiry failed for request=${req._id}: ${err?.message || err}`, @@ -978,6 +1041,15 @@ export class RequestManagementService { }, data=${JSON.stringify(err.response.data)}`, ); } + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + false, + {}, + err, + ); + await (req as any).save(); throw new HttpException("Inquiry failed", HttpStatus.BAD_REQUEST); } @@ -987,6 +1059,12 @@ export class RequestManagementService { inquiryMapped.Error, )}`, ); + this.recordPartyCaseInquiryStatus(req, "thirdParty", role, false, { + source: "TEJARAT_BLOCK_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }); + await (req as any).save(); throw new HttpException( inquiryMapped.Error.Message || "Inquiry returned error", HttpStatus.BAD_REQUEST, @@ -1020,6 +1098,13 @@ export class RequestManagementService { personalNationalCode, personalBirthDate, ); + this.recordPartyCaseInquiryStatus( + req, + "person", + role, + true, + personalInquiry, + ); this.logger.log( `[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify( personalInquiry, @@ -1029,6 +1114,8 @@ export class RequestManagementService { this.logger.error( `[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`, ); + this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err); + await (req as any).save(); throw new HttpException( "Personal identity inquiry failed", HttpStatus.BAD_REQUEST, @@ -1120,10 +1207,28 @@ export class RequestManagementService { // CAR BODY EXTERNAL API INQUIRY if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { - const carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry({ - nationalCodeOfInsurer: body.nationalCodeOfInsurer, - plate: body.plate, - }); + let carBodyInfo: any; + try { + carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry({ + nationalCodeOfInsurer: body.nationalCodeOfInsurer, + plate: body.plate, + }); + this.recordPartyCaseInquiryStatus(req, "carBody", role, true, { + source: "TEJARAT_CAR_BODY_INQUIRY", + raw: carBodyInfo.raw, + mapped: carBodyInfo.mapped, + }); + } catch (err: any) { + this.logger.error( + `[TEJARAT] car body inquiry failed for request=${req._id}: ${err?.message || err}`, + ); + this.recordPartyCaseInquiryStatus(req, "carBody", role, false, {}, err); + await (req as any).save(); + throw new HttpException( + "Car body inquiry failed", + HttpStatus.BAD_REQUEST, + ); + } // Raw + mapped stored under vehicle (same pattern as third-party inquiry) party.vehicle.inquiry = { @@ -4829,8 +4934,24 @@ export class RequestManagementService { nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, }); sandHubReport = sandHubResponse["_doc"] || sandHubResponse; + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + PartyRole.FIRST, + true, + sandHubReport, + ); } catch (e) { this.logger.error("SandHub error in expertCompleteCarBodyFormV2:", e); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + PartyRole.FIRST, + false, + {}, + e, + ); + await (req as any).save(); throw new InternalServerErrorException( "Failed to process plate information.", ); @@ -4864,6 +4985,13 @@ export class RequestManagementService { firstPartyPlate.plate, firstPartyPlate.nationalCodeOfInsurer, ); + this.recordPartyCaseInquiryStatus( + req, + "carBody", + PartyRole.FIRST, + true, + carBodyInfo, + ); const firstParty: any = { role: PartyRole.FIRST, @@ -5010,11 +5138,32 @@ export class RequestManagementService { desc: string, role: PartyRole, ) => { - const sandHubResponse = await this.sandHubService.getSandHubResponse({ - plate: plateDto.plate, - nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, - }); + let sandHubResponse: any; + try { + sandHubResponse = await this.sandHubService.getSandHubResponse({ + plate: plateDto.plate, + nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, + }); + } catch (err: any) { + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + false, + {}, + err, + ); + await (req as any).save(); + throw err; + } const sandHubReport = (sandHubResponse["_doc"] || sandHubResponse) as any; + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + true, + sandHubReport, + ); const clientName = sandHubReport?.CompanyName || sandHubReport?.LastCompanyName; const companyCode = sandHubReport?.CompanyCode;