From e89cf107ff4dc63ac50c4e9a84e902d2faffa7e0 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Tue, 12 May 2026 10:26:04 +0330 Subject: [PATCH] YARA-899 --- src/client/client.service.ts | 37 ++++++ src/client/entities/schema/client.schema.ts | 18 +++ .../request-management.service.ts | 114 ++++++++++++++++++ 3 files changed, 169 insertions(+) diff --git a/src/client/client.service.ts b/src/client/client.service.ts index fc8db42..8be15e6 100644 --- a/src/client/client.service.ts +++ b/src/client/client.service.ts @@ -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 { + 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; + } } diff --git a/src/client/entities/schema/client.schema.ts b/src/client/entities/schema/client.schema.ts index 185c476..8ab98b2 100644 --- a/src/client/entities/schema/client.schema.ts +++ b/src/client/entities/schema/client.schema.ts @@ -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); diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 7fbb29b..7d54661 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -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 { + 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,