diff --git a/src/auth/auth-controllers/user/user.auth.controller.ts b/src/auth/auth-controllers/user/user.auth.controller.ts index 25f5694..732c483 100644 --- a/src/auth/auth-controllers/user/user.auth.controller.ts +++ b/src/auth/auth-controllers/user/user.auth.controller.ts @@ -26,7 +26,10 @@ export class UserAuthController { }) @ApiAcceptedResponse() async sendOtpRq(@Body() body: UserLoginDto) { - const res = await this.userAuthService.sendOtpRequest(body.mobile); + const res = await this.userAuthService.sendOtpRequest(body.mobile, { + linkToken: body.linkToken, + linkContext: body.linkContext, + }); if (res) { throw new HttpException(res, HttpStatus.ACCEPTED); } diff --git a/src/auth/auth-services/user-auth-error.ts b/src/auth/auth-services/user-auth-error.ts new file mode 100644 index 0000000..e663ea4 --- /dev/null +++ b/src/auth/auth-services/user-auth-error.ts @@ -0,0 +1,46 @@ +import { + BadRequestException, + ForbiddenException, + UnauthorizedException, +} from "@nestjs/common"; + +export enum UserAuthErrorCode { + USER_NOT_FOUND = "USER_NOT_FOUND", + OTP_REQUIRED = "OTP_REQUIRED", + OTP_EXPIRED = "OTP_EXPIRED", + OTP_INVALID = "OTP_INVALID", + OTP_REQUEST_TOO_SOON = "OTP_REQUEST_TOO_SOON", + LINK_NOT_FOUND = "LINK_NOT_FOUND", + LINK_MOBILE_MISMATCH = "LINK_MOBILE_MISMATCH", +} + +const messages: Record = { + [UserAuthErrorCode.USER_NOT_FOUND]: "User not found", + [UserAuthErrorCode.OTP_REQUIRED]: "Please request an OTP first", + [UserAuthErrorCode.OTP_EXPIRED]: "OTP has expired", + [UserAuthErrorCode.OTP_INVALID]: "OTP is invalid", + [UserAuthErrorCode.OTP_REQUEST_TOO_SOON]: + "Wait for expiry time to finish before requesting another OTP", + [UserAuthErrorCode.LINK_NOT_FOUND]: "Linked SMS token was not found", + [UserAuthErrorCode.LINK_MOBILE_MISMATCH]: + "This mobile number is not allowed to use this SMS link", +}; + +export function userAuthErrorBody(code: UserAuthErrorCode) { + return { + code, + message: messages[code], + }; +} + +export function throwUserAuthError(code: UserAuthErrorCode): never { + if (code === UserAuthErrorCode.OTP_REQUEST_TOO_SOON) { + throw new BadRequestException(userAuthErrorBody(code)); + } + + if (code === UserAuthErrorCode.LINK_MOBILE_MISMATCH) { + throw new ForbiddenException(userAuthErrorBody(code)); + } + + throw new UnauthorizedException(userAuthErrorBody(code)); +} diff --git a/src/auth/auth-services/user-link-access.service.ts b/src/auth/auth-services/user-link-access.service.ts new file mode 100644 index 0000000..731441e --- /dev/null +++ b/src/auth/auth-services/user-link-access.service.ts @@ -0,0 +1,187 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { Model, Types } from "mongoose"; +import { + UserAuthErrorCode, + throwUserAuthError, +} from "src/auth/auth-services/user-auth-error"; +import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema"; +import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema"; +import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; +import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema"; +import { UserDbService } from "src/users/entities/db-service/user.db.service"; + +@Injectable() +export class UserLinkAccessService { + constructor( + @InjectModel(RequestManagementModel.name) + private readonly requestManagementModel: Model, + @InjectModel(BlameRequest.name) + private readonly blameRequestModel: Model, + @InjectModel(ClaimRequestManagementModel.name) + private readonly claimRequestManagementModel: Model, + private readonly userDbService: UserDbService, + ) {} + + async assertMobileAllowed(params: { + mobile: string; + linkToken?: string; + linkContext?: string; + }): Promise { + const linkToken = params.linkToken?.trim(); + if (!linkToken) return; + + const allowedMobiles = await this.resolveAllowedMobiles( + linkToken, + params.linkContext, + ); + if (allowedMobiles.length === 0) { + throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND); + } + + const normalizedMobile = this.normalizePhone(params.mobile); + if ( + !normalizedMobile || + !allowedMobiles.some((mobile) => this.normalizePhone(mobile) === normalizedMobile) + ) { + throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH); + } + } + + async resolveAllowedMobiles( + linkToken: string, + linkContext?: string, + ): Promise { + if (!Types.ObjectId.isValid(linkToken)) return []; + + const id = new Types.ObjectId(linkToken); + const context = this.normalizeContext(linkContext); + const allowedMobiles = new Set(); + + const [legacyRequest, blameRequest, claimRequest] = await Promise.all([ + this.requestManagementModel.findById(id).lean().exec(), + this.blameRequestModel.findById(id).lean().exec(), + this.claimRequestManagementModel.findById(id).lean().exec(), + ]); + + this.addLegacyRequestPhones(allowedMobiles, legacyRequest, context); + this.addBlameRequestPhones(allowedMobiles, blameRequest, context); + await this.addClaimOwnerPhone(allowedMobiles, claimRequest); + + return Array.from(allowedMobiles); + } + + private addLegacyRequestPhones( + allowedMobiles: Set, + legacyRequest: any, + context?: string, + ) { + if (!legacyRequest) return; + + const shouldAddFirst = !context || this.isFirstContext(context); + const shouldAddSecond = !context || this.isSecondContext(context); + + if (shouldAddFirst) { + this.addPhone( + allowedMobiles, + legacyRequest.firstPartyDetails?.firstPartyPhoneNumber, + ); + } + + if (shouldAddSecond) { + this.addPhone( + allowedMobiles, + legacyRequest.secondPartyDetails?.secondPartyPhoneNumber, + ); + } + + for (const event of legacyRequest.history || []) { + const metadata = event?.metadata; + if (shouldAddSecond) this.addPhone(allowedMobiles, metadata?.secondPartyPhone); + for (const sent of metadata?.sentTo || []) { + if (!context || this.matchesRoleContext(context, sent?.role)) { + this.addPhone(allowedMobiles, sent?.phoneNumber); + } + } + } + } + + private addBlameRequestPhones( + allowedMobiles: Set, + blameRequest: any, + context?: string, + ) { + if (!blameRequest) return; + + for (const party of blameRequest.parties || []) { + if (context && !this.matchesRoleContext(context, party?.role)) continue; + this.addPhone(allowedMobiles, party?.person?.phoneNumber); + } + } + + private async addClaimOwnerPhone( + allowedMobiles: Set, + claimRequest: any, + ) { + if (!claimRequest) return; + + const ownerUserId = claimRequest.owner?.userId || claimRequest.userId; + if (!ownerUserId) return; + + const ownerUserIdText = String(ownerUserId); + if (claimRequest.blameRequestId) { + const blameRequest = await this.blameRequestModel + .findById(claimRequest.blameRequestId) + .lean() + .exec(); + const ownerParty = (blameRequest?.parties || []).find( + (party: any) => + party?.person?.userId && String(party.person.userId) === ownerUserIdText, + ); + this.addPhone(allowedMobiles, ownerParty?.person?.phoneNumber); + } + + if (Types.ObjectId.isValid(ownerUserIdText)) { + const user = await this.userDbService.findOne({ + _id: new Types.ObjectId(ownerUserIdText), + }); + this.addPhone(allowedMobiles, user?.mobile); + } + } + + private addPhone(allowedMobiles: Set, phone?: string) { + const normalized = this.normalizePhone(phone); + if (normalized) allowedMobiles.add(normalized); + } + + private normalizePhone(phone?: string): string | undefined { + if (!phone) return undefined; + + const digits = String(phone).replace(/\D/g, ""); + if (!digits) return undefined; + if (digits.startsWith("0098")) return `0${digits.slice(4)}`; + if (digits.startsWith("98") && digits.length === 12) { + return `0${digits.slice(2)}`; + } + if (digits.length === 10 && digits.startsWith("9")) return `0${digits}`; + return digits; + } + + private normalizeContext(linkContext?: string): string | undefined { + return linkContext?.trim().toUpperCase(); + } + + private matchesRoleContext(context: string, role?: string): boolean { + if (this.isFirstContext(context)) return role === PartyRole.FIRST; + if (this.isSecondContext(context)) return role === PartyRole.SECOND; + return true; + } + + private isFirstContext(context: string): boolean { + return ["FIRST", "USER", "USER1", "FIRST_PARTY"].includes(context); + } + + private isSecondContext(context: string): boolean { + return ["SECOND", "USER2", "SECOND_PARTY"].includes(context); + } +} diff --git a/src/auth/auth-services/user.auth.service.ts b/src/auth/auth-services/user.auth.service.ts index 35246d1..035de71 100644 --- a/src/auth/auth-services/user.auth.service.ts +++ b/src/auth/auth-services/user.auth.service.ts @@ -1,20 +1,22 @@ -import { - BadRequestException, - HttpException, - HttpStatus, - Injectable, - Logger, - NotAcceptableException, - NotFoundException, -} from "@nestjs/common"; +import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { Types } from "mongoose"; +import { + UserAuthErrorCode, + throwUserAuthError, +} from "src/auth/auth-services/user-auth-error"; +import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service"; import { LoginDtoRs } from "src/auth/dto/user/login.dto"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { HashService } from "src/utils/hash/hash.service"; +export interface LinkBinding { + linkToken?: string; + linkContext?: string; +} + // TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE @Injectable() export class UserAuthService { @@ -26,16 +28,27 @@ export class UserAuthService { private readonly hashService: HashService, private readonly otpCreator: OtpGeneratorService, private readonly smsOrchestrationService: SmsOrchestrationService, + private readonly userLinkAccessService: UserLinkAccessService, ) {} - async validateUser(username: string, pass: string): Promise { + async validateUser( + username: string, + pass: string, + binding: LinkBinding = {}, + ): Promise { + await this.userLinkAccessService.assertMobileAllowed({ + mobile: username, + linkToken: binding.linkToken, + linkContext: binding.linkContext, + }); + const user = await this.userDbService.findOne({ username }); - if (!user) throw new NotFoundException("user not found"); + if (!user) throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND); const now = new Date().getTime(); - if (user.otp == null) throw new NotAcceptableException("please get otp"); + if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED); if (user.otpExpire < now) { - throw new NotAcceptableException("expire otp"); + throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED); } if (await this.hashService.compare(pass, user.otp)) { return user; @@ -65,7 +78,16 @@ export class UserAuthService { }; } - async sendOtpRequest(mobile: string): Promise { + async sendOtpRequest( + mobile: string, + binding: LinkBinding = {}, + ): Promise { + await this.userLinkAccessService.assertMobileAllowed({ + mobile, + linkToken: binding.linkToken, + linkContext: binding.linkContext, + }); + const userExist = await this.userDbService.findOne({ mobile, }); @@ -101,7 +123,9 @@ export class UserAuthService { return new LoginDtoRs(newUser); } if (userExist) { - if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) throw new BadRequestException("Wait for expiry time to finish"); + if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) { + throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON); + } await this.smsSender(otp, mobile); const updateTokens = await this.userDbService.findOneAndUpdate( { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 5eee53e..5bd17a0 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { JwtModule, JwtService } from "@nestjs/jwt"; +import { MongooseModule } from "@nestjs/mongoose"; import { PassportModule } from "@nestjs/passport"; import { LocalStrategy } from "src/auth/stratregys/local.strategy"; import { LocalActorStrategy } from "src/auth/stratregys/local-actor.strategy"; @@ -7,7 +8,20 @@ import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth. import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller"; import { ActorAuthService } from "src/auth/auth-services/actor.auth.service"; import { UserAuthService } from "src/auth/auth-services/user.auth.service"; +import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service"; +import { + ClaimRequestManagementModel, + ClaimRequestManagementSchema, +} from "src/claim-request-management/entites/schema/claim-request-management.schema"; import { ClientModule } from "src/client/client.module"; +import { + BlameRequest, + BlameRequestSchema, +} from "src/request-management/entities/schema/blame-cases.schema"; +import { + RequestManagementModel, + RequestManagementSchema, +} from "src/request-management/entities/schema/request-management.schema"; import { UsersModule } from "src/users/users.module"; import { HashModule } from "src/utils/hash/hash.module"; // import { MailModule } from "src/utils/mail/mail.module"; @@ -21,6 +35,14 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration. HashModule, PassportModule, SmsOrchestrationModule, + MongooseModule.forFeature([ + { name: RequestManagementModel.name, schema: RequestManagementSchema }, + { name: BlameRequest.name, schema: BlameRequestSchema }, + { + name: ClaimRequestManagementModel.name, + schema: ClaimRequestManagementSchema, + }, + ]), JwtModule.register({ signOptions: { expiresIn: "1h" }, global: true, @@ -29,6 +51,7 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration. ], providers: [ UserAuthService, + UserLinkAccessService, ActorAuthService, LocalStrategy, LocalActorStrategy, diff --git a/src/auth/dto/user/login.dto.ts b/src/auth/dto/user/login.dto.ts index 6a8ffc4..e8a9ca2 100644 --- a/src/auth/dto/user/login.dto.ts +++ b/src/auth/dto/user/login.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { UserModel } from "src/users/entities/schema/user.schema"; export class UserLoginDto { @@ -8,6 +8,20 @@ export class UserLoginDto { description: "User login dto", }) mobile: string; + + @ApiPropertyOptional({ + example: "65f0c7f0c3f8a2a7c8b3d001", + type: "string", + description: "Raw token from linked SMS URL (?token=...).", + }) + linkToken?: string; + + @ApiPropertyOptional({ + example: "FIRST", + type: "string", + description: "Optional route/context hint for linked SMS login.", + }) + linkContext?: string; } export class LoginDtoRs extends UserModel { diff --git a/src/auth/dto/user/verify.dto.ts b/src/auth/dto/user/verify.dto.ts index 24428c4..abf46f3 100644 --- a/src/auth/dto/user/verify.dto.ts +++ b/src/auth/dto/user/verify.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; export class UserVerifyOtp { @ApiProperty({ @@ -14,4 +14,18 @@ export class UserVerifyOtp { description: "User login verify dto", }) password: string; + + @ApiPropertyOptional({ + example: "65f0c7f0c3f8a2a7c8b3d001", + type: "string", + description: "Raw token from linked SMS URL (?token=...).", + }) + linkToken?: string; + + @ApiPropertyOptional({ + example: "FIRST", + type: "string", + description: "Optional route/context hint for linked SMS login.", + }) + linkContext?: string; } diff --git a/src/auth/guards/user-local.guard.ts b/src/auth/guards/user-local.guard.ts index 7d7e912..c4c5108 100644 --- a/src/auth/guards/user-local.guard.ts +++ b/src/auth/guards/user-local.guard.ts @@ -1,9 +1,9 @@ -import { - ExecutionContext, - Injectable, - NotAcceptableException, -} from "@nestjs/common"; +import { ExecutionContext, Injectable } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; +import { + UserAuthErrorCode, + throwUserAuthError, +} from "src/auth/auth-services/user-auth-error"; import { UserAuthService } from "src/auth/auth-services/user.auth.service"; @Injectable() @@ -13,13 +13,14 @@ export class LocalUserAuthGuard extends AuthGuard("local") { } async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); - const { username, password } = request.body; - let isValidUser = await this.userAuthService.validateUser( + const { username, password, linkToken, linkContext } = request.body; + const isValidUser = await this.userAuthService.validateUser( username, password, + { linkToken, linkContext }, ); if (!isValidUser) { - throw new NotAcceptableException("otp is wrong"); + throwUserAuthError(UserAuthErrorCode.OTP_INVALID); } request["user"] = isValidUser; return true; diff --git a/src/auth/stratregys/local.strategy.ts b/src/auth/stratregys/local.strategy.ts index 2d6a8bf..7f8ec30 100644 --- a/src/auth/stratregys/local.strategy.ts +++ b/src/auth/stratregys/local.strategy.ts @@ -1,6 +1,10 @@ -import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { PassportStrategy } from "@nestjs/passport"; import { Strategy } from "passport-local"; +import { + UserAuthErrorCode, + throwUserAuthError, +} from "src/auth/auth-services/user-auth-error"; import { UserAuthService } from "src/auth/auth-services/user.auth.service"; @Injectable() @@ -12,7 +16,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) { async validate(username: string, password: string): Promise { const user = await this.userAuthService.validateUser(username, password); if (!user) { - throw new UnauthorizedException("user not found please register"); + throwUserAuthError(UserAuthErrorCode.OTP_INVALID); } return user; } diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index f11fcb2..43a8711 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -46,6 +46,7 @@ import { PublicIdModule } from "src/utils/public-id/public-id.module"; import { ClientModule } from "src/client/client.module"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { JwtModule } from "@nestjs/jwt"; +import { MediaPolicyModule } from "src/media-policy/media-policy.module"; @Module({ imports: [ @@ -55,6 +56,7 @@ import { JwtModule } from "@nestjs/jwt"; AiModule, SandHubModule, ClientModule, + MediaPolicyModule, JwtModule.register({}), MongooseModule.forFeature([ { name: ClaimCase.name, schema: ClaimCaseSchema }, diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index 297d612..8b533f1 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -24,6 +24,7 @@ import { GlobalGuard } from "src/auth/guards/global.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; import { Roles } from "src/decorators/roles.decorator"; import { CurrentUser } from "src/decorators/user.decorator"; +import { MediaPolicyService } from "src/media-policy/media-policy.service"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { ClaimRequestManagementService } from "./claim-request-management.service"; import { @@ -53,6 +54,7 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; export class ClaimRequestManagementV2Controller { constructor( private readonly claimRequestManagementService: ClaimRequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, ) {} @Get("requests") @@ -300,6 +302,7 @@ export class ClaimRequestManagementV2Controller { if (!Types.ObjectId.isValid(claimRequestId)) { throw new BadRequestException("Invalid claim request id"); } + await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image"); const agreed = typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree); try { @@ -612,6 +615,8 @@ Optional: upload car green card file in the same step. @CurrentUser() user: any, @UploadedFile() file?: Express.Multer.File, ): Promise { + // Green-card photo is optional here — the helper no-ops on missing file. + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.selectOtherPartsV2( claimRequestId, @@ -776,6 +781,7 @@ Returns status of each item (uploaded/captured or not). @UploadedFile() file: Express.Multer.File, @CurrentUser() user: any, ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.uploadRequiredDocumentV2( claimRequestId, @@ -870,6 +876,7 @@ Returns status of each item (uploaded/captured or not). @UploadedFile() file: Express.Multer.File, @CurrentUser() user: any, ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.capturePartV2( claimRequestId, @@ -927,6 +934,7 @@ Returns status of each item (uploaded/captured or not). @UploadedFile() file: Express.Multer.File, @CurrentUser() user: any, ) { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); try { return await this.claimRequestManagementService.uploadClaimFactorV2( claimRequestId, @@ -996,6 +1004,7 @@ Returns status of each item (uploaded/captured or not). @UploadedFile("file") file: Express.Multer.File, @CurrentUser() user: any, ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video"); try { return await this.claimRequestManagementService.setVideoCaptureV2( claimRequestId, diff --git a/src/client/client.service.ts b/src/client/client.service.ts index fc8db42..aa4b479 100644 --- a/src/client/client.service.ts +++ b/src/client/client.service.ts @@ -7,6 +7,41 @@ 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; + +/** + * Media kinds we enforce upload-size bounds for. Each kind has its own + * default window (see {@link DEFAULT_MEDIA_LIMITS}) and an optional + * per-client override under `settings.media.`. + */ +export type MediaKind = "video" | "image" | "voice"; + +export interface MediaLimits { + /** Inclusive lower bound. `0` allows any size from zero up. */ + minBytes: number; + /** Inclusive upper bound. */ + maxBytes: number; +} + +/** + * System defaults applied when a client (or kind) has no explicit override. + * + * Important: each upload route also has a `multer.limits.fileSize` hard + * ceiling — those route ceilings are the absolute maximum the API can + * receive. Per-client `maxBytes` cannot legitimately go above its route's + * multer ceiling, so the defaults below are kept at-or-below those. + */ +export const DEFAULT_MEDIA_LIMITS: Record = { + video: { minBytes: 256 * 1024, maxBytes: 20 * 1024 * 1024 }, + image: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 }, + voice: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 }, +}; + @Injectable() export class ClientService { constructor(private readonly clientDbService: ClientDbService) {} @@ -53,4 +88,71 @@ export class ClientService { const list = client.map((element) => new ClientLists(element)); return list; } + + /** + * Resolve the per-client byte bounds for a media kind. Missing bounds fall + * back to {@link DEFAULT_MEDIA_LIMITS}. If no clientId is supplied (or it + * doesn't resolve to a client document) the defaults are returned. + * + * The returned object is always fully populated — callers can compare + * directly against `file.size`. + */ + async getMediaLimits( + clientId: string | Types.ObjectId | null | undefined, + kind: MediaKind, + ): Promise { + const defaults = DEFAULT_MEDIA_LIMITS[kind]; + if (!clientId) return { ...defaults }; + + const idString = String(clientId); + if (!Types.ObjectId.isValid(idString)) return { ...defaults }; + + const client = await this.clientDbService.findOne({ + _id: new Types.ObjectId(idString), + }); + const override = client?.settings?.media?.[kind]; + const minBytes = + typeof override?.minBytes === "number" && + Number.isFinite(override.minBytes) && + override.minBytes >= 0 + ? override.minBytes + : defaults.minBytes; + const maxBytes = + typeof override?.maxBytes === "number" && + Number.isFinite(override.maxBytes) && + override.maxBytes > 0 + ? override.maxBytes + : defaults.maxBytes; + return { minBytes, maxBytes }; + } + + /** + * 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..16aad76 100644 --- a/src/client/entities/schema/client.schema.ts +++ b/src/client/entities/schema/client.schema.ts @@ -2,6 +2,63 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; export type ClientDocument = ClientModel & Document; +/** + * Per-media (video/image/voice) byte bounds. Either bound is optional so a + * tenant can configure only one side (e.g. raise `maxBytes` without setting + * a floor). When a bound is missing the system-wide default is used (see + * {@link ClientService.getMediaLimits} for the defaults table). + */ +export class ClientMediaLimits { + @Prop({ type: Number, required: false }) + minBytes?: number; + + @Prop({ type: Number, required: false }) + maxBytes?: number; +} + +/** + * Bag of per-media-kind limits. Each kind is optional and any unset values + * fall back to the system default (see `ClientService.getMediaLimits`). + * + * Note: the system also keeps an absolute multer ceiling per upload route + * (e.g. 50MB for car-capture videos, 20MB for blame videos, 10MB for + * voice/signature/image). A client-configured `maxBytes` cannot exceed the + * route's multer ceiling because multer rejects oversize requests before + * the request reaches the policy check. + */ +export class ClientMediaSettings { + @Prop({ type: ClientMediaLimits, required: false }) + video?: ClientMediaLimits; + + @Prop({ type: ClientMediaLimits, required: false }) + image?: ClientMediaLimits; + + @Prop({ type: ClientMediaLimits, required: false }) + voice?: ClientMediaLimits; +} + +/** + * 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; + + /** + * Per-kind media upload bounds (V2 upload endpoints). Unset kinds / + * bounds fall back to the defaults in `ClientService.getMediaLimits`. + */ + @Prop({ type: ClientMediaSettings, required: false }) + media?: ClientMediaSettings; +} + @Schema({ collection: "clients", versionKey: false }) export class ClientModel { @Prop({ required: true, unique: true, type: Object }) @@ -20,6 +77,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/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 9ead291..6dff544 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -82,6 +82,8 @@ import { coerceDamagedPartsMediaToArray, normalizeCarPartDamageForExpertReply, normalizeDamageSelectedParts, + partIdentityKey, + partIdentityKeyFromCarPartDamage, } from "src/helpers/outer-damage-parts"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot"; @@ -2673,6 +2675,17 @@ export class ExpertClaimService { reply.parts?.length > 0 ? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts) : []; + + const ownerSelectedParts = normalizeDamageSelectedParts( + (claim as any)?.damage?.selectedParts, + carTypeSubmit, + (claim as any)?.damage?.selectedOuterParts, + ); + const ownerSelectedIdentitySet = new Set( + ownerSelectedParts.map((p) => partIdentityKey(p)), + ); + const seenIdentities = new Set(); + const processedParts = daghiNormalized.map((p) => { let carPartDamage: Record; try { @@ -2687,7 +2700,31 @@ export class ExpertClaimService { }`, ); } - return { ...p, carPartDamage }; + + const identityKey = partIdentityKeyFromCarPartDamage(carPartDamage); + if (!identityKey) { + throw new BadRequestException( + `Invalid carPartDamage for part ${p.partId}: cannot derive a stable identity (need a catalog id or both name and side).`, + ); + } + + if ( + ownerSelectedIdentitySet.size > 0 && + !ownerSelectedIdentitySet.has(identityKey) + ) { + throw new BadRequestException( + `Part "${identityKey}" is not in the owner's selected damaged parts; only parts the user picked (or added via objection) can be priced.`, + ); + } + + if (seenIdentities.has(identityKey)) { + throw new BadRequestException( + `Duplicate part submitted in expert reply: ${identityKey}.`, + ); + } + seenIdentities.add(identityKey); + + return { ...p, partId: identityKey, carPartDamage }; }); const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts( diff --git a/src/helpers/outer-damage-parts.ts b/src/helpers/outer-damage-parts.ts index fe904dd..27dd684 100644 --- a/src/helpers/outer-damage-parts.ts +++ b/src/helpers/outer-damage-parts.ts @@ -298,6 +298,40 @@ export function partIdentityKey(p: DamageSelectedPartV2): string { return `${p.side}|${p.name}`; } +/** + * Same canonical identity as `partIdentityKey`, but derived directly from a + * unified `carPartDamage` snapshot (the shape we persist on + * `evaluation.damageExpertReply*.parts[].carPartDamage`). + * + * Returns `null` when the input cannot produce an identity (no `id`, no + * `name`). Callers should treat that as an invalid expert reply line. + */ +export function partIdentityKeyFromCarPartDamage( + carPartDamage: unknown, +): string | null { + if ( + !carPartDamage || + typeof carPartDamage !== "object" || + Array.isArray(carPartDamage) + ) { + return null; + } + const o = carPartDamage as Record; + const idRaw = o.id; + const id = + typeof idRaw === "number" && Number.isFinite(idRaw) + ? idRaw + : typeof idRaw === "string" && + idRaw.trim() && + Number.isFinite(Number(idRaw)) + ? Number(idRaw) + : null; + const name = typeof o.name === "string" ? o.name.trim() : ""; + const side = typeof o.side === "string" ? o.side.trim() : ""; + if (id == null && !name) return null; + return partIdentityKey({ id, name, side, label_fa: "" }); +} + function normPartSegment(s: string): string { return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase(); } diff --git a/src/media-policy/media-policy.module.ts b/src/media-policy/media-policy.module.ts new file mode 100644 index 0000000..076ba3b --- /dev/null +++ b/src/media-policy/media-policy.module.ts @@ -0,0 +1,37 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; + +import { ClientModule } from "src/client/client.module"; +import { + ClaimCase, + ClaimCaseSchema, +} from "src/claim-request-management/entites/schema/claim-cases.schema"; +import { + BlameRequest, + BlameRequestSchema, +} from "src/request-management/entities/schema/blame-cases.schema"; + +import { MediaPolicyService } from "./media-policy.service"; + +/** + * Stand-alone module so we can inject `MediaPolicyService` into both the + * blame and claim V2 controllers without dragging the entire + * `RequestManagementModule` / `ClaimRequestManagementModule` graph along + * (which would risk a circular dependency). + * + * The schemas are registered directly via `MongooseModule.forFeature` — + * Mongoose deduplicates models behind the scenes so a second registration + * does not create a separate model. + */ +@Module({ + imports: [ + ClientModule, + MongooseModule.forFeature([ + { name: BlameRequest.name, schema: BlameRequestSchema }, + { name: ClaimCase.name, schema: ClaimCaseSchema }, + ]), + ], + providers: [MediaPolicyService], + exports: [MediaPolicyService], +}) +export class MediaPolicyModule {} diff --git a/src/media-policy/media-policy.service.ts b/src/media-policy/media-policy.service.ts new file mode 100644 index 0000000..6198e37 --- /dev/null +++ b/src/media-policy/media-policy.service.ts @@ -0,0 +1,270 @@ +import { + BadRequestException, + Injectable, + Logger, +} from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { promises as fs } from "node:fs"; +import { Model, Types } from "mongoose"; + +import { + ClientService, + MediaKind, + MediaLimits, +} from "src/client/client.service"; +import { ClaimCase } from "src/claim-request-management/entites/schema/claim-cases.schema"; +import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema"; + +/** + * Shape we expect from a multer-uploaded file. We type loosely because two + * different multer interceptors are in play (`FileInterceptor` and + * `FileFieldsInterceptor`) and we don't want to drag `Express.Multer.File` + * typings into a service file. + */ +type UploadedFileLike = + | (Express.Multer.File & { path?: string }) + | undefined + | null; + +/** + * Resolves the relevant client for a blame/claim file and enforces the + * client's configured byte bounds against an uploaded file. On violation it + * deletes the offending file from disk (best-effort) and throws a + * `BadRequestException` carrying a structured payload the frontend can + * branch on. + * + * Client resolution: + * - Blame endpoints: any party on the `BlameRequest` that has + * `person.clientId` set. We pick the first one we find. + * - Claim endpoints: `ClaimCase.owner.clientId`. + * + * When no clientId can be resolved (early steps, malformed ids, missing + * documents), the helper falls back to the system defaults declared in + * `DEFAULT_MEDIA_LIMITS` so the gate is still enforced. + */ +@Injectable() +export class MediaPolicyService { + private readonly logger = new Logger(MediaPolicyService.name); + + constructor( + @InjectModel(BlameRequest.name) + private readonly blameRequestModel: Model, + @InjectModel(ClaimCase.name) + private readonly claimCaseModel: Model, + private readonly clientService: ClientService, + ) {} + + /** + * Enforce limits for a file uploaded against a `BlameRequest` (V2). + * Safe to call with a `null`/`undefined` file — it just no-ops, leaving + * "file required" errors to be raised by the existing service handlers. + */ + async assertForBlame( + file: UploadedFileLike, + blameRequestId: string, + kind: MediaKind, + ): Promise { + if (!file) return; + const clientId = await this.resolveBlameClientId(blameRequestId); + await this.assertWithClient(file, clientId, kind); + } + + /** + * Enforce limits for a file uploaded against a `ClaimCase` (V2). + * Safe to call with a `null`/`undefined` file. + */ + async assertForClaim( + file: UploadedFileLike, + claimRequestId: string, + kind: MediaKind, + ): Promise { + if (!file) return; + const clientId = await this.resolveClaimClientId(claimRequestId); + await this.assertWithClient(file, clientId, kind); + } + + /** + * Validate a bag of files (as produced by `FileFieldsInterceptor`) against + * the blame file's client. Each field key is mapped to a `MediaKind` via + * `fieldKindMap`; unmapped keys are ignored. On the first violation we + * delete *every* provided file (the request is being rejected anyway) and + * throw. + */ + async assertForBlameMany( + files: Partial>, + blameRequestId: string, + fieldKindMap: Record, + ): Promise { + const provided = this.flattenProvidedFiles(files); + if (provided.length === 0) return; + + const clientId = await this.resolveBlameClientId(blameRequestId); + await this.assertManyWithClient(provided, clientId, fieldKindMap); + } + + /** + * Same as `assertForBlameMany` but for claim files. Use this when (and + * if) a multi-field claim upload endpoint is added; today's claim + * controllers only upload one file at a time. + */ + async assertForClaimMany( + files: Partial>, + claimRequestId: string, + fieldKindMap: Record, + ): Promise { + const provided = this.flattenProvidedFiles(files); + if (provided.length === 0) return; + + const clientId = await this.resolveClaimClientId(claimRequestId); + await this.assertManyWithClient(provided, clientId, fieldKindMap); + } + + // -- internal helpers --------------------------------------------------- + + private async assertWithClient( + file: NonNullable, + clientId: Types.ObjectId | string | null, + kind: MediaKind, + ): Promise { + const limits = await this.clientService.getMediaLimits(clientId, kind); + const violation = this.checkLimits(file, limits, kind); + if (!violation) return; + + await this.tryDelete(file?.path); + throw new BadRequestException(violation); + } + + private async assertManyWithClient( + provided: Array<{ field: TKeys; file: NonNullable }>, + clientId: Types.ObjectId | string | null, + fieldKindMap: Record, + ): Promise { + const limitsCache = new Map(); + const getLimits = async (kind: MediaKind) => { + if (!limitsCache.has(kind)) { + limitsCache.set( + kind, + await this.clientService.getMediaLimits(clientId, kind), + ); + } + return limitsCache.get(kind) as MediaLimits; + }; + + for (const { field, file } of provided) { + const kind = fieldKindMap[field]; + if (!kind) continue; + const limits = await getLimits(kind); + const violation = this.checkLimits(file, limits, kind, field); + if (!violation) continue; + + // The request is being rejected — clean up all uploaded files so we + // don't leave orphans on disk. + await Promise.all(provided.map(({ file: f }) => this.tryDelete(f?.path))); + throw new BadRequestException(violation); + } + } + + private checkLimits( + file: NonNullable, + { minBytes, maxBytes }: MediaLimits, + kind: MediaKind, + field?: string, + ): Record | null { + const size = Number(file.size ?? 0); + if (!Number.isFinite(size)) { + return { + code: "MEDIA_SIZE_UNKNOWN", + kind, + ...(field ? { field } : {}), + message: `Could not determine the size of the uploaded ${kind} file.`, + }; + } + if (size < minBytes) { + return { + code: "MEDIA_TOO_SMALL", + kind, + ...(field ? { field } : {}), + sizeBytes: size, + minBytes, + maxBytes, + message: + `Uploaded ${kind} is too small (${size} bytes). ` + + `Minimum allowed for this client is ${minBytes} bytes.`, + }; + } + if (size > maxBytes) { + return { + code: "MEDIA_TOO_LARGE", + kind, + ...(field ? { field } : {}), + sizeBytes: size, + minBytes, + maxBytes, + message: + `Uploaded ${kind} is too large (${size} bytes). ` + + `Maximum allowed for this client is ${maxBytes} bytes.`, + }; + } + return null; + } + + private flattenProvidedFiles( + files: Partial>, + ): Array<{ field: TKeys; file: NonNullable }> { + const out: Array<{ field: TKeys; file: NonNullable }> = []; + if (!files) return out; + for (const key of Object.keys(files) as TKeys[]) { + for (const f of files[key] || []) { + if (f) out.push({ field: key, file: f }); + } + } + return out; + } + + private async resolveBlameClientId( + blameRequestId: string, + ): Promise { + if (!blameRequestId || !Types.ObjectId.isValid(blameRequestId)) return null; + + const blame = (await this.blameRequestModel + .findById(blameRequestId) + .select("parties") + .lean() + .exec()) as { parties?: Array<{ person?: { clientId?: Types.ObjectId } }> } | null; + if (!blame?.parties?.length) return null; + + for (const party of blame.parties) { + const clientId = party?.person?.clientId; + if (clientId) return clientId; + } + return null; + } + + private async resolveClaimClientId( + claimRequestId: string, + ): Promise { + if (!claimRequestId || !Types.ObjectId.isValid(claimRequestId)) return null; + + const claim = (await this.claimCaseModel + .findById(claimRequestId) + .select("owner") + .lean() + .exec()) as { owner?: { clientId?: Types.ObjectId } } | null; + return claim?.owner?.clientId ?? null; + } + + private async tryDelete(filePath?: string): Promise { + if (!filePath) return; + try { + await fs.unlink(filePath); + } catch (err) { + // Best-effort cleanup — log and continue. The HTTP response will + // already carry the violation details for the client. + this.logger.warn( + `MediaPolicyService: failed to delete file at ${filePath}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } +} diff --git a/src/request-management/expert-initiated.v2.controller.ts b/src/request-management/expert-initiated.v2.controller.ts index fd4ae4a..9ac3438 100644 --- a/src/request-management/expert-initiated.v2.controller.ts +++ b/src/request-management/expert-initiated.v2.controller.ts @@ -24,6 +24,7 @@ import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; import { CurrentUser } from "src/decorators/user.decorator"; import { Roles } from "src/decorators/roles.decorator"; +import { MediaPolicyService } from "src/media-policy/media-policy.service"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { RequestManagementService } from "./request-management.service"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; @@ -53,6 +54,7 @@ export class ExpertInitiatedV2Controller { constructor( private readonly requestManagementService: RequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, ) {} @Get("my-files") @@ -393,6 +395,7 @@ export class ExpertInitiatedV2Controller { @Param("requestId") requestId: string, @UploadedFile() file?: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(file, requestId, "video"); return this.requestManagementService.expertUploadVideoForBlameV2( expert, requestId, @@ -432,6 +435,7 @@ export class ExpertInitiatedV2Controller { @Param("requestId") requestId: string, @UploadedFile() voice?: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(voice, requestId, "voice"); return this.requestManagementService.expertUploadVoiceForBlameV2( expert, requestId, @@ -497,6 +501,8 @@ export class ExpertInitiatedV2Controller { @Body() body: { partyRole?: string; isAccept?: string | boolean }, @UploadedFile() sign?: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(sign, requestId, "image"); + const partyRole = (body.partyRole === "FIRST" || body.partyRole === "SECOND") ? body.partyRole : ("FIRST" as const); diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index 23542da..7129abe 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -15,6 +15,7 @@ import { HashModule } from "src/utils/hash/hash.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module"; import { AuthModule } from "src/auth/auth.module"; +import { MediaPolicyModule } from "src/media-policy/media-policy.module"; import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service"; import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service"; import { UserSignDbService } from "./entities/db-service/sign.db.service"; @@ -64,6 +65,7 @@ import { RegistrarInitiatedController } from "./registrar-initiated.controller"; forwardRef(() => ClaimRequestManagementModule), CronModule, AuthModule, + MediaPolicyModule, ], controllers: [ RequestManagementController, 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, diff --git a/src/request-management/request-management.v2.controller.ts b/src/request-management/request-management.v2.controller.ts index 920ccac..f9924de 100644 --- a/src/request-management/request-management.v2.controller.ts +++ b/src/request-management/request-management.v2.controller.ts @@ -28,6 +28,7 @@ import { GlobalGuard } from "src/auth/guards/global.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; import { Roles } from "src/decorators/roles.decorator"; import { CurrentUser } from "src/decorators/user.decorator"; +import { MediaPolicyService } from "src/media-policy/media-policy.service"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { @@ -53,6 +54,7 @@ import { RequestManagementService } from "./request-management.service"; export class RequestManagementV2Controller { constructor( private readonly requestManagementService: RequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, ) { } @Post() @@ -154,6 +156,7 @@ export class RequestManagementV2Controller { @CurrentUser() user, @UploadedFile() file?: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(file, requestId, "video"); return this.requestManagementService.uploadFirstPartyVideoV2( requestId, file, @@ -222,6 +225,7 @@ export class RequestManagementV2Controller { @CurrentUser() user, @UploadedFile() voice?: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(voice, requestId, "voice"); return this.requestManagementService.uploadVoiceV2(requestId, voice, user); } @@ -338,6 +342,8 @@ export class RequestManagementV2Controller { @CurrentUser() user: any, @UploadedFile() sign: Express.Multer.File, ) { + await this.mediaPolicyService.assertForBlame(sign, requestId, "image"); + // Convert string "true"/"false" to boolean const acceptDecision = typeof isAccept === "string" ? isAccept === "true" @@ -449,6 +455,18 @@ export class RequestManagementV2Controller { @Body("description") description?: string, @Body("textDescription") textDescription?: string, ) { + // Each multipart field maps to a media kind so the policy gate can pick + // the right per-client bounds. Document images = `image`, audio = `voice`, + // video = `video`. + await this.mediaPolicyService.assertForBlameMany(files, requestId, { + drivingLicense: "image", + carCertificate: "image", + nationalCertificate: "image", + carGreenCard: "image", + voice: "voice", + video: "video", + }); + const descCandidates = [description, textDescription] .map((x) => (x != null ? String(x).trim() : "")) .filter((s) => s.length > 0);