Harden inquiry participant edge cases

This commit is contained in:
SepehrYahyaee
2026-09-13 11:18:37 +03:30
parent c64f23091a
commit d060b6d9e2
11 changed files with 505 additions and 84 deletions

View File

@@ -1,12 +1,14 @@
import { BadRequestException } from "@nestjs/common";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
import { gregorianDateInIran } from "src/helpers/iran-datetime";
import {
InquiryParticipantFieldsDto,
InquiryParticipantInputDto,
InquiryParticipantRole,
InquiryVehicleInputDto,
VehicleRegistrationState,
} from "./dto/inquiry-participants.dto";
} from "src/common/dto/inquiry-participants.dto";
export interface ResolvedInquiryParticipant {
participantId: string;
@@ -17,6 +19,11 @@ export interface ResolvedInquiryParticipant {
hasDrivingLicense?: boolean;
licenseNumber?: string;
licenseType?: string;
unknown?: boolean;
}
export interface InquiryParticipantResolutionOptions {
allowUnknownThirdPartyPolicyholder?: boolean;
}
export interface InquiryParticipantRoleAssignments {
@@ -49,9 +56,13 @@ export interface NormalizedInquirySubmission<T extends Record<string, any>> {
vehicleOwner?: ResolvedInquiryParticipant;
thirdPartyPolicyholder: ResolvedInquiryParticipant;
carBodyPolicyholder?: ResolvedInquiryParticipant;
vehicle?: InquiryVehicleInputDto;
vehicle?: ResolvedInquiryVehicle;
}
export type ResolvedInquiryVehicle = InquiryVehicleInputDto & {
registrationState: VehicleRegistrationState;
};
export function participantForRole(
resolved: ResolvedInquiryParticipants,
role: InquiryParticipantRole,
@@ -119,6 +130,7 @@ function requiredIdentity(
export function resolveInquiryParticipants(
caseType: BlameRequestType,
input: InquiryParticipantFieldsDto & Record<string, any>,
options: InquiryParticipantResolutionOptions = {},
): ResolvedInquiryParticipants {
const hasRoleCompleteInput = Object.values(ROLE_FIELDS).some(
(field) => input[field] != null,
@@ -200,8 +212,40 @@ export function resolveInquiryParticipants(
resolving.add(role);
let participantId: string;
if (value.sameAs) {
if (value.nationalCode != null || value.birthday != null) {
if (
role === InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER &&
value.unknown === true
) {
if (!options.allowUnknownThirdPartyPolicyholder) {
throw new BadRequestException(
"An unknown third-party policyholder is allowed only for the damaged party.",
);
}
const hasIdentityFields = Object.entries(value).some(
([field, fieldValue]) => field !== "unknown" && fieldValue != null,
);
if (hasIdentityFields) {
throw new BadRequestException(
`${role} must contain either unknown=true or person fields, not both.`,
);
}
const participant: ResolvedInquiryParticipant = {
participantId: role,
nationalCode: "",
birthday: "",
unknown: true,
};
participants.set(participant.participantId, participant);
participantId = participant.participantId;
} else if (value.unknown != null) {
throw new BadRequestException(
`${role} does not support the unknown option.`,
);
} else if (value.sameAs) {
const hasPersonSpecificFields = Object.entries(value).some(
([field, fieldValue]) => field !== "sameAs" && fieldValue != null,
);
if (hasPersonSpecificFields) {
throw new BadRequestException(
`${role} must contain either sameAs or identity fields, not both.`,
);
@@ -249,7 +293,7 @@ export function resolveInquiryParticipants(
export function resolveInquiryVehicle(
input: InquiryVehicleInputDto,
): InquiryVehicleInputDto {
): ResolvedInquiryVehicle {
const registrationState =
input.registrationState ?? VehicleRegistrationState.CURRENT;
if (!input.currentPlate) {
@@ -274,7 +318,7 @@ export function resolveInquiryVehicle(
return { ...input, registrationState };
}
export function vehiclePlateCandidates(input?: InquiryVehicleInputDto): Array<{
export function vehiclePlateCandidates(input?: ResolvedInquiryVehicle): Array<{
kind: "CURRENT" | "PREVIOUS";
plate: InquiryVehicleInputDto["currentPlate"];
}> {
@@ -294,6 +338,35 @@ function normalizeVehicleSerial(value: unknown): string {
.replace(/[^A-Z0-9]/g, "");
}
/** A dated result is usable only while the returned policy has not expired. */
export function isMappedPolicyCurrent(
mapped: Record<string, any>,
todayGregorian: string = gregorianDateInIran(new Date()),
): boolean {
const rawEndDate =
mapped?.EndDate ??
mapped?.HEndDte ??
mapped?.persianEndDate ??
mapped?.PolicyEndDate ??
mapped?.endDate;
if (rawEndDate == null || String(rawEndDate).trim() === "") return true;
const endDate = jalaliToGregorianDate(rawEndDate);
return endDate != null && endDate >= todayGregorian;
}
function normalizePlateForComparison(
plate: InquiryVehicleInputDto["currentPlate"],
): string {
return [
plate?.ir,
plate?.leftDigits,
plate?.centerAlphabet,
plate?.centerDigits,
]
.map((part) => String(part ?? "").trim())
.join("|");
}
export function assertPreviousPlateInquiryMatchesVin(
expectedVin: string,
mapped: Record<string, any>,
@@ -319,7 +392,7 @@ export function assertPreviousPlateInquiryMatchesVin(
}
export async function runPlateInquiryWithFallback<T>(options: {
vehicle?: InquiryVehicleInputDto;
vehicle?: ResolvedInquiryVehicle;
fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"];
query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise<T>;
isUsable: (value: T) => boolean;
@@ -358,7 +431,11 @@ export async function runPlateInquiryWithFallback<T>(options: {
usable: false,
});
if (!isLast) continue;
return { value, plateKind: candidate.kind, attempts };
const error = new BadRequestException(
"No current usable policy was found for the submitted vehicle identifiers.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;
}
if (candidate.kind === "PREVIOUS" && usable) {
assertPreviousPlateInquiryMatchesVin(
@@ -374,12 +451,23 @@ export async function runPlateInquiryWithFallback<T>(options: {
return { value, plateKind: candidate.kind, attempts };
} catch (error) {
lastError = error;
attempts.push({
plateKind: candidate.kind,
succeeded: false,
error: error instanceof Error ? error.message : String(error),
});
if (isLast) throw error;
const alreadyRecorded =
typeof error === "object" &&
error != null &&
Array.isArray((error as { attempts?: unknown }).attempts);
if (!alreadyRecorded) {
attempts.push({
plateKind: candidate.kind,
succeeded: false,
error: error instanceof Error ? error.message : String(error),
});
}
if (isLast) {
if (typeof error === "object" && error != null) {
(error as { attempts?: typeof attempts }).attempts = attempts;
}
throw error;
}
}
}
@@ -389,8 +477,9 @@ export async function runPlateInquiryWithFallback<T>(options: {
export function normalizeInquirySubmission<T extends Record<string, any>>(
caseType: BlameRequestType,
input: T,
options: InquiryParticipantResolutionOptions = {},
): NormalizedInquirySubmission<T> {
const participants = resolveInquiryParticipants(caseType, input);
const participants = resolveInquiryParticipants(caseType, input, options);
const driver = participantForRole(
participants,
InquiryParticipantRole.DRIVER,
@@ -415,6 +504,16 @@ export function normalizeInquirySubmission<T extends Record<string, any>>(
const vehicle = input.vehicle
? resolveInquiryVehicle(input.vehicle as InquiryVehicleInputDto)
: undefined;
if (
vehicle &&
input.plate &&
normalizePlateForComparison(vehicle.currentPlate) !==
normalizePlateForComparison(input.plate)
) {
throw new BadRequestException(
"plate and vehicle.currentPlate must identify the same current plate.",
);
}
const sameDriverAndPolicyholder =
participants.roles.driver === participants.roles.thirdPartyPolicyholder;