forked from Yara724/api
Compare commits
5 Commits
fd4cd3128f
...
7ba3b57cee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ba3b57cee | ||
|
|
5b6409fc2e | ||
|
|
e26c533a52 | ||
|
|
f75e6a4453 | ||
|
|
e89cf107ff |
@@ -26,7 +26,10 @@ export class UserAuthController {
|
|||||||
})
|
})
|
||||||
@ApiAcceptedResponse()
|
@ApiAcceptedResponse()
|
||||||
async sendOtpRq(@Body() body: UserLoginDto) {
|
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) {
|
if (res) {
|
||||||
throw new HttpException(res, HttpStatus.ACCEPTED);
|
throw new HttpException(res, HttpStatus.ACCEPTED);
|
||||||
}
|
}
|
||||||
|
|||||||
46
src/auth/auth-services/user-auth-error.ts
Normal file
46
src/auth/auth-services/user-auth-error.ts
Normal file
@@ -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, string> = {
|
||||||
|
[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));
|
||||||
|
}
|
||||||
187
src/auth/auth-services/user-link-access.service.ts
Normal file
187
src/auth/auth-services/user-link-access.service.ts
Normal file
@@ -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<RequestManagementModel>,
|
||||||
|
@InjectModel(BlameRequest.name)
|
||||||
|
private readonly blameRequestModel: Model<BlameRequest>,
|
||||||
|
@InjectModel(ClaimRequestManagementModel.name)
|
||||||
|
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
|
||||||
|
private readonly userDbService: UserDbService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async assertMobileAllowed(params: {
|
||||||
|
mobile: string;
|
||||||
|
linkToken?: string;
|
||||||
|
linkContext?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
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<string[]> {
|
||||||
|
if (!Types.ObjectId.isValid(linkToken)) return [];
|
||||||
|
|
||||||
|
const id = new Types.ObjectId(linkToken);
|
||||||
|
const context = this.normalizeContext(linkContext);
|
||||||
|
const allowedMobiles = new Set<string>();
|
||||||
|
|
||||||
|
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<string>,
|
||||||
|
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<string>,
|
||||||
|
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<string>,
|
||||||
|
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<string>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
import {
|
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
|
||||||
BadRequestException,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotAcceptableException,
|
|
||||||
NotFoundException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { Types } from "mongoose";
|
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 { LoginDtoRs } from "src/auth/dto/user/login.dto";
|
||||||
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
|
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
|
||||||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||||
import { HashService } from "src/utils/hash/hash.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
|
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserAuthService {
|
export class UserAuthService {
|
||||||
@@ -26,16 +28,27 @@ export class UserAuthService {
|
|||||||
private readonly hashService: HashService,
|
private readonly hashService: HashService,
|
||||||
private readonly otpCreator: OtpGeneratorService,
|
private readonly otpCreator: OtpGeneratorService,
|
||||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||||
|
private readonly userLinkAccessService: UserLinkAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validateUser(username: string, pass: string): Promise<any> {
|
async validateUser(
|
||||||
|
username: string,
|
||||||
|
pass: string,
|
||||||
|
binding: LinkBinding = {},
|
||||||
|
): Promise<any> {
|
||||||
|
await this.userLinkAccessService.assertMobileAllowed({
|
||||||
|
mobile: username,
|
||||||
|
linkToken: binding.linkToken,
|
||||||
|
linkContext: binding.linkContext,
|
||||||
|
});
|
||||||
|
|
||||||
const user = await this.userDbService.findOne({ username });
|
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();
|
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) {
|
if (user.otpExpire < now) {
|
||||||
throw new NotAcceptableException("expire otp");
|
throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED);
|
||||||
}
|
}
|
||||||
if (await this.hashService.compare(pass, user.otp)) {
|
if (await this.hashService.compare(pass, user.otp)) {
|
||||||
return user;
|
return user;
|
||||||
@@ -65,7 +78,16 @@ export class UserAuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendOtpRequest(mobile: string): Promise<LoginDtoRs> {
|
async sendOtpRequest(
|
||||||
|
mobile: string,
|
||||||
|
binding: LinkBinding = {},
|
||||||
|
): Promise<LoginDtoRs> {
|
||||||
|
await this.userLinkAccessService.assertMobileAllowed({
|
||||||
|
mobile,
|
||||||
|
linkToken: binding.linkToken,
|
||||||
|
linkContext: binding.linkContext,
|
||||||
|
});
|
||||||
|
|
||||||
const userExist = await this.userDbService.findOne({
|
const userExist = await this.userDbService.findOne({
|
||||||
mobile,
|
mobile,
|
||||||
});
|
});
|
||||||
@@ -101,7 +123,9 @@ export class UserAuthService {
|
|||||||
return new LoginDtoRs(newUser);
|
return new LoginDtoRs(newUser);
|
||||||
}
|
}
|
||||||
if (userExist) {
|
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);
|
await this.smsSender(otp, mobile);
|
||||||
const updateTokens = await this.userDbService.findOneAndUpdate(
|
const updateTokens = await this.userDbService.findOneAndUpdate(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { JwtModule, JwtService } from "@nestjs/jwt";
|
import { JwtModule, JwtService } from "@nestjs/jwt";
|
||||||
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { PassportModule } from "@nestjs/passport";
|
import { PassportModule } from "@nestjs/passport";
|
||||||
import { LocalStrategy } from "src/auth/stratregys/local.strategy";
|
import { LocalStrategy } from "src/auth/stratregys/local.strategy";
|
||||||
import { LocalActorStrategy } from "src/auth/stratregys/local-actor.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 { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller";
|
||||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||||
import { UserAuthService } from "src/auth/auth-services/user.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 { 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 { UsersModule } from "src/users/users.module";
|
||||||
import { HashModule } from "src/utils/hash/hash.module";
|
import { HashModule } from "src/utils/hash/hash.module";
|
||||||
// import { MailModule } from "src/utils/mail/mail.module";
|
// import { MailModule } from "src/utils/mail/mail.module";
|
||||||
@@ -21,6 +35,14 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
|
|||||||
HashModule,
|
HashModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
SmsOrchestrationModule,
|
SmsOrchestrationModule,
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: RequestManagementModel.name, schema: RequestManagementSchema },
|
||||||
|
{ name: BlameRequest.name, schema: BlameRequestSchema },
|
||||||
|
{
|
||||||
|
name: ClaimRequestManagementModel.name,
|
||||||
|
schema: ClaimRequestManagementSchema,
|
||||||
|
},
|
||||||
|
]),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
signOptions: { expiresIn: "1h" },
|
signOptions: { expiresIn: "1h" },
|
||||||
global: true,
|
global: true,
|
||||||
@@ -29,6 +51,7 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
|
|||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
UserAuthService,
|
UserAuthService,
|
||||||
|
UserLinkAccessService,
|
||||||
ActorAuthService,
|
ActorAuthService,
|
||||||
LocalStrategy,
|
LocalStrategy,
|
||||||
LocalActorStrategy,
|
LocalActorStrategy,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { UserModel } from "src/users/entities/schema/user.schema";
|
import { UserModel } from "src/users/entities/schema/user.schema";
|
||||||
|
|
||||||
export class UserLoginDto {
|
export class UserLoginDto {
|
||||||
@@ -8,6 +8,20 @@ export class UserLoginDto {
|
|||||||
description: "User login dto",
|
description: "User login dto",
|
||||||
})
|
})
|
||||||
mobile: string;
|
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 {
|
export class LoginDtoRs extends UserModel {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
export class UserVerifyOtp {
|
export class UserVerifyOtp {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@@ -14,4 +14,18 @@ export class UserVerifyOtp {
|
|||||||
description: "User login verify dto",
|
description: "User login verify dto",
|
||||||
})
|
})
|
||||||
password: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||||
ExecutionContext,
|
|
||||||
Injectable,
|
|
||||||
NotAcceptableException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
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";
|
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -13,13 +13,14 @@ export class LocalUserAuthGuard extends AuthGuard("local") {
|
|||||||
}
|
}
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const { username, password } = request.body;
|
const { username, password, linkToken, linkContext } = request.body;
|
||||||
let isValidUser = await this.userAuthService.validateUser(
|
const isValidUser = await this.userAuthService.validateUser(
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
{ linkToken, linkContext },
|
||||||
);
|
);
|
||||||
if (!isValidUser) {
|
if (!isValidUser) {
|
||||||
throw new NotAcceptableException("otp is wrong");
|
throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
|
||||||
}
|
}
|
||||||
request["user"] = isValidUser;
|
request["user"] = isValidUser;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import { Strategy } from "passport-local";
|
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";
|
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -12,7 +16,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
|||||||
async validate(username: string, password: string): Promise<any> {
|
async validate(username: string, password: string): Promise<any> {
|
||||||
const user = await this.userAuthService.validateUser(username, password);
|
const user = await this.userAuthService.validateUser(username, password);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException("user not found please register");
|
throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { PublicIdModule } from "src/utils/public-id/public-id.module";
|
|||||||
import { ClientModule } from "src/client/client.module";
|
import { ClientModule } from "src/client/client.module";
|
||||||
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
import { MediaPolicyModule } from "src/media-policy/media-policy.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -55,6 +56,7 @@ import { JwtModule } from "@nestjs/jwt";
|
|||||||
AiModule,
|
AiModule,
|
||||||
SandHubModule,
|
SandHubModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
|
MediaPolicyModule,
|
||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { GlobalGuard } from "src/auth/guards/global.guard";
|
|||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
import { CurrentUser } from "src/decorators/user.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 { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +54,7 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
|||||||
export class ClaimRequestManagementV2Controller {
|
export class ClaimRequestManagementV2Controller {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||||
|
private readonly mediaPolicyService: MediaPolicyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("requests")
|
@Get("requests")
|
||||||
@@ -300,6 +302,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
if (!Types.ObjectId.isValid(claimRequestId)) {
|
if (!Types.ObjectId.isValid(claimRequestId)) {
|
||||||
throw new BadRequestException("Invalid claim request id");
|
throw new BadRequestException("Invalid claim request id");
|
||||||
}
|
}
|
||||||
|
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
|
||||||
const agreed =
|
const agreed =
|
||||||
typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree);
|
typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree);
|
||||||
try {
|
try {
|
||||||
@@ -612,6 +615,8 @@ Optional: upload car green card file in the same step.
|
|||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
@UploadedFile() file?: Express.Multer.File,
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||||
|
// Green-card photo is optional here — the helper no-ops on missing file.
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.selectOtherPartsV2(
|
return await this.claimRequestManagementService.selectOtherPartsV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
@@ -776,6 +781,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.uploadRequiredDocumentV2(
|
return await this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
@@ -870,6 +876,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
): Promise<CapturePartV2ResponseDto> {
|
): Promise<CapturePartV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.capturePartV2(
|
return await this.claimRequestManagementService.capturePartV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
@@ -927,6 +934,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
) {
|
) {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.uploadClaimFactorV2(
|
return await this.claimRequestManagementService.uploadClaimFactorV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
@@ -996,6 +1004,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
@UploadedFile("file") file: Express.Multer.File,
|
@UploadedFile("file") file: Express.Multer.File,
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
): Promise<VideoCaptureV2ResponseDto> {
|
): Promise<VideoCaptureV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.setVideoCaptureV2(
|
return await this.claimRequestManagementService.setVideoCaptureV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
|
|||||||
@@ -7,6 +7,41 @@ import {
|
|||||||
} from "src/client/dto/create-client.dto";
|
} from "src/client/dto/create-client.dto";
|
||||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
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.<kind>`.
|
||||||
|
*/
|
||||||
|
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<MediaKind, MediaLimits> = {
|
||||||
|
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()
|
@Injectable()
|
||||||
export class ClientService {
|
export class ClientService {
|
||||||
constructor(private readonly clientDbService: ClientDbService) {}
|
constructor(private readonly clientDbService: ClientDbService) {}
|
||||||
@@ -53,4 +88,71 @@ export class ClientService {
|
|||||||
const list = client.map((element) => new ClientLists(element));
|
const list = client.map((element) => new ClientLists(element));
|
||||||
return list;
|
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<MediaLimits> {
|
||||||
|
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<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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,63 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
|||||||
|
|
||||||
export type ClientDocument = ClientModel & Document;
|
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 })
|
@Schema({ collection: "clients", versionKey: false })
|
||||||
export class ClientModel {
|
export class ClientModel {
|
||||||
@Prop({ required: true, unique: true, type: Object })
|
@Prop({ required: true, unique: true, type: Object })
|
||||||
@@ -20,6 +77,9 @@ export class ClientModel {
|
|||||||
|
|
||||||
@Prop({ required: true, unique: false })
|
@Prop({ required: true, unique: false })
|
||||||
clientCode: number;
|
clientCode: number;
|
||||||
|
|
||||||
|
@Prop({ type: ClientSettings, required: false, default: {} })
|
||||||
|
settings?: ClientSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClientDbSchema = SchemaFactory.createForClass(ClientModel);
|
export const ClientDbSchema = SchemaFactory.createForClass(ClientModel);
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ import {
|
|||||||
coerceDamagedPartsMediaToArray,
|
coerceDamagedPartsMediaToArray,
|
||||||
normalizeCarPartDamageForExpertReply,
|
normalizeCarPartDamageForExpertReply,
|
||||||
normalizeDamageSelectedParts,
|
normalizeDamageSelectedParts,
|
||||||
|
partIdentityKey,
|
||||||
|
partIdentityKeyFromCarPartDamage,
|
||||||
} from "src/helpers/outer-damage-parts";
|
} from "src/helpers/outer-damage-parts";
|
||||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
||||||
@@ -2673,6 +2675,17 @@ export class ExpertClaimService {
|
|||||||
reply.parts?.length > 0
|
reply.parts?.length > 0
|
||||||
? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts)
|
? 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<string>();
|
||||||
|
|
||||||
const processedParts = daghiNormalized.map((p) => {
|
const processedParts = daghiNormalized.map((p) => {
|
||||||
let carPartDamage: Record<string, unknown>;
|
let carPartDamage: Record<string, unknown>;
|
||||||
try {
|
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(
|
const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts(
|
||||||
|
|||||||
@@ -298,6 +298,40 @@ export function partIdentityKey(p: DamageSelectedPartV2): string {
|
|||||||
return `${p.side}|${p.name}`;
|
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<string, unknown>;
|
||||||
|
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 {
|
function normPartSegment(s: string): string {
|
||||||
return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase();
|
return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/media-policy/media-policy.module.ts
Normal file
37
src/media-policy/media-policy.module.ts
Normal file
@@ -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 {}
|
||||||
270
src/media-policy/media-policy.service.ts
Normal file
270
src/media-policy/media-policy.service.ts
Normal file
@@ -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<BlameRequest>,
|
||||||
|
@InjectModel(ClaimCase.name)
|
||||||
|
private readonly claimCaseModel: Model<ClaimCase>,
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<TKeys extends string>(
|
||||||
|
files: Partial<Record<TKeys, UploadedFileLike[] | undefined>>,
|
||||||
|
blameRequestId: string,
|
||||||
|
fieldKindMap: Record<TKeys, MediaKind | undefined>,
|
||||||
|
): Promise<void> {
|
||||||
|
const provided = this.flattenProvidedFiles<TKeys>(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<TKeys extends string>(
|
||||||
|
files: Partial<Record<TKeys, UploadedFileLike[] | undefined>>,
|
||||||
|
claimRequestId: string,
|
||||||
|
fieldKindMap: Record<TKeys, MediaKind | undefined>,
|
||||||
|
): Promise<void> {
|
||||||
|
const provided = this.flattenProvidedFiles<TKeys>(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<UploadedFileLike>,
|
||||||
|
clientId: Types.ObjectId | string | null,
|
||||||
|
kind: MediaKind,
|
||||||
|
): Promise<void> {
|
||||||
|
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<TKeys extends string>(
|
||||||
|
provided: Array<{ field: TKeys; file: NonNullable<UploadedFileLike> }>,
|
||||||
|
clientId: Types.ObjectId | string | null,
|
||||||
|
fieldKindMap: Record<TKeys, MediaKind | undefined>,
|
||||||
|
): Promise<void> {
|
||||||
|
const limitsCache = new Map<MediaKind, MediaLimits>();
|
||||||
|
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<UploadedFileLike>,
|
||||||
|
{ minBytes, maxBytes }: MediaLimits,
|
||||||
|
kind: MediaKind,
|
||||||
|
field?: string,
|
||||||
|
): Record<string, unknown> | 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<TKeys extends string>(
|
||||||
|
files: Partial<Record<TKeys, UploadedFileLike[] | undefined>>,
|
||||||
|
): Array<{ field: TKeys; file: NonNullable<UploadedFileLike> }> {
|
||||||
|
const out: Array<{ field: TKeys; file: NonNullable<UploadedFileLike> }> = [];
|
||||||
|
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<Types.ObjectId | null> {
|
||||||
|
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<Types.ObjectId | null> {
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
|||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
import { Roles } from "src/decorators/roles.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 { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { RequestManagementService } from "./request-management.service";
|
import { RequestManagementService } from "./request-management.service";
|
||||||
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
|
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
|
||||||
@@ -53,6 +54,7 @@ export class ExpertInitiatedV2Controller {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly requestManagementService: RequestManagementService,
|
private readonly requestManagementService: RequestManagementService,
|
||||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||||
|
private readonly mediaPolicyService: MediaPolicyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("my-files")
|
@Get("my-files")
|
||||||
@@ -393,6 +395,7 @@ export class ExpertInitiatedV2Controller {
|
|||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
@UploadedFile() file?: Express.Multer.File,
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
) {
|
) {
|
||||||
|
await this.mediaPolicyService.assertForBlame(file, requestId, "video");
|
||||||
return this.requestManagementService.expertUploadVideoForBlameV2(
|
return this.requestManagementService.expertUploadVideoForBlameV2(
|
||||||
expert,
|
expert,
|
||||||
requestId,
|
requestId,
|
||||||
@@ -432,6 +435,7 @@ export class ExpertInitiatedV2Controller {
|
|||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
@UploadedFile() voice?: Express.Multer.File,
|
@UploadedFile() voice?: Express.Multer.File,
|
||||||
) {
|
) {
|
||||||
|
await this.mediaPolicyService.assertForBlame(voice, requestId, "voice");
|
||||||
return this.requestManagementService.expertUploadVoiceForBlameV2(
|
return this.requestManagementService.expertUploadVoiceForBlameV2(
|
||||||
expert,
|
expert,
|
||||||
requestId,
|
requestId,
|
||||||
@@ -497,6 +501,8 @@ export class ExpertInitiatedV2Controller {
|
|||||||
@Body() body: { partyRole?: string; isAccept?: string | boolean },
|
@Body() body: { partyRole?: string; isAccept?: string | boolean },
|
||||||
@UploadedFile() sign?: Express.Multer.File,
|
@UploadedFile() sign?: Express.Multer.File,
|
||||||
) {
|
) {
|
||||||
|
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
|
||||||
|
|
||||||
const partyRole = (body.partyRole === "FIRST" || body.partyRole === "SECOND")
|
const partyRole = (body.partyRole === "FIRST" || body.partyRole === "SECOND")
|
||||||
? body.partyRole
|
? body.partyRole
|
||||||
: ("FIRST" as const);
|
: ("FIRST" as const);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { HashModule } from "src/utils/hash/hash.module";
|
|||||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||||
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
|
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
|
||||||
import { AuthModule } from "src/auth/auth.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 { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service";
|
||||||
import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service";
|
import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service";
|
||||||
import { UserSignDbService } from "./entities/db-service/sign.db.service";
|
import { UserSignDbService } from "./entities/db-service/sign.db.service";
|
||||||
@@ -64,6 +65,7 @@ import { RegistrarInitiatedController } from "./registrar-initiated.controller";
|
|||||||
forwardRef(() => ClaimRequestManagementModule),
|
forwardRef(() => ClaimRequestManagementModule),
|
||||||
CronModule,
|
CronModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
MediaPolicyModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
RequestManagementController,
|
RequestManagementController,
|
||||||
|
|||||||
@@ -327,6 +327,102 @@ export class RequestManagementService {
|
|||||||
private readonly userAuthService: UserAuthService,
|
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.
|
* 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.",
|
"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.accidentDate = body.accidentDate as any;
|
||||||
party.statement.accidentTime = body.accidentTime;
|
party.statement.accidentTime = body.accidentTime;
|
||||||
(party.statement as any).weatherCondition = body.weatherCondition;
|
(party.statement as any).weatherCondition = body.weatherCondition;
|
||||||
@@ -4506,6 +4609,17 @@ export class RequestManagementService {
|
|||||||
throw new NotFoundException(`Client not found for company: ${clientName}`);
|
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(
|
const carBodyInfo = await this.mockCarBodyInsuranceInquiry(
|
||||||
requestId,
|
requestId,
|
||||||
firstPartyPlate.plate,
|
firstPartyPlate.plate,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { GlobalGuard } from "src/auth/guards/global.guard";
|
|||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
import { CurrentUser } from "src/decorators/user.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 { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +54,7 @@ import { RequestManagementService } from "./request-management.service";
|
|||||||
export class RequestManagementV2Controller {
|
export class RequestManagementV2Controller {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly requestManagementService: RequestManagementService,
|
private readonly requestManagementService: RequestManagementService,
|
||||||
|
private readonly mediaPolicyService: MediaPolicyService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -154,6 +156,7 @@ export class RequestManagementV2Controller {
|
|||||||
@CurrentUser() user,
|
@CurrentUser() user,
|
||||||
@UploadedFile() file?: Express.Multer.File,
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
) {
|
) {
|
||||||
|
await this.mediaPolicyService.assertForBlame(file, requestId, "video");
|
||||||
return this.requestManagementService.uploadFirstPartyVideoV2(
|
return this.requestManagementService.uploadFirstPartyVideoV2(
|
||||||
requestId,
|
requestId,
|
||||||
file,
|
file,
|
||||||
@@ -222,6 +225,7 @@ export class RequestManagementV2Controller {
|
|||||||
@CurrentUser() user,
|
@CurrentUser() user,
|
||||||
@UploadedFile() voice?: Express.Multer.File,
|
@UploadedFile() voice?: Express.Multer.File,
|
||||||
) {
|
) {
|
||||||
|
await this.mediaPolicyService.assertForBlame(voice, requestId, "voice");
|
||||||
return this.requestManagementService.uploadVoiceV2(requestId, voice, user);
|
return this.requestManagementService.uploadVoiceV2(requestId, voice, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +342,8 @@ export class RequestManagementV2Controller {
|
|||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
@UploadedFile() sign: Express.Multer.File,
|
@UploadedFile() sign: Express.Multer.File,
|
||||||
) {
|
) {
|
||||||
|
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
|
||||||
|
|
||||||
// Convert string "true"/"false" to boolean
|
// Convert string "true"/"false" to boolean
|
||||||
const acceptDecision = typeof isAccept === "string"
|
const acceptDecision = typeof isAccept === "string"
|
||||||
? isAccept === "true"
|
? isAccept === "true"
|
||||||
@@ -449,6 +455,18 @@ export class RequestManagementV2Controller {
|
|||||||
@Body("description") description?: string,
|
@Body("description") description?: string,
|
||||||
@Body("textDescription") textDescription?: 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]
|
const descCandidates = [description, textDescription]
|
||||||
.map((x) => (x != null ? String(x).trim() : ""))
|
.map((x) => (x != null ? String(x).trim() : ""))
|
||||||
.filter((s) => s.length > 0);
|
.filter((s) => s.length > 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user