forked from Yara724/api
Implement role-complete inquiry participants
This commit is contained in:
450
src/request-management/inquiry-participant-resolver.ts
Normal file
450
src/request-management/inquiry-participant-resolver.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import {
|
||||
InquiryParticipantFieldsDto,
|
||||
InquiryParticipantInputDto,
|
||||
InquiryParticipantRole,
|
||||
InquiryVehicleInputDto,
|
||||
VehicleRegistrationState,
|
||||
} from "./dto/inquiry-participants.dto";
|
||||
|
||||
export interface ResolvedInquiryParticipant {
|
||||
participantId: string;
|
||||
nationalCode: string;
|
||||
birthday: string;
|
||||
fullName?: string;
|
||||
phoneNumber?: string;
|
||||
hasDrivingLicense?: boolean;
|
||||
licenseNumber?: string;
|
||||
licenseType?: string;
|
||||
}
|
||||
|
||||
export interface InquiryParticipantRoleAssignments {
|
||||
driver: string;
|
||||
vehicleOwner?: string;
|
||||
thirdPartyPolicyholder: string;
|
||||
carBodyPolicyholder?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedInquiryParticipants {
|
||||
participants: ResolvedInquiryParticipant[];
|
||||
roles: InquiryParticipantRoleAssignments;
|
||||
legacy: boolean;
|
||||
}
|
||||
|
||||
export interface NormalizedInquirySubmission<T extends Record<string, any>> {
|
||||
dto: T & {
|
||||
nationalCodeOfDriver: string;
|
||||
driverBirthday: string;
|
||||
driverLicense?: string;
|
||||
licenseType?: string;
|
||||
userNoCertificate?: boolean;
|
||||
nationalCodeOfInsurer: string;
|
||||
insurerBirthday: string;
|
||||
driverIsInsurer: boolean;
|
||||
insurerLicense?: string;
|
||||
};
|
||||
participants: ResolvedInquiryParticipants;
|
||||
driver: ResolvedInquiryParticipant;
|
||||
vehicleOwner?: ResolvedInquiryParticipant;
|
||||
thirdPartyPolicyholder: ResolvedInquiryParticipant;
|
||||
carBodyPolicyholder?: ResolvedInquiryParticipant;
|
||||
vehicle?: InquiryVehicleInputDto;
|
||||
}
|
||||
|
||||
export function participantForRole(
|
||||
resolved: ResolvedInquiryParticipants,
|
||||
role: InquiryParticipantRole,
|
||||
): ResolvedInquiryParticipant | undefined {
|
||||
const roleField = ROLE_FIELDS[
|
||||
role
|
||||
] as keyof InquiryParticipantRoleAssignments;
|
||||
const participantId = resolved.roles[roleField];
|
||||
return resolved.participants.find(
|
||||
(participant) => participant.participantId === participantId,
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_FIELDS: Record<
|
||||
InquiryParticipantRole,
|
||||
keyof InquiryParticipantFieldsDto
|
||||
> = {
|
||||
[InquiryParticipantRole.DRIVER]: "driver",
|
||||
[InquiryParticipantRole.VEHICLE_OWNER]: "vehicleOwner",
|
||||
[InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER]: "thirdPartyPolicyholder",
|
||||
[InquiryParticipantRole.CAR_BODY_POLICYHOLDER]: "carBodyPolicyholder",
|
||||
};
|
||||
|
||||
function requiredIdentity(
|
||||
role: InquiryParticipantRole,
|
||||
input: InquiryParticipantInputDto,
|
||||
): ResolvedInquiryParticipant {
|
||||
const nationalCode = String(input.nationalCode ?? "").trim();
|
||||
const birthday = String(input.birthday ?? "").trim();
|
||||
if (!nationalCode || !birthday) {
|
||||
throw new BadRequestException(
|
||||
`${role} requires nationalCode and birthday.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
role === InquiryParticipantRole.DRIVER &&
|
||||
typeof input.hasDrivingLicense !== "boolean"
|
||||
) {
|
||||
throw new BadRequestException("DRIVER requires hasDrivingLicense.");
|
||||
}
|
||||
if (
|
||||
role === InquiryParticipantRole.DRIVER &&
|
||||
input.hasDrivingLicense === true &&
|
||||
(!String(input.licenseNumber ?? "").trim() ||
|
||||
!String(input.licenseType ?? "").trim())
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"DRIVER requires licenseNumber and licenseType when hasDrivingLicense is true.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
participantId: role,
|
||||
nationalCode,
|
||||
birthday,
|
||||
...(input.fullName ? { fullName: input.fullName } : {}),
|
||||
...(input.phoneNumber ? { phoneNumber: input.phoneNumber } : {}),
|
||||
...(input.hasDrivingLicense != null
|
||||
? { hasDrivingLicense: input.hasDrivingLicense }
|
||||
: {}),
|
||||
...(input.licenseNumber ? { licenseNumber: input.licenseNumber } : {}),
|
||||
...(input.licenseType ? { licenseType: input.licenseType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveInquiryParticipants(
|
||||
caseType: BlameRequestType,
|
||||
input: InquiryParticipantFieldsDto & Record<string, any>,
|
||||
): ResolvedInquiryParticipants {
|
||||
const hasRoleCompleteInput = Object.values(ROLE_FIELDS).some(
|
||||
(field) => input[field] != null,
|
||||
);
|
||||
if (!hasRoleCompleteInput) {
|
||||
const driverId = InquiryParticipantRole.DRIVER;
|
||||
const policyholderId = input.driverIsInsurer
|
||||
? driverId
|
||||
: InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER;
|
||||
const participants: ResolvedInquiryParticipant[] = [
|
||||
{
|
||||
participantId: driverId,
|
||||
nationalCode: String(input.nationalCodeOfDriver ?? ""),
|
||||
birthday: String(
|
||||
input.driverBirthday ??
|
||||
(input.driverIsInsurer ? input.insurerBirthday : "") ??
|
||||
"",
|
||||
),
|
||||
...(input.driverLicense
|
||||
? { licenseNumber: String(input.driverLicense) }
|
||||
: {}),
|
||||
...(input.licenseType
|
||||
? { licenseType: String(input.licenseType) }
|
||||
: {}),
|
||||
...(input.userNoCertificate != null
|
||||
? { hasDrivingLicense: !input.userNoCertificate }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
if (policyholderId !== driverId) {
|
||||
participants.push({
|
||||
participantId: policyholderId,
|
||||
nationalCode: String(input.nationalCodeOfInsurer ?? ""),
|
||||
birthday: String(input.insurerBirthday ?? ""),
|
||||
});
|
||||
}
|
||||
return {
|
||||
participants,
|
||||
roles: {
|
||||
driver: driverId,
|
||||
thirdPartyPolicyholder: policyholderId,
|
||||
},
|
||||
legacy: true,
|
||||
};
|
||||
}
|
||||
if (
|
||||
caseType === BlameRequestType.THIRD_PARTY &&
|
||||
input.carBodyPolicyholder != null
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"CAR_BODY_POLICYHOLDER is not allowed for a THIRD_PARTY case.",
|
||||
);
|
||||
}
|
||||
|
||||
const requiredRoles = [
|
||||
InquiryParticipantRole.DRIVER,
|
||||
InquiryParticipantRole.VEHICLE_OWNER,
|
||||
InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER,
|
||||
...(caseType === BlameRequestType.CAR_BODY
|
||||
? [InquiryParticipantRole.CAR_BODY_POLICYHOLDER]
|
||||
: []),
|
||||
];
|
||||
const participants = new Map<string, ResolvedInquiryParticipant>();
|
||||
const resolvedRoleIds = new Map<InquiryParticipantRole, string>();
|
||||
const resolving = new Set<InquiryParticipantRole>();
|
||||
|
||||
const resolveRole = (role: InquiryParticipantRole): string => {
|
||||
const existing = resolvedRoleIds.get(role);
|
||||
if (existing) return existing;
|
||||
if (resolving.has(role)) {
|
||||
throw new BadRequestException(
|
||||
"Participant sameAs references cannot be circular.",
|
||||
);
|
||||
}
|
||||
const value = input[ROLE_FIELDS[role]] as
|
||||
| InquiryParticipantInputDto
|
||||
| undefined;
|
||||
if (!value) throw new BadRequestException(`${role} is required.`);
|
||||
|
||||
resolving.add(role);
|
||||
let participantId: string;
|
||||
if (value.sameAs) {
|
||||
if (value.nationalCode != null || value.birthday != null) {
|
||||
throw new BadRequestException(
|
||||
`${role} must contain either sameAs or identity fields, not both.`,
|
||||
);
|
||||
}
|
||||
participantId = resolveRole(value.sameAs);
|
||||
} else {
|
||||
const participant = requiredIdentity(role, value);
|
||||
const duplicate = [...participants.values()].find(
|
||||
(item) => item.nationalCode === participant.nationalCode,
|
||||
);
|
||||
if (duplicate) {
|
||||
throw new BadRequestException(
|
||||
`${role} duplicates an existing nationalCode; use sameAs instead.`,
|
||||
);
|
||||
}
|
||||
participants.set(participant.participantId, participant);
|
||||
participantId = participant.participantId;
|
||||
}
|
||||
resolving.delete(role);
|
||||
resolvedRoleIds.set(role, participantId);
|
||||
return participantId;
|
||||
};
|
||||
|
||||
for (const role of requiredRoles) resolveRole(role);
|
||||
|
||||
return {
|
||||
participants: [...participants.values()],
|
||||
roles: {
|
||||
driver: resolvedRoleIds.get(InquiryParticipantRole.DRIVER)!,
|
||||
vehicleOwner: resolvedRoleIds.get(InquiryParticipantRole.VEHICLE_OWNER)!,
|
||||
thirdPartyPolicyholder: resolvedRoleIds.get(
|
||||
InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER,
|
||||
)!,
|
||||
...(caseType === BlameRequestType.CAR_BODY
|
||||
? {
|
||||
carBodyPolicyholder: resolvedRoleIds.get(
|
||||
InquiryParticipantRole.CAR_BODY_POLICYHOLDER,
|
||||
)!,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
legacy: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveInquiryVehicle(
|
||||
input: InquiryVehicleInputDto,
|
||||
): InquiryVehicleInputDto {
|
||||
const registrationState =
|
||||
input.registrationState ?? VehicleRegistrationState.CURRENT;
|
||||
if (!input.currentPlate) {
|
||||
throw new BadRequestException("vehicle.currentPlate is required.");
|
||||
}
|
||||
if (
|
||||
registrationState === VehicleRegistrationState.RECENTLY_TRANSFERRED &&
|
||||
(!input.previousPlate || !String(input.vin ?? "").trim())
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"RECENTLY_TRANSFERRED requires previousPlate and vin.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
registrationState !== VehicleRegistrationState.RECENTLY_TRANSFERRED &&
|
||||
input.previousPlate
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"previousPlate is only allowed for RECENTLY_TRANSFERRED vehicles.",
|
||||
);
|
||||
}
|
||||
return { ...input, registrationState };
|
||||
}
|
||||
|
||||
export function vehiclePlateCandidates(input?: InquiryVehicleInputDto): Array<{
|
||||
kind: "CURRENT" | "PREVIOUS";
|
||||
plate: InquiryVehicleInputDto["currentPlate"];
|
||||
}> {
|
||||
if (!input) return [];
|
||||
return [
|
||||
{ kind: "CURRENT" as const, plate: input.currentPlate },
|
||||
...(input.registrationState ===
|
||||
VehicleRegistrationState.RECENTLY_TRANSFERRED && input.previousPlate
|
||||
? [{ kind: "PREVIOUS" as const, plate: input.previousPlate }]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeVehicleSerial(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
export function assertPreviousPlateInquiryMatchesVin(
|
||||
expectedVin: string,
|
||||
mapped: Record<string, any>,
|
||||
): void {
|
||||
const expected = normalizeVehicleSerial(expectedVin);
|
||||
const candidates = [
|
||||
mapped?.VinNumberField,
|
||||
mapped?.vin,
|
||||
mapped?.VIN,
|
||||
mapped?.ChassisNumberField,
|
||||
mapped?.chassisNumber,
|
||||
mapped?.ChassisNo,
|
||||
mapped?.vehicle?.VIN,
|
||||
mapped?.vehicle?.ChassisNo,
|
||||
]
|
||||
.map(normalizeVehicleSerial)
|
||||
.filter(Boolean);
|
||||
if (!expected || !candidates.includes(expected)) {
|
||||
throw new BadRequestException(
|
||||
"Previous-plate inquiry does not match the submitted VIN/chassis; manual review is required.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPlateInquiryWithFallback<T>(options: {
|
||||
vehicle?: InquiryVehicleInputDto;
|
||||
fallbackCurrentPlate: InquiryVehicleInputDto["currentPlate"];
|
||||
query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise<T>;
|
||||
isUsable: (value: T) => boolean;
|
||||
mappedValue: (value: T) => Record<string, any>;
|
||||
}): Promise<{
|
||||
value: T;
|
||||
plateKind: "CURRENT" | "PREVIOUS";
|
||||
attempts: Array<{
|
||||
plateKind: "CURRENT" | "PREVIOUS";
|
||||
succeeded: boolean;
|
||||
usable?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
}> {
|
||||
const candidates = options.vehicle
|
||||
? vehiclePlateCandidates(options.vehicle)
|
||||
: [{ kind: "CURRENT" as const, plate: options.fallbackCurrentPlate }];
|
||||
let lastError: unknown;
|
||||
const attempts: Array<{
|
||||
plateKind: "CURRENT" | "PREVIOUS";
|
||||
succeeded: boolean;
|
||||
usable?: boolean;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (let index = 0; index < candidates.length; index += 1) {
|
||||
const candidate = candidates[index];
|
||||
const isLast = index === candidates.length - 1;
|
||||
try {
|
||||
const value = await options.query(candidate.plate);
|
||||
const usable = options.isUsable(value);
|
||||
if (!usable) {
|
||||
attempts.push({
|
||||
plateKind: candidate.kind,
|
||||
succeeded: true,
|
||||
usable: false,
|
||||
});
|
||||
if (!isLast) continue;
|
||||
return { value, plateKind: candidate.kind, attempts };
|
||||
}
|
||||
if (candidate.kind === "PREVIOUS" && usable) {
|
||||
assertPreviousPlateInquiryMatchesVin(
|
||||
options.vehicle!.vin!,
|
||||
options.mappedValue(value),
|
||||
);
|
||||
}
|
||||
attempts.push({
|
||||
plateKind: candidate.kind,
|
||||
succeeded: true,
|
||||
usable: true,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new BadRequestException("Inquiry failed for all plates.");
|
||||
}
|
||||
|
||||
export function normalizeInquirySubmission<T extends Record<string, any>>(
|
||||
caseType: BlameRequestType,
|
||||
input: T,
|
||||
): NormalizedInquirySubmission<T> {
|
||||
const participants = resolveInquiryParticipants(caseType, input);
|
||||
const driver = participantForRole(
|
||||
participants,
|
||||
InquiryParticipantRole.DRIVER,
|
||||
);
|
||||
const thirdPartyPolicyholder = participantForRole(
|
||||
participants,
|
||||
InquiryParticipantRole.THIRD_PARTY_POLICYHOLDER,
|
||||
);
|
||||
if (!driver || !thirdPartyPolicyholder) {
|
||||
throw new BadRequestException(
|
||||
"Driver and third-party policyholder identities are required.",
|
||||
);
|
||||
}
|
||||
const vehicleOwner = participantForRole(
|
||||
participants,
|
||||
InquiryParticipantRole.VEHICLE_OWNER,
|
||||
);
|
||||
const carBodyPolicyholder = participantForRole(
|
||||
participants,
|
||||
InquiryParticipantRole.CAR_BODY_POLICYHOLDER,
|
||||
);
|
||||
const vehicle = input.vehicle
|
||||
? resolveInquiryVehicle(input.vehicle as InquiryVehicleInputDto)
|
||||
: undefined;
|
||||
const sameDriverAndPolicyholder =
|
||||
participants.roles.driver === participants.roles.thirdPartyPolicyholder;
|
||||
|
||||
return {
|
||||
dto: {
|
||||
...input,
|
||||
...(vehicle?.currentPlate && !input.plate
|
||||
? { plate: vehicle.currentPlate }
|
||||
: {}),
|
||||
...(vehicle?.vin && !input.vin ? { vin: vehicle.vin } : {}),
|
||||
nationalCodeOfDriver: driver.nationalCode,
|
||||
driverBirthday: driver.birthday,
|
||||
driverLicense: driver.licenseNumber,
|
||||
licenseType: driver.licenseType,
|
||||
userNoCertificate:
|
||||
driver.hasDrivingLicense == null
|
||||
? input.userNoCertificate
|
||||
: !driver.hasDrivingLicense,
|
||||
nationalCodeOfInsurer: thirdPartyPolicyholder.nationalCode,
|
||||
insurerBirthday: thirdPartyPolicyholder.birthday,
|
||||
driverIsInsurer: sameDriverAndPolicyholder,
|
||||
insurerLicense: sameDriverAndPolicyholder
|
||||
? driver.licenseNumber
|
||||
: input.insurerLicense,
|
||||
} as NormalizedInquirySubmission<T>["dto"],
|
||||
participants,
|
||||
driver,
|
||||
vehicleOwner,
|
||||
thirdPartyPolicyholder,
|
||||
carBodyPolicyholder,
|
||||
vehicle,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user