This commit is contained in:
SepehrYahyaee
2026-05-12 10:26:04 +03:30
parent 07c4e5126a
commit e89cf107ff
3 changed files with 169 additions and 0 deletions

View File

@@ -7,6 +7,13 @@ import {
} from "src/client/dto/create-client.dto";
import { ClientDbService } from "./entities/db-service/client.db.service";
/**
* System-wide default applied when a client document has no
* `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the
* gate is enforced even for older / unconfigured clients.
*/
export const DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS = 7;
@Injectable()
export class ClientService {
constructor(private readonly clientDbService: ClientDbService) {}
@@ -53,4 +60,34 @@ export class ClientService {
const list = client.map((element) => new ClientLists(element));
return list;
}
/**
* Resolve the per-client CAR_BODY accident-age window (in days).
*
* Falls back to {@link DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS} when:
* - no client id is supplied,
* - the id is malformed,
* - the client document is missing,
* - the client has no `settings.carBodyAccidentMaxAgeDays` configured,
* - or the configured value is not a positive finite number.
*/
async getCarBodyAccidentMaxAgeDays(
clientId?: string | Types.ObjectId | null,
): Promise<number> {
if (!clientId) return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
const idString = String(clientId);
if (!Types.ObjectId.isValid(idString)) {
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
}
const client = await this.clientDbService.findOne({
_id: new Types.ObjectId(idString),
});
const configured = client?.settings?.carBodyAccidentMaxAgeDays;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
return configured;
}
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
}
}

View File

@@ -2,6 +2,21 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
export type ClientDocument = ClientModel & Document;
/**
* Per-tenant tunables. Add new policy fields here; consumers read them via
* `ClientService` with documented defaults so older client documents keep
* working when a field is missing.
*/
export class ClientSettings {
/**
* Max number of days between the accident and the moment a CAR_BODY blame
* file is allowed to be submitted. When unset, consumers fall back to the
* system default (see {@link ClientService.getCarBodyAccidentMaxAgeDays}).
*/
@Prop({ type: Number, required: false })
carBodyAccidentMaxAgeDays?: number;
}
@Schema({ collection: "clients", versionKey: false })
export class ClientModel {
@Prop({ required: true, unique: true, type: Object })
@@ -20,6 +35,9 @@ export class ClientModel {
@Prop({ required: true, unique: false })
clientCode: number;
@Prop({ type: ClientSettings, required: false, default: {} })
settings?: ClientSettings;
}
export const ClientDbSchema = SchemaFactory.createForClass(ClientModel);

View File

@@ -327,6 +327,102 @@ export class RequestManagementService {
private readonly userAuthService: UserAuthService,
) { }
/**
* Reject CAR_BODY submissions whose accident is older than the per-client
* window (see `ClientService.getCarBodyAccidentMaxAgeDays`). The check is
* scoped to CAR_BODY because THIRD_PARTY files do not record accidentDate
* during the description step.
*
* `clientId` is best-effort — pass `party.person.clientId`,
* `firstPartyDetails.firstPartyClient.clientId`, the SandHub-resolved
* `client._id`, etc., whatever is available at the call site. When the
* value is missing or invalid the helper falls back to the system default
* window so the gate is still enforced.
*
* Throws `BadRequestException` when the accident instant cannot be parsed,
* is in the future, or is older than the configured window.
*/
private async assertCarBodyAccidentWithinWindow(params: {
clientId?: string | Types.ObjectId | null;
accidentDate: Date | string | null | undefined;
accidentTime?: string | null;
}): Promise<void> {
const { clientId, accidentDate, accidentTime } = params;
if (accidentDate == null) {
throw new BadRequestException({
code: "CAR_BODY_ACCIDENT_DATE_REQUIRED",
message:
"CAR_BODY files require an accident date to enforce the submission window.",
});
}
const instant = this.parseAccidentInstant(
accidentDate,
accidentTime ?? undefined,
);
if (!instant || Number.isNaN(instant.getTime())) {
throw new BadRequestException({
code: "CAR_BODY_ACCIDENT_DATE_INVALID",
message: `Invalid accidentDate/accidentTime: "${String(accidentDate)}"${
accidentTime ? ` "${accidentTime}"` : ""
}.`,
});
}
const ageMs = Date.now() - instant.getTime();
if (ageMs < 0) {
throw new BadRequestException({
code: "CAR_BODY_ACCIDENT_DATE_IN_FUTURE",
message: "Accident date cannot be in the future.",
});
}
const maxAgeDays = await this.clientService.getCarBodyAccidentMaxAgeDays(
clientId ?? undefined,
);
const ageDays = ageMs / (24 * 60 * 60 * 1000);
if (ageDays > maxAgeDays) {
throw new BadRequestException({
code: "CAR_BODY_ACCIDENT_TOO_OLD",
message:
`CAR_BODY files must be submitted within ${maxAgeDays} day(s) of the accident. ` +
`This accident occurred about ${Math.floor(ageDays)} day(s) ago.`,
maxAgeDays,
ageDays: Math.floor(ageDays),
});
}
}
/**
* Best-effort parser that combines a date input with an optional `HH:MM`
* time. Accepts a `Date`, an ISO datetime string, or an ISO date-only
* string so DTO payloads (which currently send `"YYYY-MM-DD"` + `"HH:MM"`)
* can be passed through directly. Returns `null` when the result is not a
* finite instant.
*/
private parseAccidentInstant(
date: Date | string,
time?: string,
): Date | null {
if (date instanceof Date) {
return Number.isNaN(date.getTime()) ? null : date;
}
const raw = String(date).trim();
if (!raw) return null;
// Already a full datetime — trust it and ignore the separate time field.
if (raw.includes("T") || /\s\d{1,2}:\d{2}/.test(raw)) {
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
}
const cleanTime = (time ?? "").trim();
const iso = cleanTime ? `${raw}T${cleanTime}` : raw;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* Linked claim cases: block user on damage flow while blame awaits document resend.
*/
@@ -1226,6 +1322,13 @@ export class RequestManagementService {
"CAR_BODY description step requires desc, accidentDate, accidentTime, weatherCondition, roadCondition, and lightCondition.",
);
}
// Gate stale CAR_BODY submissions per the party's client window
// (falls back to the system default when no client is attached yet).
await this.assertCarBodyAccidentWithinWindow({
clientId: party.person?.clientId,
accidentDate: body.accidentDate,
accidentTime: body.accidentTime,
});
party.statement.accidentDate = body.accidentDate as any;
party.statement.accidentTime = body.accidentTime;
(party.statement as any).weatherCondition = body.weatherCondition;
@@ -4506,6 +4609,17 @@ export class RequestManagementService {
throw new NotFoundException(`Client not found for company: ${clientName}`);
}
// Gate stale CAR_BODY submissions using the resolved client's window
// (V2 expert-initiated path always supplies accidentDate/accidentTime
// via formData.expertDescription).
if (formData?.expertDescription?.accidentDate) {
await this.assertCarBodyAccidentWithinWindow({
clientId: (client as any)?._id,
accidentDate: formData.expertDescription.accidentDate,
accidentTime: formData.expertDescription.accidentTime,
});
}
const carBodyInfo = await this.mockCarBodyInsuranceInquiry(
requestId,
firstPartyPlate.plate,