forked from Yara724/api
Compare commits
32 Commits
0d0cec4b20
...
39c4855b95
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c4855b95 | |||
|
|
35487ad033 | ||
| 3dec22595c | |||
|
|
44723259d6 | ||
| c96e361990 | |||
|
|
e4dfe7c572 | ||
| 511d478064 | |||
|
|
cef684e37f | ||
| c81157c431 | |||
|
|
e1954cdb37 | ||
|
|
7ff3e9fd10 | ||
| ced8586710 | |||
|
|
f0ba8949cb | ||
| fb224360ab | |||
|
|
600c6bd7ed | ||
| 129be58cc9 | |||
|
|
094816ce8f | ||
| f2848a6179 | |||
|
|
7797a4ddab | ||
| 09eb6cc5c0 | |||
|
|
7c76149c95 | ||
| d562e09aab | |||
|
|
2c851725a5 | ||
| e4d6246103 | |||
|
|
b9d15d1ff6 | ||
| 241634b149 | |||
|
|
7ba3b57cee | ||
|
|
5b6409fc2e | ||
| fd4cd3128f | |||
|
|
e26c533a52 | ||
|
|
f75e6a4453 | ||
|
|
e89cf107ff |
@@ -106,6 +106,9 @@
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, ValidationPipe } from "@nestjs/common";
|
||||
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
|
||||
import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { ServeStaticModule } from "@nestjs/serve-static";
|
||||
@@ -16,6 +18,7 @@ import { LookupsModule } from "./lookups/lookups.module";
|
||||
import { PlatesModule } from "./plates/plates.module";
|
||||
import { ProfileModule } from "./profile/profile.module";
|
||||
import { SandHubModule } from "./sand-hub/sand-hub.module";
|
||||
import { SystemSettingsModule } from "./system-settings/system-settings.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
import { RequestManagementModule } from "./request-management/request-management.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
@@ -58,6 +61,7 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
||||
PlatesModule,
|
||||
RequestManagementModule,
|
||||
SandHubModule,
|
||||
SystemSettingsModule,
|
||||
ExpertBlameModule,
|
||||
ClaimRequestManagementModule,
|
||||
ExpertClaimModule,
|
||||
@@ -68,6 +72,19 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
||||
WorkflowStepManagementModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: UnicodeDigitsNormalizeInterceptor,
|
||||
},
|
||||
// {
|
||||
// provide: APP_PIPE,
|
||||
// useValue: new ValidationPipe({
|
||||
// transform: true,
|
||||
// whitelist: true,
|
||||
// forbidNonWhitelisted: false,
|
||||
// }),
|
||||
// },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
LegalRegisterDto,
|
||||
} from "src/auth/dto/actor/register.actor.dto";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { ClientKey } from "src/decorators/clientKey.decorator";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
|
||||
@@ -136,9 +135,17 @@ export class ActorAuthController {
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: "No actor account exists for the given email and role",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: "Wrong password or role mismatch",
|
||||
})
|
||||
@ApiAcceptedResponse()
|
||||
async login(@Body() body, @Req() req, @ClientKey() client) {
|
||||
return await this.actorAuthService.loginActors(req.user);
|
||||
async login(@Req() req: { user: Record<string, unknown> }) {
|
||||
return req.user;
|
||||
}
|
||||
|
||||
@Post("forget-password")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -97,48 +97,95 @@ export class ActorAuthService {
|
||||
return res;
|
||||
}
|
||||
|
||||
async validateActor(username: string, pass: string, role): Promise<any> {
|
||||
const user = await this.dynamicDbController(role, username);
|
||||
if (user) {
|
||||
if (user.role !== role) {
|
||||
throw new UnauthorizedException("user not assigned to this role");
|
||||
}
|
||||
if (!(await this.hashService.compare(pass, user.password))) {
|
||||
throw new UnauthorizedException(
|
||||
"password is incorrect or access Denied",
|
||||
);
|
||||
} else {
|
||||
return user;
|
||||
}
|
||||
/** Normalizes `role` from login body (string or single-element array). */
|
||||
parseActorLoginRole(role: unknown): RoleEnum {
|
||||
const raw = Array.isArray(role) ? role[0] : role;
|
||||
if (
|
||||
typeof raw !== "string" ||
|
||||
!Object.values(RoleEnum).includes(raw as RoleEnum)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Invalid role. Expected one of: ${Object.values(RoleEnum).join(", ")}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return raw as RoleEnum;
|
||||
}
|
||||
|
||||
async loginActors(user: any) {
|
||||
let foundedUser = await this.dynamicDbController(user.role, user.username);
|
||||
if (foundedUser) {
|
||||
const payload = {
|
||||
username: foundedUser.username || foundedUser.email,
|
||||
sub: foundedUser._id,
|
||||
fullName:
|
||||
`${foundedUser.firstName || ""} ${foundedUser.lastName || ""}`.trim(),
|
||||
role: foundedUser.role || "User",
|
||||
userType: foundedUser.userType || "UserType",
|
||||
clientKey: foundedUser.clientKey || null,
|
||||
};
|
||||
|
||||
const accToken = this.jwtService.sign(payload, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
expiresIn: "1h",
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
access_token: accToken,
|
||||
};
|
||||
} else {
|
||||
throw new UnauthorizedException("expert or damage_expert not found");
|
||||
parseActorLoginUsername(body: Record<string, unknown>): string {
|
||||
const username = body?.username ?? body?.email;
|
||||
if (typeof username !== "string" || !username.trim()) {
|
||||
throw new BadRequestException("username (email) is required");
|
||||
}
|
||||
return username.trim();
|
||||
}
|
||||
|
||||
issueActorTokens(actor: {
|
||||
_id: Types.ObjectId;
|
||||
username?: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role?: string;
|
||||
userType?: string;
|
||||
clientKey?: Types.ObjectId | string | null;
|
||||
}) {
|
||||
const payload = {
|
||||
username: actor.username || actor.email,
|
||||
sub: actor._id,
|
||||
fullName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
|
||||
role: actor.role || "User",
|
||||
userType: actor.userType || "UserType",
|
||||
clientKey: actor.clientKey ?? null,
|
||||
};
|
||||
|
||||
const access_token = this.jwtService.sign(payload, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
expiresIn: "1h",
|
||||
});
|
||||
|
||||
return { ...payload, access_token };
|
||||
}
|
||||
|
||||
async validateActor(
|
||||
username: string,
|
||||
pass: string,
|
||||
role: RoleEnum,
|
||||
): Promise<any> {
|
||||
const user = await this.dynamicDbController(role, username);
|
||||
if (!user) {
|
||||
throw new NotFoundException("Actor account not found");
|
||||
}
|
||||
if (user.role !== role) {
|
||||
throw new UnauthorizedException("user not assigned to this role");
|
||||
}
|
||||
if (!(await this.hashService.compare(pass, user.password))) {
|
||||
throw new UnauthorizedException(
|
||||
"password is incorrect or access Denied",
|
||||
);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates an actor from a login request body (role, username/email, password).
|
||||
*/
|
||||
async loginFromCredentials(body: Record<string, unknown>) {
|
||||
const role = this.parseActorLoginRole(body?.role);
|
||||
const username = this.parseActorLoginUsername(body);
|
||||
const password = body?.password;
|
||||
if (typeof password !== "string" || !password) {
|
||||
throw new BadRequestException("password is required");
|
||||
}
|
||||
const actor = await this.validateActor(username, password, role);
|
||||
return this.issueActorTokens(actor);
|
||||
}
|
||||
|
||||
/** @deprecated Prefer {@link loginFromCredentials}. Kept for internal callers. */
|
||||
async loginActors(user: any) {
|
||||
if (user?.access_token && user?.sub) {
|
||||
return user;
|
||||
}
|
||||
return this.loginFromCredentials(user as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async registerActors(
|
||||
|
||||
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 {
|
||||
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<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 });
|
||||
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<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({
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
import { UserModel } from "src/users/entities/schema/user.schema";
|
||||
|
||||
export class UserLoginDto {
|
||||
@ApiProperty({
|
||||
example: "09226187419",
|
||||
type: "string",
|
||||
description: "User login dto",
|
||||
description: "Mobile number (username for OTP login)",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
mobile: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "65f0c7f0c3f8a2a7c8b3d001",
|
||||
type: "string",
|
||||
description: "Raw token from linked SMS URL (?token=...).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
linkToken?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "FIRST",
|
||||
type: "string",
|
||||
description: "Optional route/context hint for linked SMS login.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
linkContext?: string;
|
||||
}
|
||||
|
||||
export class LoginDtoRs extends UserModel {
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class UserVerifyOtp {
|
||||
@ApiProperty({
|
||||
example: "09226187419",
|
||||
type: "string",
|
||||
description: "User login dto",
|
||||
description: "Mobile number (same value sent to send-otp)",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
username: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "258567",
|
||||
type: "string",
|
||||
description: "User login verify dto",
|
||||
description: "OTP code from SMS",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(16)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "65f0c7f0c3f8a2a7c8b3d001",
|
||||
type: "string",
|
||||
description: "Raw token from linked SMS URL (?token=...).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
linkToken?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "FIRST",
|
||||
type: "string",
|
||||
description: "Optional route/context hint for linked SMS login.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
linkContext?: string;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,12 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
const path = request.url;
|
||||
|
||||
if (!token) {
|
||||
if (path === "/actor/login") {
|
||||
const loginData = await this.actorAuthService.loginActors(request.body);
|
||||
if (this.isActorLoginRequest(request)) {
|
||||
const loginData = await this.actorAuthService.loginFromCredentials(
|
||||
request.body ?? {},
|
||||
);
|
||||
request.user = loginData;
|
||||
request.identity = request;
|
||||
request.identity = loginData;
|
||||
return true;
|
||||
} else {
|
||||
throw new UnauthorizedException("Token not found");
|
||||
@@ -59,6 +61,17 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
return true;
|
||||
}
|
||||
|
||||
private isActorLoginRequest(request: {
|
||||
url?: string;
|
||||
path?: string;
|
||||
route?: { path?: string };
|
||||
}): boolean {
|
||||
const path = (request.route?.path ?? request.url ?? request.path ?? "")
|
||||
.split("?")[0]
|
||||
.replace(/\/+$/, "");
|
||||
return path === "/actor/login" || path.endsWith("/actor/login");
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
//@ts-ignore
|
||||
const [type, token] = request.headers.authorization?.split(" ") ?? [];
|
||||
|
||||
41
src/auth/guards/settings-jwt.guard.ts
Normal file
41
src/auth/guards/settings-jwt.guard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Request } from "express";
|
||||
|
||||
/**
|
||||
* Verifies Bearer JWT for platform settings routes. Does not restrict by role;
|
||||
* pair with {@link RolesGuard} on handlers.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SettingsJwtGuard implements CanActivate {
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException("Token not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
});
|
||||
(request as any).user = payload;
|
||||
(request as any).identity = payload;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(" ") ?? [];
|
||||
return type === "Bearer" ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
|
||||
@@ -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<any> {
|
||||
const user = await this.userAuthService.validateUser(username, password);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("user not found please register");
|
||||
throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
VideoCaptureV2ResponseDto,
|
||||
} from "./dto/capture-part-v2.dto";
|
||||
import { GetMyClaimsV2ResponseDto, ClaimListItemV2Dto } from "./dto/my-claims-v2.dto";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
import { ClaimRequiredDocumentType, CarAngle } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||
@@ -116,20 +118,19 @@ import {
|
||||
type DamageSelectedPartV2,
|
||||
migrateExpertReplyCarPartDamageToUnified,
|
||||
normalizeDamageSelectedParts,
|
||||
partIdentityKey,
|
||||
parseCatalogPartIdInput,
|
||||
partLookupKey,
|
||||
resolvePartCaptureIndex,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||||
|
||||
/** Same `requiredDocuments` keys as in getCaptureRequirementsV2; upload allowed during CAPTURE_PART_DAMAGES. */
|
||||
const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [
|
||||
"damaged_chassis_number",
|
||||
"damaged_engine_photo",
|
||||
"damaged_metal_plate",
|
||||
] as const;
|
||||
|
||||
function isCapturePhaseDamagedPartyDocKey(key: string): boolean {
|
||||
return (CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
import {
|
||||
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
|
||||
capturePhaseSequenceMessage,
|
||||
getClaimCaptureProgress,
|
||||
isCapturePhaseDamagedPartyDocKey,
|
||||
isClaimCaptureStepComplete,
|
||||
} from "src/helpers/claim-capture-phase";
|
||||
|
||||
@Injectable()
|
||||
export class ClaimRequestManagementService {
|
||||
@@ -203,12 +204,6 @@ export class ClaimRequestManagementService {
|
||||
return !!doc?.uploaded;
|
||||
}
|
||||
|
||||
private capturePhaseDamagedPartyDocsComplete(claimCase: any): boolean {
|
||||
return CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.every((k) =>
|
||||
this.isRequiredDocumentUploadedOnClaim(claimCase, k),
|
||||
);
|
||||
}
|
||||
|
||||
private allV2OwnerDocumentsComplete(
|
||||
claimCase: any,
|
||||
isCarBody: boolean,
|
||||
@@ -4579,10 +4574,10 @@ export class ClaimRequestManagementService {
|
||||
|
||||
// Car angles
|
||||
const carAngles = [
|
||||
{ key: 'front', label_fa: 'جلو', label_en: 'Front' },
|
||||
{ key: 'back', label_fa: 'عقب', label_en: 'Back' },
|
||||
{ key: 'left', label_fa: 'چپ', label_en: 'Left' },
|
||||
{ key: 'right', label_fa: 'راست', label_en: 'Right' },
|
||||
{ key: 'front', label_fa: 'جلوی ماشین', label_en: 'Front of the car' },
|
||||
{ key: 'back', label_fa: 'عقب ماشین', label_en: 'Back of the car' },
|
||||
{ key: 'left', label_fa: 'چپ ماشین', label_en: 'Left of the car' },
|
||||
{ key: 'right', label_fa: 'راست ماشین', label_en: 'Right of the car' },
|
||||
].map(angle => ({
|
||||
key: angle.key,
|
||||
label_fa: angle.label_fa,
|
||||
@@ -4633,15 +4628,31 @@ export class ClaimRequestManagementService {
|
||||
const anglesCaptured = carAngles.filter(a => a.captured).length;
|
||||
const partsCaptured = damagedParts.filter(p => p.captured).length;
|
||||
|
||||
const captureProgress = getClaimCaptureProgress(claimCase);
|
||||
const capturePhaseDocsRemaining = captureProgress.capturePhaseDocsRemaining;
|
||||
const partsRemaining = Math.max(
|
||||
0,
|
||||
captureProgress.partsTotal - captureProgress.partsCaptured,
|
||||
);
|
||||
const anglesRemaining = Math.max(
|
||||
0,
|
||||
captureProgress.anglesTotal - captureProgress.anglesCaptured,
|
||||
);
|
||||
const postCaptureDocsRemaining = requiredDocuments.filter(
|
||||
(d) => !d.preferUploadDuringCapture && !d.uploaded,
|
||||
).length;
|
||||
|
||||
const totalRemaining =
|
||||
(requiredDocuments.length - documentsUploaded) +
|
||||
(carAngles.length - anglesCaptured) +
|
||||
(damagedParts.length - partsCaptured);
|
||||
partsRemaining + anglesRemaining + capturePhaseDocsRemaining;
|
||||
|
||||
return {
|
||||
claimRequestId: claimCase._id.toString(),
|
||||
publicId: claimCase.publicId,
|
||||
currentStep: claimCase.workflow?.currentStep || '',
|
||||
captureSequencePhase: captureProgress.sequencePhase,
|
||||
captureSequenceHint: capturePhaseSequenceMessage(
|
||||
captureProgress.sequencePhase,
|
||||
),
|
||||
requiredDocuments,
|
||||
carAngles,
|
||||
damagedParts,
|
||||
@@ -4653,6 +4664,8 @@ export class ClaimRequestManagementService {
|
||||
anglesTotal: carAngles.length,
|
||||
partsCaptured,
|
||||
partsTotal: damagedParts.length,
|
||||
capturePhaseDocsRemaining,
|
||||
postCaptureDocumentsRemaining: postCaptureDocsRemaining,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -4796,9 +4809,22 @@ export class ClaimRequestManagementService {
|
||||
if (!isResendUpload) {
|
||||
if (step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && !isCapturePhaseDocUpload) {
|
||||
throw new BadRequestException(
|
||||
`During ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} only chassis, engine, and damaged metal plate photos may be uploaded here (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}).`,
|
||||
`During ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} only chassis, engine, and damaged metal plate photos may be uploaded here (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}), and only after all damaged parts and car angles are captured.`,
|
||||
);
|
||||
}
|
||||
if (isCapturePhaseDocUpload) {
|
||||
const captureProgress = getClaimCaptureProgress(claimCase);
|
||||
if (!captureProgress.partsComplete) {
|
||||
throw new BadRequestException(
|
||||
"Upload photos for all selected damaged parts before uploading chassis, engine, or metal plate photos.",
|
||||
);
|
||||
}
|
||||
if (!captureProgress.anglesComplete) {
|
||||
throw new BadRequestException(
|
||||
"Capture all four car angles (front, back, left, right) before uploading chassis, engine, or metal plate photos.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const allowedInitialUploadStep =
|
||||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS || isCapturePhaseDocUpload;
|
||||
if (!allowedInitialUploadStep) {
|
||||
@@ -4896,31 +4922,14 @@ export class ClaimRequestManagementService {
|
||||
allDocumentsUploaded = false;
|
||||
|
||||
if (remaining === 0) {
|
||||
// After this upload all 3 capture-phase docs will be on the claim.
|
||||
// Check angles + parts captures using the in-memory claim media.
|
||||
const carAnglesData = (claimCase.media as any)?.carAngles;
|
||||
const damagedPartsData = (claimCase.media as any)?.damagedParts;
|
||||
const selectedNorm = normalizeDamageSelectedParts(
|
||||
claimCase.damage?.selectedParts,
|
||||
claimCase.vehicle?.carType as ClaimVehicleTypeV2,
|
||||
(claimCase.damage as any)?.selectedOuterParts,
|
||||
);
|
||||
const anglesKeys = ["front", "back", "left", "right"];
|
||||
const anglesCaptured = anglesKeys.filter((k) =>
|
||||
hasClaimCarAngleCapture(
|
||||
carAnglesData,
|
||||
damagedPartsData,
|
||||
k as ClaimCarAngleKey,
|
||||
),
|
||||
).length;
|
||||
const partsCaptured = selectedNorm.filter((sp) => {
|
||||
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
||||
return hasDamagedPartCapture(damagedPartsData, ck, selectedNorm);
|
||||
}).length;
|
||||
const progressAfterDoc = getClaimCaptureProgress(claimCase, {
|
||||
assumeCapturePhaseDocKey: body.documentKey,
|
||||
});
|
||||
|
||||
if (
|
||||
anglesCaptured >= 4 &&
|
||||
partsCaptured >= selectedNorm.length
|
||||
progressAfterDoc.partsComplete &&
|
||||
progressAfterDoc.anglesComplete &&
|
||||
progressAfterDoc.capturePhaseDocsComplete
|
||||
) {
|
||||
captureStepCompletedOnThisUpload = true;
|
||||
updateData["status"] = ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS;
|
||||
@@ -5031,8 +5040,12 @@ export class ClaimRequestManagementService {
|
||||
? captureStepCompletedOnThisUpload
|
||||
? "Capture-phase documents and all damage captures are complete. Please proceed to upload the remaining required documents."
|
||||
: remaining > 0
|
||||
? `Document saved. ${remaining} capture-phase document(s) still required (chassis, engine, metal plate) before you can finish damage capture.`
|
||||
: "Document saved. All capture-phase documents are uploaded; finish car angles and part photos to proceed."
|
||||
? `Document saved. ${remaining} capture-phase document(s) still required (chassis, engine, metal plate).`
|
||||
: capturePhaseSequenceMessage(
|
||||
getClaimCaptureProgress(claimCase, {
|
||||
assumeCapturePhaseDocKey: body.documentKey,
|
||||
}).sequencePhase,
|
||||
)
|
||||
: allDocumentsUploaded
|
||||
? "All documents uploaded successfully. Your claim is now ready for damage expert review."
|
||||
: `Document uploaded successfully. ${remaining} documents remaining.`;
|
||||
@@ -5114,6 +5127,19 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isResendCapture) {
|
||||
const captureProgressBefore = getClaimCaptureProgress(claimCase);
|
||||
const willBeAngle =
|
||||
body.captureType === "angle" ||
|
||||
(body.captureType === "part" &&
|
||||
canonicalizeClaimCarAngleKey(body.captureKey) !== null);
|
||||
if (willBeAngle && !captureProgressBefore.partsComplete) {
|
||||
throw new BadRequestException(
|
||||
"Upload photos for all selected damaged parts before capturing car angles.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const angleCanon = canonicalizeClaimCarAngleKey(body.captureKey);
|
||||
if (body.captureType === "angle" && !angleCanon) {
|
||||
throw new BadRequestException(
|
||||
@@ -5269,40 +5295,8 @@ export class ClaimRequestManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
const hasCapture = (data: any, key: string) =>
|
||||
data && (data instanceof Map ? data.get(key) : data[key]);
|
||||
|
||||
// Use same logic as getCaptureRequirementsV2 to determine completion
|
||||
const anglesKeys = ["front", "back", "left", "right"];
|
||||
const carAnglesData = updatedClaim?.media?.carAngles as any;
|
||||
const damagedPartsData = updatedClaim?.media?.damagedParts as any;
|
||||
const selectedNormAfter = normalizeDamageSelectedParts(
|
||||
updatedClaim?.damage?.selectedParts,
|
||||
updatedClaim?.vehicle?.carType as ClaimVehicleTypeV2,
|
||||
(updatedClaim?.damage as any)?.selectedOuterParts,
|
||||
);
|
||||
|
||||
const anglesCaptured = anglesKeys.filter((k) =>
|
||||
hasClaimCarAngleCapture(
|
||||
carAnglesData,
|
||||
damagedPartsData,
|
||||
k as ClaimCarAngleKey,
|
||||
),
|
||||
).length;
|
||||
const partsCaptured = selectedNormAfter.filter((sp) => {
|
||||
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
||||
return hasDamagedPartCapture(
|
||||
damagedPartsData,
|
||||
ck,
|
||||
selectedNormAfter,
|
||||
);
|
||||
}).length;
|
||||
|
||||
const capturePhaseDocsDone = this.capturePhaseDamagedPartyDocsComplete(updatedClaim);
|
||||
const allCapturesComplete =
|
||||
anglesCaptured >= 4 &&
|
||||
partsCaptured >= selectedNormAfter.length &&
|
||||
capturePhaseDocsDone;
|
||||
const captureProgress = getClaimCaptureProgress(updatedClaim);
|
||||
const allCapturesComplete = isClaimCaptureStepComplete(updatedClaim);
|
||||
|
||||
if (allCapturesComplete) {
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
@@ -5329,16 +5323,21 @@ export class ClaimRequestManagementService {
|
||||
});
|
||||
}
|
||||
|
||||
const captureDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
|
||||
(k) => !this.isRequiredDocumentUploadedOnClaim(updatedClaim, k),
|
||||
).length;
|
||||
const partsRemaining = Math.max(
|
||||
0,
|
||||
captureProgress.partsTotal - captureProgress.partsCaptured,
|
||||
);
|
||||
const anglesRemaining = Math.max(
|
||||
0,
|
||||
captureProgress.anglesTotal - captureProgress.anglesCaptured,
|
||||
);
|
||||
const remaining =
|
||||
(4 - anglesCaptured) +
|
||||
(selectedNormAfter.length - partsCaptured) +
|
||||
captureDocsRemaining;
|
||||
partsRemaining +
|
||||
anglesRemaining +
|
||||
captureProgress.capturePhaseDocsRemaining;
|
||||
const message = allCapturesComplete
|
||||
? 'All captures complete. Please proceed to upload required documents.'
|
||||
: `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} items remaining (angles, parts, and capture-phase documents).`;
|
||||
? "All captures complete. Please proceed to upload the remaining required documents."
|
||||
: `${body.captureType === "angle" ? "Angle" : "Part"} captured successfully. ${capturePhaseSequenceMessage(captureProgress.sequencePhase)} (${remaining} item(s) left in this step).`;
|
||||
|
||||
return {
|
||||
claimRequestId: claimCase._id.toString(),
|
||||
@@ -5387,9 +5386,21 @@ export class ClaimRequestManagementService {
|
||||
throw new BadRequestException('Video file is required.');
|
||||
}
|
||||
|
||||
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.CAPTURE_PART_DAMAGES) {
|
||||
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) {
|
||||
throw new BadRequestException(
|
||||
`Invalid workflow step. Expected ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES}, but current step is ${claimCase.workflow?.currentStep}`,
|
||||
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`,
|
||||
);
|
||||
}
|
||||
|
||||
const captureProgress = getClaimCaptureProgress(claimCase);
|
||||
if (!captureProgress.partsComplete) {
|
||||
throw new BadRequestException(
|
||||
"Upload photos for all selected damaged parts before uploading the walk-around video.",
|
||||
);
|
||||
}
|
||||
if (!captureProgress.anglesComplete) {
|
||||
throw new BadRequestException(
|
||||
"Capture all four car angles (front, back, left, right) before uploading the walk-around video.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5537,19 +5548,20 @@ export class ClaimRequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
const pricedPartIdSet =
|
||||
const pricedCatalogIds =
|
||||
activeExpert?.parts?.length ?
|
||||
new Set(
|
||||
activeExpert.parts
|
||||
.filter((p) => p.factorNeeded !== true)
|
||||
.map((p) => String((p as { partId?: string }).partId ?? ""))
|
||||
.filter(Boolean),
|
||||
.map((p) => parseCatalogPartIdInput(p.partId))
|
||||
.filter((id): id is number => id != null),
|
||||
)
|
||||
: new Set<string>();
|
||||
: new Set<number>();
|
||||
|
||||
if (partsIn.length && pricingObjectionEligible) {
|
||||
for (const op of partsIn) {
|
||||
if (!pricedPartIdSet.has(String(op.partId))) {
|
||||
const catalogId = parseCatalogPartIdInput(op.partId);
|
||||
if (catalogId == null || !pricedCatalogIds.has(catalogId)) {
|
||||
throw new BadRequestException(
|
||||
`Only priced repair lines can be disputed (${String(op.partId)} is factor-only or unknown).`,
|
||||
);
|
||||
@@ -5558,19 +5570,28 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
|
||||
const objectionParts = partsIn.map((p) => ({
|
||||
partId: p.partId,
|
||||
reason: p.reason,
|
||||
partPrice: p.partPrice,
|
||||
partSalary: p.partSalary,
|
||||
partId: parseCatalogPartIdInput(p.partId)!,
|
||||
reason: p.reason?.trim() || undefined,
|
||||
partPrice:
|
||||
p.partPrice != null && String(p.partPrice).trim() !== ""
|
||||
? normalizeMoneyAmountString(String(p.partPrice))
|
||||
: undefined,
|
||||
partSalary:
|
||||
p.partSalary != null && String(p.partSalary).trim() !== ""
|
||||
? normalizeMoneyAmountString(String(p.partSalary))
|
||||
: undefined,
|
||||
typeOfDamage: p.typeOfDamage != null ? String(p.typeOfDamage) : undefined,
|
||||
carPartDamage: p.carPartDamage,
|
||||
side: p.side,
|
||||
carPartDamage: p.carPartDamage?.trim() || undefined,
|
||||
side: p.side?.trim() || undefined,
|
||||
}));
|
||||
|
||||
const newParts = newPartsIn.map((p) => ({
|
||||
partId: p.partId ? String(p.partId) : new Types.ObjectId().toString(),
|
||||
partName: p.partName,
|
||||
side: p.side,
|
||||
partId:
|
||||
p.partId != null && parseCatalogPartIdInput(p.partId) != null
|
||||
? parseCatalogPartIdInput(p.partId)!
|
||||
: new Types.ObjectId().toString(),
|
||||
partName: p.partName.trim(),
|
||||
side: p.side?.trim() || undefined,
|
||||
}));
|
||||
|
||||
const objectionCarType = claimCase.vehicle?.carType as
|
||||
@@ -5581,7 +5602,7 @@ export class ClaimRequestManagementService {
|
||||
objectionCarType,
|
||||
(claimCase.damage as any)?.selectedOuterParts,
|
||||
);
|
||||
const mergedKeys = new Set(mergedNorm.map((p) => partIdentityKey(p)));
|
||||
const mergedKeys = new Set(mergedNorm.map((p) => partLookupKey(p)));
|
||||
for (const np of newParts) {
|
||||
const name = np.partName?.trim();
|
||||
if (!name) continue;
|
||||
@@ -5592,7 +5613,7 @@ export class ClaimRequestManagementService {
|
||||
side,
|
||||
label_fa: name,
|
||||
};
|
||||
const k = partIdentityKey(row);
|
||||
const k = partLookupKey(row);
|
||||
if (!mergedKeys.has(k)) {
|
||||
mergedNorm.push(row);
|
||||
mergedKeys.add(k);
|
||||
@@ -5990,6 +6011,7 @@ export class ClaimRequestManagementService {
|
||||
async getMyClaimsV2(
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<GetMyClaimsV2ResponseDto> {
|
||||
try {
|
||||
let claims: any[];
|
||||
@@ -6031,7 +6053,25 @@ export class ClaimRequestManagementService {
|
||||
createdAt: c.createdAt,
|
||||
blameRequestId: c.blameRequestId?.toString(),
|
||||
})) as ClaimListItemV2Dto[];
|
||||
return { list, total: list.length };
|
||||
|
||||
const paged = applyListQueryV2(list, {
|
||||
publicId: (r) => r.publicId,
|
||||
createdAt: (r) => r.createdAt,
|
||||
requestNo: (r) => r.requestNo,
|
||||
status: (r) => r.status,
|
||||
searchExtras: (r) =>
|
||||
[r.claimRequestId, r.claimStatus, r.currentStep, r.blameRequestId].filter(
|
||||
Boolean,
|
||||
) as string[],
|
||||
}, query);
|
||||
|
||||
return {
|
||||
list: paged.list,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error('getMyClaimsV2 failed', error);
|
||||
@@ -6105,7 +6145,7 @@ export class ClaimRequestManagementService {
|
||||
);
|
||||
const displayParts = [...selectedNormDetails];
|
||||
const seenDetailKeys = new Set(
|
||||
selectedNormDetails.map((p) => partIdentityKey(p)),
|
||||
selectedNormDetails.map((p) => partLookupKey(p)),
|
||||
);
|
||||
for (const rk of resendPartKeys) {
|
||||
const extra =
|
||||
@@ -6117,7 +6157,7 @@ export class ClaimRequestManagementService {
|
||||
label_fa: rk,
|
||||
catalogKey: rk,
|
||||
};
|
||||
const k = partIdentityKey(extra);
|
||||
const k = partLookupKey(extra);
|
||||
if (!seenDetailKeys.has(k)) {
|
||||
displayParts.push(extra);
|
||||
seenDetailKeys.add(k);
|
||||
|
||||
@@ -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 {
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
VideoCaptureV2ResponseDto,
|
||||
} from "./dto/capture-part-v2.dto";
|
||||
import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||||
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
|
||||
import { UserRatingDto } from "./dto/user-rating.dto";
|
||||
@@ -53,21 +55,30 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
export class ClaimRequestManagementV2Controller {
|
||||
constructor(
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
private readonly mediaPolicyService: MediaPolicyService,
|
||||
) {}
|
||||
|
||||
@Get("requests")
|
||||
@ApiOperation({
|
||||
summary: "Get My Claims (V2)",
|
||||
description: "Get list of all claim requests for the current user.",
|
||||
description:
|
||||
"Claims for the current user (or field-expert in-person files). Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of user claims",
|
||||
type: GetMyClaimsV2ResponseDto,
|
||||
})
|
||||
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
|
||||
async getMyClaims(
|
||||
@CurrentUser() user: any,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
): Promise<GetMyClaimsV2ResponseDto> {
|
||||
try {
|
||||
return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user);
|
||||
return await this.claimRequestManagementService.getMyClaimsV2(
|
||||
user.sub,
|
||||
user,
|
||||
query,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
@@ -300,6 +311,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 +624,8 @@ Optional: upload car green card file in the same step.
|
||||
@CurrentUser() user: any,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||
// 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,
|
||||
@@ -644,7 +658,7 @@ Optional: upload car green card file in the same step.
|
||||
|
||||
Returns status of each item (uploaded/captured or not).
|
||||
|
||||
**V2 order:** During \`CAPTURE_PART_DAMAGES\`, complete angles, part photos, walk-around video, and the three \`preferUploadDuringCapture\` documents (same upload-document API). Then complete the remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`.
|
||||
**V2 order (enforced by API):** During \`CAPTURE_PART_DAMAGES\`, (1) all damaged-part photos, (2) four car angles, (3) chassis/engine/metal-plate via upload-document, then walk-around video. Remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`. Use \`captureSequencePhase\` / \`captureSequenceHint\` in the response.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
@@ -776,6 +790,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: any,
|
||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
try {
|
||||
return await this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||
claimRequestId,
|
||||
@@ -822,7 +837,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
1. **angle**: Car angles (front, back, left, right) - 4 required
|
||||
2. **part**: Damaged parts based on selectedParts from Step 2
|
||||
|
||||
**When all captures are complete:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5).
|
||||
**When all captures are complete (parts, angles, capture-phase docs):** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). Angles are blocked until all parts are captured; capture-phase documents are blocked until all angles are captured.
|
||||
|
||||
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party.
|
||||
`,
|
||||
@@ -870,6 +885,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: any,
|
||||
): Promise<CapturePartV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
try {
|
||||
return await this.claimRequestManagementService.capturePartV2(
|
||||
claimRequestId,
|
||||
@@ -927,6 +943,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 +1013,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
@UploadedFile("file") file: Express.Multer.File,
|
||||
@CurrentUser() user: any,
|
||||
): Promise<VideoCaptureV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
|
||||
try {
|
||||
return await this.claimRequestManagementService.setVideoCaptureV2(
|
||||
claimRequestId,
|
||||
|
||||
@@ -144,6 +144,20 @@ export class GetCaptureRequirementsV2ResponseDto {
|
||||
})
|
||||
currentStep: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Ordered capture phase during CAPTURE_PART_DAMAGES: parts → angles → capture_phase_documents',
|
||||
example: 'angles',
|
||||
enum: ['parts', 'angles', 'capture_phase_documents', 'complete'],
|
||||
})
|
||||
captureSequencePhase: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Human-readable hint for what the user should do next in the capture step',
|
||||
example: 'Capture all four car angles (front, back, left, right) next.',
|
||||
})
|
||||
captureSequenceHint: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'List of required documents to upload',
|
||||
type: [RequiredDocumentItem],
|
||||
@@ -186,5 +200,7 @@ export class GetCaptureRequirementsV2ResponseDto {
|
||||
anglesTotal: number;
|
||||
partsCaptured: number;
|
||||
partsTotal: number;
|
||||
capturePhaseDocsRemaining: number;
|
||||
postCaptureDocumentsRemaining: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ClaimListItemV2Dto {
|
||||
@ApiProperty({ description: 'Claim case ID', example: '507f1f77bcf86cd799439011' })
|
||||
@@ -34,6 +34,17 @@ export class GetMyClaimsV2ResponseDto {
|
||||
@ApiProperty({ description: 'List of user claims', type: [ClaimListItemV2Dto] })
|
||||
list: ClaimListItemV2Dto[];
|
||||
|
||||
@ApiProperty({ description: 'Total count', example: 5 })
|
||||
@ApiProperty({ description: 'Total count after search filter', example: 5 })
|
||||
total: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Current page when `page` or `limit` query params were sent',
|
||||
})
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Page size when paginating' })
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total pages when paginating' })
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsOptional, ValidateNested } from "class-validator";
|
||||
import {
|
||||
IsArray,
|
||||
IsOptional,
|
||||
Validate,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { HasObjectionEntriesConstraint } from "src/common/validators/has-objection-entries.validator";
|
||||
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
|
||||
|
||||
/**
|
||||
* V2 user objection body — same shape as v1 {@link import("./user-objection.dto").UserObjectionDto}
|
||||
* V2 user objection body — same shape as v1 {@link UserObjectionDto}
|
||||
* with nested validation enabled for the v2 controller pipeline.
|
||||
*/
|
||||
export class UserObjectionV2Dto {
|
||||
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@@ -16,6 +23,7 @@ export class UserObjectionV2Dto {
|
||||
objectionParts?: UserObjectionPartDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -1,47 +1,113 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
Validate,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { HasObjectionEntriesConstraint } from "src/common/validators/has-objection-entries.validator";
|
||||
import { IsRepairLineAmountToman } from "src/common/validators/repair-line-amount-toman.validator";
|
||||
import { ObjectionPartHasContentConstraint } from "src/common/validators/objection-part-content.validator";
|
||||
import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum";
|
||||
|
||||
export class UserObjectionPartDto {
|
||||
@ApiProperty()
|
||||
partId: string;
|
||||
@ApiProperty({
|
||||
example: 201,
|
||||
description: "Numeric catalog part id of the priced line being disputed.",
|
||||
})
|
||||
@Validate(ObjectionPartHasContentConstraint)
|
||||
@IsInt()
|
||||
partId: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Why the owner disagrees (min 3 chars when provided). Required unless partPrice or partSalary is sent.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
reason?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional({
|
||||
description: "Owner-proposed part price (Toman, integer string).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
@IsRepairLineAmountToman()
|
||||
partPrice?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional({
|
||||
description: "Owner-proposed salary / labor (Toman, integer string).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
@IsRepairLineAmountToman()
|
||||
partSalary?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional({ enum: TypeOfDamage })
|
||||
@IsOptional()
|
||||
@IsEnum(TypeOfDamage)
|
||||
typeOfDamage?: TypeOfDamage;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
carPartDamage?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
side?: string;
|
||||
}
|
||||
|
||||
export class NewPartDto {
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
partId: string | null;
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Optional catalog id when the new part exists in the outer catalog.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
partId?: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ example: "سپر جلو" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
partName: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@ApiPropertyOptional({ example: "front" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
side?: string;
|
||||
}
|
||||
|
||||
export class UserObjectionDto {
|
||||
@ApiProperty({ type: [UserObjectionPartDto], required: false })
|
||||
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UserObjectionPartDto)
|
||||
objectionParts?: UserObjectionPartDto[];
|
||||
|
||||
@ApiProperty({ type: [NewPartDto], required: false })
|
||||
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NewPartDto)
|
||||
newParts?: NewPartDto[];
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userRepl
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimPartPricing {
|
||||
@Prop({ type: String })
|
||||
partId: string;
|
||||
@Prop({ type: Number })
|
||||
partId: number;
|
||||
|
||||
/**
|
||||
* Unified outer-part snapshot `{ id?, name, side, label_fa, catalogKey? }` (same as
|
||||
@@ -141,8 +141,8 @@ export const ClaimExpertReplySchema =
|
||||
/** One line item the user disputes on an expert-priced part */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjectionPart {
|
||||
@Prop({ type: String, required: true })
|
||||
partId: string;
|
||||
@Prop({ type: Number, required: true })
|
||||
partId: number;
|
||||
|
||||
@Prop({ type: String })
|
||||
reason?: string;
|
||||
@@ -167,8 +167,9 @@ export const ClaimUserObjectionPartSchema =
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjectionNewPart {
|
||||
@Prop({ type: String })
|
||||
partId?: string;
|
||||
/** Catalog id when known; otherwise a generated string id for the new line. */
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
partId?: number | string;
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
partName: string;
|
||||
@@ -215,6 +216,9 @@ export class ClaimResendRequest {
|
||||
/** Damage expert profile when resend was requested (`damage-expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
requestedByExpertId?: Types.ObjectId;
|
||||
}
|
||||
export const ClaimResendRequestSchema =
|
||||
SchemaFactory.createForClass(ClaimResendRequest);
|
||||
@@ -266,6 +270,12 @@ export class ClaimPriceDrop {
|
||||
|
||||
@Prop({ type: Number })
|
||||
sumOfSeverity?: number;
|
||||
|
||||
@Prop({ type: Number })
|
||||
coefficientYear?: number;
|
||||
|
||||
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||
partLines?: Array<Record<string, unknown>>;
|
||||
}
|
||||
export const ClaimPriceDropSchema = SchemaFactory.createForClass(ClaimPriceDrop);
|
||||
|
||||
|
||||
71
src/client/client-panel.controller.ts
Normal file
71
src/client/client-panel.controller.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ClientService } from "./client.service";
|
||||
import {
|
||||
ClientSettingsPanelResponseDto,
|
||||
UpdateClientSettingsDto,
|
||||
} from "./dto/client-settings.dto";
|
||||
|
||||
/**
|
||||
* Insurer (company) panel: per-tenant upload limits and CAR_BODY accident window.
|
||||
* Scoped to the JWT `clientKey` — tenants cannot edit other clients.
|
||||
*/
|
||||
@Controller("client-panel")
|
||||
@ApiTags("insurer-client-panel")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.COMPANY)
|
||||
export class ClientPanelController {
|
||||
constructor(private readonly clientService: ClientService) {}
|
||||
|
||||
@Get("settings")
|
||||
@ApiOperation({
|
||||
summary: "Get tenant settings (media limits & CAR_BODY accident window)",
|
||||
description:
|
||||
"Returns configured overrides, effective values (with system defaults), and route upload ceilings for the authenticated insurer's client.",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: ClientSettingsPanelResponseDto })
|
||||
async getSettings(
|
||||
@CurrentUser() insurer: { clientKey?: string },
|
||||
): Promise<ClientSettingsPanelResponseDto> {
|
||||
if (!insurer?.clientKey) {
|
||||
throw new UnauthorizedException("Insurer client key is missing.");
|
||||
}
|
||||
return this.clientService.getPanelSettings(insurer.clientKey);
|
||||
}
|
||||
|
||||
@Patch("settings")
|
||||
@ApiOperation({
|
||||
summary: "Update tenant settings",
|
||||
description:
|
||||
"Partial update of `settings.carBodyAccidentMaxAgeDays` and/or `settings.media` (video, image, voice). " +
|
||||
"Only fields sent in the body are changed. `maxBytes` cannot exceed the route ceiling returned by GET.",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: ClientSettingsPanelResponseDto })
|
||||
async updateSettings(
|
||||
@CurrentUser() insurer: { clientKey?: string },
|
||||
@Body() body: UpdateClientSettingsDto,
|
||||
): Promise<ClientSettingsPanelResponseDto> {
|
||||
if (!insurer?.clientKey) {
|
||||
throw new UnauthorizedException("Insurer client key is missing.");
|
||||
}
|
||||
return this.clientService.updatePanelSettings(insurer.clientKey, body);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { ClientController } from "./client.controller";
|
||||
import { ClientPanelController } from "./client-panel.controller";
|
||||
import { ClientService } from "./client.service";
|
||||
import { BranchDbService } from "./entities/db-service/branch.db.service";
|
||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||
@@ -17,7 +18,7 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [ClientController],
|
||||
controllers: [ClientController, ClientPanelController],
|
||||
providers: [ClientService, ClientDbService, BranchDbService],
|
||||
exports: [ClientService, ClientDbService, BranchDbService],
|
||||
})
|
||||
|
||||
@@ -1,12 +1,70 @@
|
||||
import { BadGatewayException, GoneException, Injectable } from "@nestjs/common";
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
GoneException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
ClientDto,
|
||||
ClientDtoRs,
|
||||
ClientLists,
|
||||
} from "src/client/dto/create-client.dto";
|
||||
import {
|
||||
type ClientSettingsPanelResponseDto,
|
||||
UpdateClientSettingsDto,
|
||||
} from "src/client/dto/client-settings.dto";
|
||||
import type { ClientMediaLimits } from "./entities/schema/client.schema";
|
||||
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 },
|
||||
};
|
||||
|
||||
/** Highest multer `fileSize` used on any route for each kind (policy cannot exceed this). */
|
||||
export const MEDIA_ROUTE_MAX_BYTES: Record<MediaKind, number> = {
|
||||
video: 50 * 1024 * 1024,
|
||||
image: 10 * 1024 * 1024,
|
||||
voice: 10 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN = 1;
|
||||
export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX = 365;
|
||||
|
||||
const MEDIA_KINDS: MediaKind[] = ["video", "image", "voice"];
|
||||
|
||||
@Injectable()
|
||||
export class ClientService {
|
||||
constructor(private readonly clientDbService: ClientDbService) {}
|
||||
@@ -53,4 +111,205 @@ 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<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;
|
||||
}
|
||||
|
||||
private resolveClientObjectId(clientKey: string | Types.ObjectId): Types.ObjectId {
|
||||
const raw = String(clientKey ?? "").trim();
|
||||
if (!Types.ObjectId.isValid(raw)) {
|
||||
throw new BadRequestException("Invalid client key");
|
||||
}
|
||||
return new Types.ObjectId(raw);
|
||||
}
|
||||
|
||||
private async loadClientOrThrow(clientKey: string | Types.ObjectId) {
|
||||
const id = this.resolveClientObjectId(clientKey);
|
||||
const client = await this.clientDbService.findOne({ _id: id });
|
||||
if (!client) {
|
||||
throw new NotFoundException("Client not found");
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
private configuredMediaLimits(
|
||||
stored: ClientMediaLimits | undefined,
|
||||
): { minBytes?: number; maxBytes?: number } | null {
|
||||
if (!stored) return null;
|
||||
const hasMin = typeof stored.minBytes === "number";
|
||||
const hasMax = typeof stored.maxBytes === "number";
|
||||
if (!hasMin && !hasMax) return null;
|
||||
return {
|
||||
...(hasMin ? { minBytes: stored.minBytes } : {}),
|
||||
...(hasMax ? { maxBytes: stored.maxBytes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private validateMediaLimitsPatch(
|
||||
kind: MediaKind,
|
||||
patch: { minBytes?: number; maxBytes?: number },
|
||||
): { minBytes?: number; maxBytes?: number } {
|
||||
const defaults = DEFAULT_MEDIA_LIMITS[kind];
|
||||
const routeMax = MEDIA_ROUTE_MAX_BYTES[kind];
|
||||
const minBytes =
|
||||
patch.minBytes !== undefined ? patch.minBytes : defaults.minBytes;
|
||||
const maxBytes =
|
||||
patch.maxBytes !== undefined ? patch.maxBytes : defaults.maxBytes;
|
||||
|
||||
if (maxBytes > routeMax) {
|
||||
throw new BadRequestException(
|
||||
`${kind} maxBytes (${maxBytes}) cannot exceed the route upload limit of ${routeMax} bytes.`,
|
||||
);
|
||||
}
|
||||
if (minBytes > maxBytes) {
|
||||
throw new BadRequestException(
|
||||
`${kind} minBytes (${minBytes}) cannot be greater than maxBytes (${maxBytes}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const out: { minBytes?: number; maxBytes?: number } = {};
|
||||
if (patch.minBytes !== undefined) out.minBytes = patch.minBytes;
|
||||
if (patch.maxBytes !== undefined) out.maxBytes = patch.maxBytes;
|
||||
return out;
|
||||
}
|
||||
|
||||
async getPanelSettings(
|
||||
clientKey: string | Types.ObjectId,
|
||||
): Promise<ClientSettingsPanelResponseDto> {
|
||||
const client = await this.loadClientOrThrow(clientKey);
|
||||
const clientId = client._id.toString();
|
||||
|
||||
const configuredDays = client.settings?.carBodyAccidentMaxAgeDays;
|
||||
const effectiveDays = await this.getCarBodyAccidentMaxAgeDays(client._id);
|
||||
|
||||
const media = {} as ClientSettingsPanelResponseDto["media"];
|
||||
for (const kind of MEDIA_KINDS) {
|
||||
const effective = await this.getMediaLimits(client._id, kind);
|
||||
media[kind] = {
|
||||
configured: this.configuredMediaLimits(client.settings?.media?.[kind]),
|
||||
effective,
|
||||
systemDefault: { ...DEFAULT_MEDIA_LIMITS[kind] },
|
||||
routeMaxBytes: MEDIA_ROUTE_MAX_BYTES[kind],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
clientId,
|
||||
carBodyAccidentMaxAgeDays: {
|
||||
configured:
|
||||
typeof configuredDays === "number" && Number.isFinite(configuredDays)
|
||||
? configuredDays
|
||||
: null,
|
||||
effective: effectiveDays,
|
||||
systemDefault: DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS,
|
||||
minDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN,
|
||||
maxDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX,
|
||||
},
|
||||
media,
|
||||
};
|
||||
}
|
||||
|
||||
async updatePanelSettings(
|
||||
clientKey: string | Types.ObjectId,
|
||||
body: UpdateClientSettingsDto,
|
||||
): Promise<ClientSettingsPanelResponseDto> {
|
||||
const client = await this.loadClientOrThrow(clientKey);
|
||||
const $set: Record<string, unknown> = {};
|
||||
|
||||
if (body.carBodyAccidentMaxAgeDays !== undefined) {
|
||||
$set["settings.carBodyAccidentMaxAgeDays"] = body.carBodyAccidentMaxAgeDays;
|
||||
}
|
||||
|
||||
if (body.media) {
|
||||
for (const kind of MEDIA_KINDS) {
|
||||
const patch = body.media[kind];
|
||||
if (!patch) continue;
|
||||
const validated = this.validateMediaLimitsPatch(kind, patch);
|
||||
if (validated.minBytes !== undefined) {
|
||||
$set[`settings.media.${kind}.minBytes`] = validated.minBytes;
|
||||
}
|
||||
if (validated.maxBytes !== undefined) {
|
||||
$set[`settings.media.${kind}.maxBytes`] = validated.maxBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys($set).length === 0) {
|
||||
throw new BadRequestException("No settings fields to update.");
|
||||
}
|
||||
|
||||
const updated = await this.clientDbService.findByIdAndUpdate(
|
||||
client._id.toString(),
|
||||
{ $set },
|
||||
);
|
||||
if (!updated) {
|
||||
throw new NotFoundException("Client not found");
|
||||
}
|
||||
|
||||
return this.getPanelSettings(client._id);
|
||||
}
|
||||
}
|
||||
|
||||
131
src/client/dto/client-settings.dto.ts
Normal file
131
src/client/dto/client-settings.dto.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import type { MediaKind } from "../client.service";
|
||||
|
||||
export class ClientMediaLimitsDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Minimum upload size in bytes (inclusive). Omit to keep default.",
|
||||
example: 5120,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minBytes?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Maximum upload size in bytes (inclusive). Cannot exceed route ceiling.",
|
||||
example: 8388608,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export class ClientMediaSettingsDto {
|
||||
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClientMediaLimitsDto)
|
||||
video?: ClientMediaLimitsDto;
|
||||
|
||||
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClientMediaLimitsDto)
|
||||
image?: ClientMediaLimitsDto;
|
||||
|
||||
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClientMediaLimitsDto)
|
||||
voice?: ClientMediaLimitsDto;
|
||||
}
|
||||
|
||||
export class UpdateClientSettingsDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Max days between CAR_BODY accident date and submission. Omit to leave unchanged.",
|
||||
example: 7,
|
||||
minimum: 1,
|
||||
maximum: 365,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(365)
|
||||
carBodyAccidentMaxAgeDays?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: ClientMediaSettingsDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClientMediaSettingsDto)
|
||||
media?: ClientMediaSettingsDto;
|
||||
}
|
||||
|
||||
export class MediaLimitsResolvedDto {
|
||||
@ApiProperty({ example: 5120 })
|
||||
minBytes: number;
|
||||
|
||||
@ApiProperty({ example: 8388608 })
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
export class MediaKindSettingsViewDto {
|
||||
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||
configured: { minBytes?: number; maxBytes?: number } | null;
|
||||
|
||||
@ApiProperty({ type: MediaLimitsResolvedDto })
|
||||
effective: MediaLimitsResolvedDto;
|
||||
|
||||
@ApiProperty({ type: MediaLimitsResolvedDto })
|
||||
systemDefault: MediaLimitsResolvedDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Hard multer ceiling for this kind on upload routes (bytes)",
|
||||
example: 10485760,
|
||||
})
|
||||
routeMaxBytes: number;
|
||||
}
|
||||
|
||||
export class CarBodyAccidentWindowViewDto {
|
||||
@ApiPropertyOptional({ example: 14, nullable: true })
|
||||
configured: number | null;
|
||||
|
||||
@ApiProperty({ example: 14 })
|
||||
effective: number;
|
||||
|
||||
@ApiProperty({ example: 7 })
|
||||
systemDefault: number;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
minDays: number;
|
||||
|
||||
@ApiProperty({ example: 365 })
|
||||
maxDays: number;
|
||||
}
|
||||
|
||||
export class ClientSettingsPanelResponseDto {
|
||||
@ApiProperty({ example: "507f1f77bcf86cd799439011" })
|
||||
clientId: string;
|
||||
|
||||
@ApiProperty({ type: CarBodyAccidentWindowViewDto })
|
||||
carBodyAccidentMaxAgeDays: CarBodyAccidentWindowViewDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Per-kind upload bounds (video / image / voice)",
|
||||
example: {
|
||||
video: {},
|
||||
image: {},
|
||||
voice: {},
|
||||
},
|
||||
})
|
||||
media: Record<MediaKind, MediaKindSettingsViewDto>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectModel } from "@nestjs/mongoose";
|
||||
import { FilterQuery, Model } from "mongoose";
|
||||
import { FilterQuery, Model, UpdateQuery } from "mongoose";
|
||||
import { ClientModel, ClientDocument } from "../schema/client.schema";
|
||||
|
||||
@Injectable()
|
||||
@@ -25,4 +25,14 @@ export class ClientDbService {
|
||||
async findAll(): Promise<ClientModel[]> {
|
||||
return await this.clientModel.find();
|
||||
}
|
||||
|
||||
async findByIdAndUpdate(
|
||||
id: string,
|
||||
update: UpdateQuery<ClientModel>,
|
||||
): Promise<ClientModel | null> {
|
||||
return this.clientModel
|
||||
.findByIdAndUpdate(id, update, { new: true })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
78
src/common/dto/list-query-v2.dto.ts
Normal file
78
src/common/dto/list-query-v2.dto.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
|
||||
/** Allowed sort keys for V2 list endpoints (claims / blame). */
|
||||
export const LIST_SORT_BY_V2 = [
|
||||
"publicId",
|
||||
"createdAt",
|
||||
"requestNo",
|
||||
"status",
|
||||
] as const;
|
||||
|
||||
export type ListSortByV2 = (typeof LIST_SORT_BY_V2)[number];
|
||||
|
||||
/**
|
||||
* Optional query params for V2 lists. If neither `page` nor `limit` is sent,
|
||||
* the full filtered+sorted list is returned (backward compatible).
|
||||
*/
|
||||
export class ListQueryV2Dto {
|
||||
@ApiPropertyOptional({
|
||||
description: "1-based page index (use together with `limit` for pagination).",
|
||||
example: 1,
|
||||
minimum: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Page size (max 100). If only `page` is set, defaults to 20.",
|
||||
example: 20,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: LIST_SORT_BY_V2,
|
||||
description: "Sort field. Default: `createdAt`.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...LIST_SORT_BY_V2])
|
||||
sortBy?: ListSortByV2;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["asc", "desc"],
|
||||
description: "Sort direction. Default: `desc` for dates, `asc` for publicId/requestNo.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["asc", "desc"])
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Case-insensitive substring match on `publicId` and `requestNo` (and other endpoint-specific fields).",
|
||||
example: "A142",
|
||||
maxLength: 64,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
search?: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from "@nestjs/common";
|
||||
import { Observable } from "rxjs";
|
||||
import { normalizeUnicodeDigitsDeep } from "src/utils/unicode-digits";
|
||||
|
||||
/**
|
||||
* Normalizes Unicode decimal digits in JSON request bodies to ASCII before
|
||||
* validation and handlers run (Persian/Arabic-Indic → 0-9).
|
||||
*/
|
||||
@Injectable()
|
||||
export class UnicodeDigitsNormalizeInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const contentType = String(req.headers?.["content-type"] ?? "");
|
||||
|
||||
if (
|
||||
req.body &&
|
||||
typeof req.body === "object" &&
|
||||
!Buffer.isBuffer(req.body) &&
|
||||
!contentType.includes("multipart/form-data")
|
||||
) {
|
||||
req.body = normalizeUnicodeDigitsDeep(req.body);
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
24
src/common/validators/has-objection-entries.validator.ts
Normal file
24
src/common/validators/has-objection-entries.validator.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
ValidationArguments,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "hasObjectionEntries", async: false })
|
||||
export class HasObjectionEntriesConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(_value: unknown, args: ValidationArguments): boolean {
|
||||
const o = args.object as {
|
||||
objectionParts?: unknown[];
|
||||
newParts?: unknown[];
|
||||
};
|
||||
const partsLen = Array.isArray(o.objectionParts) ? o.objectionParts.length : 0;
|
||||
const newLen = Array.isArray(o.newParts) ? o.newParts.length : 0;
|
||||
return partsLen > 0 || newLen > 0;
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return "Provide at least one entry in objectionParts and/or newParts.";
|
||||
}
|
||||
}
|
||||
37
src/common/validators/money-amount-string.validator.ts
Normal file
37
src/common/validators/money-amount-string.validator.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||||
|
||||
@ValidatorConstraint({ name: "isMoneyAmountString", async: false })
|
||||
export class IsMoneyAmountStringConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(value: unknown): boolean {
|
||||
if (value == null || value === "") return true;
|
||||
if (typeof value !== "string") return false;
|
||||
const n = normalizeMoneyAmountString(value);
|
||||
if (!n) return false;
|
||||
return /^\d+(\.\d+)?$/.test(n);
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return "Must be a non-negative amount (digits only, optional decimal).";
|
||||
}
|
||||
}
|
||||
|
||||
export function IsMoneyAmountString(validationOptions?: ValidationOptions) {
|
||||
return (object: object, propertyName: string) => {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsMoneyAmountStringConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
29
src/common/validators/objection-part-content.validator.ts
Normal file
29
src/common/validators/objection-part-content.validator.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
ValidationArguments,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "objectionPartHasContent", async: false })
|
||||
export class ObjectionPartHasContentConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(_value: unknown, args: ValidationArguments): boolean {
|
||||
const o = args.object as {
|
||||
reason?: string;
|
||||
partPrice?: string;
|
||||
partSalary?: string;
|
||||
};
|
||||
const hasReason =
|
||||
typeof o.reason === "string" && o.reason.trim().length >= 3;
|
||||
const hasPrice =
|
||||
o.partPrice != null && String(o.partPrice).trim() !== "";
|
||||
const hasSalary =
|
||||
o.partSalary != null && String(o.partSalary).trim() !== "";
|
||||
return hasReason || hasPrice || hasSalary;
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return "Each objection line needs a reason (min 3 characters) and/or proposed partPrice / partSalary.";
|
||||
}
|
||||
}
|
||||
66
src/common/validators/repair-line-amount-toman.validator.ts
Normal file
66
src/common/validators/repair-line-amount-toman.validator.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
import {
|
||||
REPAIR_LINE_AMOUNT_TOMAN,
|
||||
} from "src/constants/repair-amount-limits";
|
||||
import { parseMoneyAmountToman } from "src/utils/unicode-digits";
|
||||
|
||||
export type RepairLineAmountTomanOptions = {
|
||||
/** When true, `0` is valid (e.g. expert `price` / `salary` split where one side is zero). */
|
||||
allowZero?: boolean;
|
||||
};
|
||||
|
||||
@ValidatorConstraint({ name: "isRepairLineAmountToman", async: false })
|
||||
export class IsRepairLineAmountTomanConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(value: unknown, args: ValidationArguments): boolean {
|
||||
const [minToman, maxToman, allowZero] = args.constraints as [
|
||||
number,
|
||||
number,
|
||||
boolean | undefined,
|
||||
];
|
||||
if (value == null || value === "") return true;
|
||||
const amount = parseMoneyAmountToman(value);
|
||||
if (amount == null) return false;
|
||||
if (allowZero && amount === 0) return true;
|
||||
return amount >= minToman && amount <= maxToman;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const [minToman, maxToman, allowZero] = args.constraints as [
|
||||
number,
|
||||
number,
|
||||
boolean | undefined,
|
||||
];
|
||||
const zeroHint = allowZero ? " Use 0 where one side of price/salary is unused." : "";
|
||||
return (
|
||||
`Amount must be in Toman between ${minToman.toLocaleString("en-US")} and ${maxToman.toLocaleString("en-US")}.${zeroHint}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional repair-line amount in Toman (digits only after unicode normalize). */
|
||||
export function IsRepairLineAmountToman(
|
||||
options?: ValidationOptions & RepairLineAmountTomanOptions,
|
||||
) {
|
||||
const { allowZero, ...validationOptions } = options ?? {};
|
||||
return (object: object, propertyName: string) => {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [
|
||||
REPAIR_LINE_AMOUNT_TOMAN.MIN,
|
||||
REPAIR_LINE_AMOUNT_TOMAN.MAX,
|
||||
allowZero === true,
|
||||
],
|
||||
validator: IsRepairLineAmountTomanConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
12
src/constants/repair-amount-limits.ts
Normal file
12
src/constants/repair-amount-limits.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Per-line and total caps for repair money. All values are **Toman** (no unit conversion in the API).
|
||||
*/
|
||||
export const REPAIR_LINE_AMOUNT_TOMAN = {
|
||||
/** Below this is not credible for a priced repair line (e.g. 1,000 Toman). */
|
||||
MIN: 10_000,
|
||||
/** Aligns with the total assessment cap; rejects absurd values (e.g. 100bn). */
|
||||
MAX: 53_000_000,
|
||||
} as const;
|
||||
|
||||
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
|
||||
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
||||
|
||||
export class AllRequestDto {
|
||||
@@ -122,7 +123,30 @@ export class AllRequestDtoV2 {
|
||||
|
||||
export class AllRequestDtoRsV2 {
|
||||
data: AllRequestDtoV2[];
|
||||
constructor(items: AllRequestDtoV2[]) {
|
||||
this.data = items;
|
||||
|
||||
@ApiProperty({ description: "Total rows after search filter" })
|
||||
total: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
totalPages?: number;
|
||||
|
||||
constructor(payload: {
|
||||
data: AllRequestDtoV2[];
|
||||
total: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
totalPages?: number;
|
||||
}) {
|
||||
this.data = payload.data;
|
||||
this.total = payload.total;
|
||||
this.page = payload.page;
|
||||
this.limit = payload.limit;
|
||||
this.totalPages = payload.totalPages;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
blameCaseStatusToReportBucket,
|
||||
initialBlameExpertReportBuckets,
|
||||
} from "src/helpers/expert-panel-status-report";
|
||||
import {
|
||||
blameDocInExpertPortfolio,
|
||||
expertPortfolioFileIdsFromActivityEvents,
|
||||
objectIdsFromStringSet,
|
||||
} from "src/helpers/expert-portfolio";
|
||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||
import {
|
||||
@@ -222,38 +229,58 @@ export class ExpertBlameService {
|
||||
* Does NOT show requests decided by other experts.
|
||||
*/
|
||||
/**
|
||||
* V2: Count blame cases for this expert’s tenant, grouped for dashboard:
|
||||
* `IN_PROGRESS` = OPEN + WAITING_FOR_SECOND_PARTY; other {@link CaseStatus} keys unchanged.
|
||||
* V2: Count blame cases in this field expert’s portfolio, grouped for dashboard.
|
||||
* Portfolio = file-activity (checked/handled), lock, decision, or expert-initiated file.
|
||||
*/
|
||||
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
||||
requireActorClientKey(actor);
|
||||
const rows = await this.blameRequestDbService.find({}, { lean: true });
|
||||
const expertId = String(actor.sub);
|
||||
const expertOid = new Types.ObjectId(expertId);
|
||||
|
||||
const activityEvents = await this.expertFileActivityDbService.findByExpert(
|
||||
expertId,
|
||||
ExpertFileKind.BLAME,
|
||||
);
|
||||
const activityFileIds =
|
||||
expertPortfolioFileIdsFromActivityEvents(activityEvents);
|
||||
|
||||
const orClauses: Record<string, unknown>[] = [
|
||||
{ initiatedByFieldExpertId: expertOid },
|
||||
{ "expert.decision.decidedByExpertId": expertOid },
|
||||
{ "expert.resend.requestedByExpertId": expertOid },
|
||||
{ "workflow.lockedBy.actorId": expertOid },
|
||||
];
|
||||
const activityOids = objectIdsFromStringSet(activityFileIds);
|
||||
if (activityOids.length > 0) {
|
||||
orClauses.push({ _id: { $in: activityOids } });
|
||||
}
|
||||
|
||||
const rows = await this.blameRequestDbService.find(
|
||||
{
|
||||
blameStatus: BlameStatus.DISAGREEMENT,
|
||||
$or: orClauses,
|
||||
},
|
||||
{ lean: true },
|
||||
);
|
||||
|
||||
const buckets = initialBlameExpertReportBuckets();
|
||||
const seen = new Set<string>();
|
||||
for (const doc of rows as Record<string, unknown>[]) {
|
||||
if (!this.blameDocIncludedInExpertTenantReport(doc, actor)) continue;
|
||||
const st = String(doc.status ?? "");
|
||||
const key = blameCaseStatusToReportBucket(st);
|
||||
const id = String(doc._id ?? "");
|
||||
if (seen.has(id)) continue;
|
||||
if (!blameDocInExpertPortfolio(doc, actor, activityFileIds)) continue;
|
||||
seen.add(id);
|
||||
const key = blameCaseStatusToReportBucket(String(doc.status ?? ""));
|
||||
buckets.all++;
|
||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/** Tenant + expert-initiated visibility (same as list, without queue filters). */
|
||||
private blameDocIncludedInExpertTenantReport(
|
||||
doc: Record<string, unknown>,
|
||||
actor: { sub: string; clientKey?: string },
|
||||
): boolean {
|
||||
if (!blameCaseAccessibleToExpert(doc, actor)) {
|
||||
return false;
|
||||
}
|
||||
if (doc.expertInitiated && doc.initiatedByFieldExpertId) {
|
||||
return String(doc.initiatedByFieldExpertId) === String(actor.sub);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
|
||||
async findAllV2(
|
||||
actor: any,
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<AllRequestDtoRsV2> {
|
||||
try {
|
||||
requireActorClientKey(actor);
|
||||
const expertId = actor.sub;
|
||||
@@ -332,10 +359,41 @@ export class ExpertBlameService {
|
||||
}
|
||||
}
|
||||
|
||||
const items: AllRequestDtoV2[] = visibleCases.map(
|
||||
(doc) => this.mapBlameRequestToListItemV2(doc),
|
||||
const paged = applyListQueryV2(visibleCases, {
|
||||
publicId: (doc) => String((doc as { publicId?: string }).publicId ?? ""),
|
||||
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
|
||||
requestNo: (doc) =>
|
||||
String(
|
||||
(doc as { requestNo?: string }).requestNo ??
|
||||
(doc as { publicId?: string }).publicId ??
|
||||
"",
|
||||
),
|
||||
status: (doc) => String((doc as { status?: string }).status ?? ""),
|
||||
searchExtras: (doc) => {
|
||||
const d = doc as {
|
||||
_id?: unknown;
|
||||
blameStatus?: string;
|
||||
type?: string;
|
||||
};
|
||||
return [
|
||||
String(d._id ?? ""),
|
||||
d.blameStatus,
|
||||
d.type,
|
||||
].filter(Boolean) as string[];
|
||||
},
|
||||
}, query);
|
||||
|
||||
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
|
||||
this.mapBlameRequestToListItemV2(doc),
|
||||
);
|
||||
return new AllRequestDtoRsV2(items);
|
||||
|
||||
return new AllRequestDtoRsV2({
|
||||
data: items,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error("findAllV2 failed", error instanceof Error ? error.stack : String(error));
|
||||
@@ -1217,15 +1275,23 @@ export class ExpertBlameService {
|
||||
);
|
||||
}
|
||||
|
||||
const resendTenantId =
|
||||
String(request.parties?.[0]?.person?.clientId ?? "") ||
|
||||
String(request.parties?.[1]?.person?.clientId ?? "");
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(actorId),
|
||||
requestId: String(requestId),
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
tenantId:
|
||||
String(request.parties?.[0]?.person?.clientId ?? "") ||
|
||||
String(request.parties?.[1]?.person?.clientId ?? ""),
|
||||
tenantId: resendTenantId,
|
||||
idempotencyKey: `blame:${requestId}:unchecked:resend:${actorId}`,
|
||||
});
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(actorId),
|
||||
requestId: String(requestId),
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
tenantId: resendTenantId,
|
||||
idempotencyKey: `blame:${requestId}:handled:resend:${actorId}`,
|
||||
});
|
||||
|
||||
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
|
||||
requestId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
InternalServerErrorException,
|
||||
Param,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger";
|
||||
@@ -15,6 +16,8 @@ import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertBlameService } from "./expert-blame.service";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { AllRequestDtoRsV2 } from "./dto/all-request.dto";
|
||||
import { SubmitReplyDto } from "./dto/reply.dto";
|
||||
import { ResendRequestDto } from "./dto/resend.dto";
|
||||
|
||||
@@ -27,9 +30,17 @@ export class ExpertBlameV2Controller {
|
||||
constructor(private readonly expertBlameService: ExpertBlameService) { }
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() actor: any) {
|
||||
@ApiOperation({
|
||||
summary: "List blame cases for field expert (V2)",
|
||||
description:
|
||||
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
||||
})
|
||||
async findAll(
|
||||
@CurrentUser() actor: any,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
): Promise<AllRequestDtoRsV2> {
|
||||
try {
|
||||
return await this.expertBlameService.findAllV2(actor);
|
||||
return await this.expertBlameService.findAllV2(actor, query);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
@@ -40,9 +51,9 @@ export class ExpertBlameV2Controller {
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Count blame cases by grouped status bucket (tenant)",
|
||||
summary: "Count blame cases by grouped status bucket (this field expert)",
|
||||
description:
|
||||
"IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. WAITING_FOR_EXPERT, WAITING_FOR_DOCUMENT_RESEND, WAITING_FOR_SIGNATURES, and terminal statuses are counted separately. Expert-initiated files count only for the initiating expert.",
|
||||
"Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), workflow lock, expert decision, or expert-initiated blame. IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. Other keys match CaseStatus. Does not include the open WAITING_FOR_EXPERT queue unless this expert has touched the file.",
|
||||
})
|
||||
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||
try {
|
||||
|
||||
@@ -93,6 +93,12 @@ export class ClaimDetailV2ResponseDto {
|
||||
type: 'object',
|
||||
properties: {
|
||||
index: { type: 'number' },
|
||||
partId: {
|
||||
type: 'number',
|
||||
nullable: true,
|
||||
description:
|
||||
'Numeric catalog part id for outer parts (`null` for non-catalog lines); use in reply/submit and price-drop',
|
||||
},
|
||||
id: { type: 'number', nullable: true },
|
||||
name: { type: 'string' },
|
||||
side: { type: 'string' },
|
||||
@@ -104,6 +110,7 @@ export class ClaimDetailV2ResponseDto {
|
||||
})
|
||||
damagedParts?: Array<{
|
||||
index: number;
|
||||
partId: number | null;
|
||||
id?: number | null;
|
||||
name: string;
|
||||
side: string;
|
||||
@@ -146,6 +153,15 @@ export class ClaimDetailV2ResponseDto {
|
||||
signLink?: string;
|
||||
signedAt?: Date | string;
|
||||
};
|
||||
priceDrop?: {
|
||||
total?: number;
|
||||
carPrice?: number;
|
||||
carModel?: number;
|
||||
carValue?: number[];
|
||||
sumOfSeverity?: number;
|
||||
coefficientYear?: number;
|
||||
partLines?: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
|
||||
@@ -64,6 +64,15 @@ export class GetClaimListV2ResponseDto {
|
||||
@ApiProperty({ type: [ClaimListItemV2Dto] })
|
||||
list: ClaimListItemV2Dto[];
|
||||
|
||||
@ApiProperty({ description: 'Total count' })
|
||||
@ApiProperty({ description: 'Total count after search filter' })
|
||||
total: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Current page when paginating' })
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Page size when paginating' })
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total pages when paginating' })
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
127
src/expert-claim/dto/claim-price-drop-v2.dto.ts
Normal file
127
src/expert-claim/dto/claim-price-drop-v2.dto.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import type { PriceDropSeverity } from "src/helpers/claim-price-drop";
|
||||
|
||||
export class PriceDropPartSeverityV2Dto {
|
||||
@ApiProperty({
|
||||
example: 12,
|
||||
description: "From claim detail `damagedParts[].partId`",
|
||||
})
|
||||
@IsInt()
|
||||
partId: number;
|
||||
|
||||
@ApiProperty({
|
||||
enum: ["Minor", "Moderate", "Severe"],
|
||||
description: "Severity: جزئی / متوسط / شدید",
|
||||
})
|
||||
@IsIn(["Minor", "Moderate", "Severe"])
|
||||
severity: PriceDropSeverity;
|
||||
}
|
||||
|
||||
export class UpsertClaimPriceDropV2Dto {
|
||||
@ApiPropertyOptional({ example: "پژو 206" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
carName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "پژو 206 تیپ 5",
|
||||
description: "Display model string stored on `claim.vehicle.carModel`",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
carModel?: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 450000000,
|
||||
description: "Market price of the car (Toman)",
|
||||
})
|
||||
carPrice: number | string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 1394,
|
||||
description:
|
||||
"Jalali production year (coefficient). Defaults from linked blame plate inquiry when omitted.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
carModelYear?: number;
|
||||
|
||||
@ApiProperty({
|
||||
type: [PriceDropPartSeverityV2Dto],
|
||||
description:
|
||||
"One row per damaged part on the claim; coefficients come from the price-drop table × severity.",
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PriceDropPartSeverityV2Dto)
|
||||
partSeverities: PriceDropPartSeverityV2Dto[];
|
||||
}
|
||||
|
||||
export class ClaimPriceDropContextV2Dto {
|
||||
@ApiProperty()
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
vehicle?: {
|
||||
carName?: string;
|
||||
carModel?: string;
|
||||
carType?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Jalali year from blame plate inquiry (ModelField / ModelCii)",
|
||||
})
|
||||
suggestedCarModelYear?: number | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
priceDrop?: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
label_fa: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
severityOptions: Array<{ value: string; label_fa: string }>;
|
||||
|
||||
@ApiProperty({ description: "Full coefficient table for all price-drop parts" })
|
||||
priceDropCatalog: Array<Record<string, unknown>>;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Claim damaged parts with optional price-drop key mapping",
|
||||
})
|
||||
damagedParts: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export class ClaimPriceDropResultV2Dto {
|
||||
@ApiProperty()
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiProperty()
|
||||
vehicle: {
|
||||
carName?: string;
|
||||
carModel?: string;
|
||||
carType?: string;
|
||||
};
|
||||
|
||||
@ApiProperty()
|
||||
priceDrop: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({ type: "array" })
|
||||
partLines: Array<Record<string, unknown>>;
|
||||
}
|
||||
@@ -10,57 +10,10 @@ import {
|
||||
IsInt,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsRepairLineAmountToman } from 'src/common/validators/repair-line-amount-toman.validator';
|
||||
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
||||
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
|
||||
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
|
||||
|
||||
/**
|
||||
* Damage line part reference — same unified shape as `damage.selectedParts` / catalog rows.
|
||||
* Send either `{ name, side, label_fa?, id?, catalogKey? }` or legacy `{ part, side }`
|
||||
* (e.g. part `backFender`, side `left`); the API persists canonical `{ id, name, side, label_fa, catalogKey? }`.
|
||||
*/
|
||||
export class ExpertReplyCarPartDamageV2Dto {
|
||||
@ApiPropertyOptional({ description: 'Catalog id when known' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
id?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Side-agnostic catalog segment, e.g. backfender',
|
||||
example: 'backfender',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'left' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
side?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Farsi label; optional when server can resolve from catalog + vehicle.carType',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label_fa?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'left_backfender' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
catalogKey?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Legacy expert UI: camelCase segment with `side`, e.g. backFender',
|
||||
example: 'backFender',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
part?: string;
|
||||
}
|
||||
|
||||
/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */
|
||||
export class DaghiDetailsV2Dto {
|
||||
@ApiProperty({
|
||||
enum: DaghiOption,
|
||||
@@ -71,10 +24,11 @@ export class DaghiDetailsV2Dto {
|
||||
option: DaghiOption;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
|
||||
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman)`,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman()
|
||||
price?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
@@ -86,30 +40,39 @@ export class DaghiDetailsV2Dto {
|
||||
}
|
||||
|
||||
export class PartPricingV2Dto {
|
||||
@ApiProperty({ example: 'part-001' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
partId: string;
|
||||
|
||||
@ApiProperty({ type: ExpertReplyCarPartDamageV2Dto })
|
||||
@ValidateNested()
|
||||
@Type(() => ExpertReplyCarPartDamageV2Dto)
|
||||
carPartDamage: ExpertReplyCarPartDamageV2Dto;
|
||||
@ApiProperty({
|
||||
example: 201,
|
||||
description: 'Numeric catalog part id from claim detail `damagedParts[].partId`.',
|
||||
})
|
||||
@IsInt()
|
||||
partId: number;
|
||||
|
||||
@ApiProperty({ example: 'Minor' })
|
||||
@IsString()
|
||||
typeOfDamage: string;
|
||||
|
||||
@ApiProperty({ example: '5000000' })
|
||||
@ApiProperty({
|
||||
example: "5000000",
|
||||
description: "Part price in Toman (integer string). Use 0 if the full amount is in salary.",
|
||||
})
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman({ allowZero: true })
|
||||
price: string;
|
||||
|
||||
@ApiProperty({ example: '2000000' })
|
||||
@ApiProperty({
|
||||
example: "2000000",
|
||||
description: "Labor in Toman (integer string). Use 0 if the full amount is in price.",
|
||||
})
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman({ allowZero: true })
|
||||
salary: string;
|
||||
|
||||
@ApiProperty({ example: '7000000' })
|
||||
@ApiProperty({
|
||||
example: "7000000",
|
||||
description: "Line total in Toman (integer string).",
|
||||
})
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman()
|
||||
totalPayment: string;
|
||||
|
||||
@ApiProperty({ type: DaghiDetailsV2Dto })
|
||||
@@ -135,7 +98,7 @@ export class SubmitExpertReplyV2Dto {
|
||||
@ApiProperty({
|
||||
type: [PartPricingV2Dto],
|
||||
description:
|
||||
"Each part: manual pricing and `daghi`; set `factorNeeded` only where a repair factor upload is required from the owner.",
|
||||
"One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.",
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { IsRepairLineAmountToman } from "src/common/validators/repair-line-amount-toman.validator";
|
||||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||||
|
||||
class FactorDecisionDto {
|
||||
@@ -34,9 +36,9 @@ export class FactorValidationDto {
|
||||
|
||||
/** V2 ClaimCase: expert confirms or overrides part pricing when validating uploaded factors. */
|
||||
class FactorDecisionV2Dto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
partId: string;
|
||||
@ApiProperty({ example: 201 })
|
||||
@IsInt()
|
||||
partId: number;
|
||||
|
||||
@ApiProperty({ enum: [FactorStatus.APPROVED, FactorStatus.REJECTED] })
|
||||
@IsEnum([FactorStatus.APPROVED, FactorStatus.REJECTED])
|
||||
@@ -49,26 +51,29 @@ class FactorDecisionV2Dto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Part price (Persian/ASCII digits). For each factor line, send totalPayment **or** both price and salary. Required for **APPROVED** (accepted amount read from factor / expert) and **REJECTED** (expert’s replacement pricing).",
|
||||
"Part price (Toman). With totalPayment or price+salary for APPROVED/REJECTED factor lines. Use 0 if unused.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman({ allowZero: true })
|
||||
price?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Salary portion when not using a single totalPayment (must be sent together with price for APPROVED/REJECTED factor lines).",
|
||||
"Salary in Toman. Send with price when not using only totalPayment. Use 0 if unused.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman({ allowZero: true })
|
||||
salary?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Line total payment; preferred when the expert enters one number. Required together with APPROVED/REJECTED unless both price and salary are sent.",
|
||||
"Line total in Toman; or send both price and salary.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman()
|
||||
totalPayment?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,20 @@ import {
|
||||
claimCaseStatusToReportBucket,
|
||||
initialClaimExpertReportBuckets,
|
||||
} from "src/helpers/expert-panel-status-report";
|
||||
import {
|
||||
claimDocInExpertPortfolio,
|
||||
expertPortfolioFileIdsFromActivityEvents,
|
||||
objectIdsFromStringSet,
|
||||
} from "src/helpers/expert-portfolio";
|
||||
import {
|
||||
buildCoefficientsFromPartSeverities,
|
||||
buildDamagedPartsPriceDropLines,
|
||||
buildPriceDropCatalogForApi,
|
||||
calculateClaimPriceDrop,
|
||||
PRICE_DROP_SEVERITY_OPTIONS,
|
||||
resolveVehicleModelYearFromBlame,
|
||||
} from "src/helpers/claim-price-drop";
|
||||
import { UpsertClaimPriceDropV2Dto } from "./dto/claim-price-drop-v2.dto";
|
||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
@@ -82,6 +96,13 @@ import {
|
||||
coerceDamagedPartsMediaToArray,
|
||||
normalizeCarPartDamageForExpertReply,
|
||||
normalizeDamageSelectedParts,
|
||||
catalogPartIdFromCarPartDamage,
|
||||
catalogPartIdFromSelectedPart,
|
||||
partLookupKey,
|
||||
resolvePartForExpertReply,
|
||||
sameCatalogPartId,
|
||||
sanitizeDamageSelectedPartV2,
|
||||
type DamageSelectedPartV2,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
||||
@@ -93,8 +114,12 @@ import {
|
||||
ExpertFileKind,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
|
||||
/** Maximum sum of line `totalPayment` across the claim (priced parts + factor lines after validation). */
|
||||
const CLAIM_V2_TOTAL_PAYMENT_CAP = 53_000_000;
|
||||
/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */
|
||||
import { CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN } from "src/constants/repair-amount-limits";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||
|
||||
const CLAIM_V2_TOTAL_PAYMENT_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN;
|
||||
|
||||
@Injectable()
|
||||
export class ExpertClaimService {
|
||||
@@ -752,7 +777,7 @@ export class ExpertClaimService {
|
||||
|
||||
/** Price-cap total for one reply line: `totalPayment` if set, otherwise `price` + `salary`. */
|
||||
private claimReplyPartLineTotalForCap(part: {
|
||||
partId?: string;
|
||||
partId?: number | string;
|
||||
totalPayment?: unknown;
|
||||
price?: unknown;
|
||||
salary?: unknown;
|
||||
@@ -1502,7 +1527,7 @@ export class ExpertClaimService {
|
||||
|
||||
if (totalPrice > PRICE_CAP) {
|
||||
throw new BadRequestException({
|
||||
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
error: "PRICE_CAP_ERROR",
|
||||
code: "PRICE_CAP_ERROR",
|
||||
totalPrice: totalPrice,
|
||||
@@ -2068,7 +2093,7 @@ export class ExpertClaimService {
|
||||
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
|
||||
* — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature).
|
||||
* — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later).
|
||||
* Total of all repair lines must be ≤ `CLAIM_V2_TOTAL_PAYMENT_CAP` (53_000_000; same as initial expert reply).
|
||||
* Total of all repair lines must be ≤ `CLAIM_V2_TOTAL_PAYMENT_CAP` Toman (53_000_000; same as initial expert reply).
|
||||
* Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
|
||||
*/
|
||||
async validateClaimFactorsV2(
|
||||
@@ -2115,8 +2140,8 @@ export class ExpertClaimService {
|
||||
|
||||
const $set: Record<string, unknown> = {};
|
||||
for (const decision of body.decisions) {
|
||||
const partIndex = finalReply.parts.findIndex(
|
||||
(p) => String(p.partId) === String(decision.partId),
|
||||
const partIndex = finalReply.parts.findIndex((p) =>
|
||||
sameCatalogPartId(p.partId, decision.partId),
|
||||
);
|
||||
if (partIndex === -1) {
|
||||
this.logger.warn(
|
||||
@@ -2209,7 +2234,7 @@ export class ExpertClaimService {
|
||||
const line = this.claimReplyPartLineTotalForCap(part);
|
||||
if (isNaN(line)) {
|
||||
throw new BadRequestException({
|
||||
message: `Part ${String((part as { partId?: string }).partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`,
|
||||
message: `Part ${String(part.partId ?? "")}: invalid amount for price total — use totalPayment or price and salary.`,
|
||||
error: "PRICE_CAP_ERROR",
|
||||
});
|
||||
}
|
||||
@@ -2217,7 +2242,7 @@ export class ExpertClaimService {
|
||||
}
|
||||
if (totalPrice > PRICE_CAP) {
|
||||
throw new BadRequestException({
|
||||
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
error: "PRICE_CAP_ERROR",
|
||||
totalPrice,
|
||||
priceCap: PRICE_CAP,
|
||||
@@ -2302,6 +2327,15 @@ export class ExpertClaimService {
|
||||
return await this.branchDbService.findAll(insuranceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: branches for the damage expert's insurer tenant (JWT `clientKey`).
|
||||
* Same data as v1 `GET expert-claim/branches/:insuranceId` without passing client id.
|
||||
*/
|
||||
async retrieveInsuranceBranchesV2(actor: { clientKey?: string }) {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
return await this.branchDbService.findAll(clientKey);
|
||||
}
|
||||
|
||||
private globalErrHandler(error) {
|
||||
this.logger.error(error.response?.responseCode, error.response);
|
||||
throw new HttpException(error.response, error.claimStatus);
|
||||
@@ -2558,6 +2592,7 @@ export class ExpertClaimService {
|
||||
resendDescription: desc || undefined,
|
||||
resendDocuments: uniqueDocs,
|
||||
resendCarParts: normalizedParts,
|
||||
requestedByExpertId: new Types.ObjectId(actor.sub),
|
||||
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
|
||||
},
|
||||
},
|
||||
@@ -2594,6 +2629,14 @@ export class ExpertClaimService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.recordClaimExpertActivity({
|
||||
expertId: String(actor.sub),
|
||||
tenantId: this.claimActivityTenantId(claim, actor),
|
||||
claimId: String(claimRequestId),
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
idempotencyKey: `claim:${claimRequestId}:handled:resend:${actor.sub}`,
|
||||
});
|
||||
|
||||
return {
|
||||
claimRequestId,
|
||||
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
||||
@@ -2661,7 +2704,7 @@ export class ExpertClaimService {
|
||||
}
|
||||
if (totalPrice > PRICE_CAP) {
|
||||
throw new BadRequestException({
|
||||
message: `You have reached the maximum acceptable total price. The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
|
||||
error: 'PRICE_CAP_ERROR',
|
||||
totalPrice,
|
||||
priceCap: PRICE_CAP,
|
||||
@@ -2673,21 +2716,73 @@ 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 seenCatalogIds = new Set<number>();
|
||||
const expertAddedParts: DamageSelectedPartV2[] = [];
|
||||
|
||||
const processedParts = daghiNormalized.map((p) => {
|
||||
const selected = resolvePartForExpertReply(
|
||||
p.partId,
|
||||
ownerSelectedParts,
|
||||
carTypeSubmit,
|
||||
);
|
||||
if (!selected) {
|
||||
throw new BadRequestException(
|
||||
`Unknown partId "${p.partId}". Use numeric catalog id from claim detail damagedParts.`,
|
||||
);
|
||||
}
|
||||
|
||||
let carPartDamage: Record<string, unknown>;
|
||||
try {
|
||||
carPartDamage = normalizeCarPartDamageForExpertReply(
|
||||
p.carPartDamage as unknown,
|
||||
{
|
||||
id: selected.id,
|
||||
name: selected.name,
|
||||
side: selected.side,
|
||||
label_fa: selected.label_fa,
|
||||
...(selected.catalogKey ? { catalogKey: selected.catalogKey } : {}),
|
||||
},
|
||||
carTypeSubmit,
|
||||
);
|
||||
} catch (err) {
|
||||
throw new BadRequestException(
|
||||
`Invalid carPartDamage for part ${p.partId}: ${
|
||||
`Invalid part ${p.partId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return { ...p, carPartDamage };
|
||||
|
||||
const catalogId = catalogPartIdFromCarPartDamage(carPartDamage);
|
||||
if (catalogId == null) {
|
||||
throw new BadRequestException(
|
||||
`Invalid part ${p.partId}: a numeric catalog id is required.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (seenCatalogIds.has(catalogId)) {
|
||||
throw new BadRequestException(
|
||||
`Duplicate part submitted in expert reply: ${catalogId}.`,
|
||||
);
|
||||
}
|
||||
seenCatalogIds.add(catalogId);
|
||||
|
||||
const wasOnClaim = ownerSelectedParts.some(
|
||||
(op) => partLookupKey(op) === partLookupKey(selected),
|
||||
);
|
||||
if (!wasOnClaim) {
|
||||
expertAddedParts.push(selected);
|
||||
}
|
||||
|
||||
return {
|
||||
...p,
|
||||
partId: catalogId,
|
||||
carPartDamage,
|
||||
};
|
||||
});
|
||||
|
||||
const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts(
|
||||
@@ -2743,9 +2838,26 @@ export class ExpertClaimService {
|
||||
}
|
||||
}
|
||||
|
||||
const mergedSelectedParts = [...ownerSelectedParts];
|
||||
if (expertAddedParts.length > 0) {
|
||||
const mergedKeys = new Set(
|
||||
mergedSelectedParts.map((sp) => partLookupKey(sp)),
|
||||
);
|
||||
for (const sp of expertAddedParts) {
|
||||
const k = partLookupKey(sp);
|
||||
if (!mergedKeys.has(k)) {
|
||||
mergedKeys.add(k);
|
||||
mergedSelectedParts.push(sp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatePayload: Record<string, unknown> = {
|
||||
status: nextCaseStatus,
|
||||
claimStatus: nextClaimStatus,
|
||||
...(expertAddedParts.length > 0
|
||||
? { "damage.selectedParts": mergedSelectedParts }
|
||||
: {}),
|
||||
'workflow.locked': false,
|
||||
$unset: {
|
||||
'workflow.lockedAt': '',
|
||||
@@ -2909,18 +3021,55 @@ export class ExpertClaimService {
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Count claim cases for this damage expert’s tenant, grouped for dashboard:
|
||||
* `IN_PROGRESS` = user flow before submission complete (CREATED … CAPTURING_PART_DAMAGES);
|
||||
* other {@link ClaimCaseStatus} keys unchanged.
|
||||
* V2: Count claim cases in this damage expert’s portfolio, grouped for dashboard.
|
||||
* Includes files the expert locked, replied on, or has CHECKED/HANDLED in expertFileActivities.
|
||||
* Does not include the open tenant queue (`WAITING_FOR_DAMAGE_EXPERT`) unless this expert touched the file.
|
||||
*/
|
||||
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const rows = await this.claimCaseDbService.find({}, { lean: true });
|
||||
const expertId = String(actor.sub);
|
||||
const expertOid = new Types.ObjectId(expertId);
|
||||
|
||||
const activityEvents = await this.expertFileActivityDbService.findByExpert(
|
||||
expertId,
|
||||
ExpertFileKind.CLAIM,
|
||||
);
|
||||
const activityFileIds =
|
||||
expertPortfolioFileIdsFromActivityEvents(activityEvents);
|
||||
|
||||
const orClauses: Record<string, unknown>[] = [
|
||||
{ "workflow.lockedBy.actorId": expertOid },
|
||||
{ "evaluation.damageExpertReply.actorDetail.actorId": expertOid },
|
||||
{ "evaluation.damageExpertReplyFinal.actorDetail.actorId": expertOid },
|
||||
{ "evaluation.damageExpertResend.requestedByExpertId": expertOid },
|
||||
];
|
||||
const activityOids = objectIdsFromStringSet(activityFileIds);
|
||||
if (activityOids.length > 0) {
|
||||
orClauses.push({ _id: { $in: activityOids } });
|
||||
}
|
||||
|
||||
const rows =
|
||||
orClauses.length === 0
|
||||
? []
|
||||
: await this.claimCaseDbService.find({ $or: orClauses }, { lean: true });
|
||||
|
||||
const buckets = initialClaimExpertReportBuckets();
|
||||
const seen = new Set<string>();
|
||||
for (const doc of rows as Record<string, unknown>[]) {
|
||||
if (!claimCaseTouchesClient(doc, clientKey)) continue;
|
||||
const st = String(doc.status ?? "");
|
||||
const key = claimCaseStatusToReportBucket(st);
|
||||
const id = String(doc._id ?? "");
|
||||
if (seen.has(id)) continue;
|
||||
if (
|
||||
!claimDocInExpertPortfolio(
|
||||
doc,
|
||||
expertId,
|
||||
activityFileIds,
|
||||
clientKey,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
const key = claimCaseStatusToReportBucket(String(doc.status ?? ""));
|
||||
buckets.all++;
|
||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
||||
}
|
||||
@@ -2935,7 +3084,10 @@ export class ExpertClaimService {
|
||||
* 2. Locked by this expert (in-progress)
|
||||
* 3. EXPERT_VALIDATING_REPAIR_FACTORS (or legacy WAITING_FOR_INSURER_APPROVAL) + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue)
|
||||
*/
|
||||
async getClaimListV2(actor: any): Promise<GetClaimListV2ResponseDto> {
|
||||
async getClaimListV2(
|
||||
actor: any,
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<GetClaimListV2ResponseDto> {
|
||||
requireActorClientKey(actor);
|
||||
const actorId = actor.sub;
|
||||
const clientKey = actor.clientKey as string;
|
||||
@@ -3069,7 +3221,28 @@ export class ExpertClaimService {
|
||||
};
|
||||
}) as ClaimListItemV2Dto[];
|
||||
|
||||
return { list, total: list.length };
|
||||
const paged = applyListQueryV2(list, {
|
||||
publicId: (r) => r.publicId,
|
||||
createdAt: (r) => r.createdAt,
|
||||
requestNo: (r) => r.publicId,
|
||||
status: (r) => r.status,
|
||||
searchExtras: (r) =>
|
||||
[
|
||||
r.claimRequestId,
|
||||
r.currentStep,
|
||||
r.vehicle?.carName,
|
||||
r.vehicle?.carModel,
|
||||
r.blameRequestType,
|
||||
].filter(Boolean) as string[],
|
||||
}, query);
|
||||
|
||||
return {
|
||||
list: paged.list,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3491,6 +3664,7 @@ export class ExpertClaimService {
|
||||
) as { url?: string; path?: string } | undefined;
|
||||
return {
|
||||
index,
|
||||
partId: catalogPartIdFromSelectedPart(sp),
|
||||
id: sp.id,
|
||||
name: sp.name,
|
||||
side: sp.side,
|
||||
@@ -3638,6 +3812,12 @@ export class ExpertClaimService {
|
||||
evaluationForApi.ownerPricedPartsApproval =
|
||||
enrichedEvaluation.ownerPricedPartsApproval;
|
||||
}
|
||||
if (
|
||||
isDamageExpertPhase &&
|
||||
enrichedEvaluation.priceDrop !== undefined
|
||||
) {
|
||||
evaluationForApi.priceDrop = enrichedEvaluation.priceDrop;
|
||||
}
|
||||
if (Object.keys(evaluationForApi).length === 0) {
|
||||
evaluationForApi = undefined;
|
||||
}
|
||||
@@ -3693,6 +3873,180 @@ export class ExpertClaimService {
|
||||
};
|
||||
}
|
||||
|
||||
/** V2 price-drop: claim locked by this expert during damage assessment. */
|
||||
private assertExpertCanEditClaimDuringReviewV2(claim: any, actor: any): void {
|
||||
requireActorClientKey(actor);
|
||||
assertClaimCaseForTenant(claim, actor);
|
||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||
throw new BadRequestException(
|
||||
`Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`,
|
||||
);
|
||||
}
|
||||
if (!claim.workflow?.locked) {
|
||||
throw new ForbiddenException(
|
||||
"You must lock the claim before entering price-drop data",
|
||||
);
|
||||
}
|
||||
if (claim.workflow.lockedBy?.actorId?.toString() !== String(actor.sub)) {
|
||||
throw new ForbiddenException("This claim is locked by another expert");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Context for manual price-drop (vehicle, inquiry year, catalog, damaged parts).
|
||||
*/
|
||||
async getPriceDropContextV2(claimRequestId: string, actor: any) {
|
||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||
if (!claim) {
|
||||
throw new NotFoundException("Claim request not found");
|
||||
}
|
||||
this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
|
||||
|
||||
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
const selectedNorm = normalizeDamageSelectedParts(
|
||||
claim.damage?.selectedParts,
|
||||
carType,
|
||||
(claim.damage as any)?.selectedOuterParts,
|
||||
);
|
||||
|
||||
let suggestedCarModelYear: number | null = null;
|
||||
if (claim.blameRequestId) {
|
||||
const blameRows = await this.blameRequestDbService.find(
|
||||
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
|
||||
{
|
||||
lean: true,
|
||||
select: "parties.role parties.person.userId parties.vehicle.inquiry",
|
||||
},
|
||||
);
|
||||
const blame = blameRows[0] as Record<string, unknown> | undefined;
|
||||
suggestedCarModelYear = resolveVehicleModelYearFromBlame(
|
||||
claim,
|
||||
blame as Parameters<typeof resolveVehicleModelYearFromBlame>[1],
|
||||
);
|
||||
}
|
||||
|
||||
const existing = (claim as any).evaluation?.priceDrop;
|
||||
|
||||
return {
|
||||
claimRequestId: String(claim._id),
|
||||
vehicle: {
|
||||
carName: claim.vehicle?.carName,
|
||||
carModel: claim.vehicle?.carModel,
|
||||
carType: claim.vehicle?.carType,
|
||||
},
|
||||
suggestedCarModelYear,
|
||||
priceDrop: existing ?? null,
|
||||
severityOptions: PRICE_DROP_SEVERITY_OPTIONS,
|
||||
priceDropCatalog: buildPriceDropCatalogForApi(),
|
||||
damagedParts: buildDamagedPartsPriceDropLines(selectedNorm),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Expert enters vehicle + per-part severities; persists `vehicle` and `evaluation.priceDrop`.
|
||||
*/
|
||||
async upsertPriceDropV2(
|
||||
claimRequestId: string,
|
||||
body: UpsertClaimPriceDropV2Dto,
|
||||
actor: any,
|
||||
) {
|
||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||
if (!claim) {
|
||||
throw new NotFoundException("Claim request not found");
|
||||
}
|
||||
this.assertExpertCanEditClaimDuringReviewV2(claim, actor);
|
||||
|
||||
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
const selectedNorm = normalizeDamageSelectedParts(
|
||||
claim.damage?.selectedParts,
|
||||
carType,
|
||||
(claim.damage as any)?.selectedOuterParts,
|
||||
);
|
||||
|
||||
if (!body.partSeverities?.length) {
|
||||
throw new BadRequestException(
|
||||
"Provide at least one partSeverities line for a damaged part.",
|
||||
);
|
||||
}
|
||||
|
||||
let carModelYear = body.carModelYear;
|
||||
if (carModelYear == null && claim.blameRequestId) {
|
||||
const blameRows = await this.blameRequestDbService.find(
|
||||
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
|
||||
{
|
||||
lean: true,
|
||||
select: "parties.role parties.person.userId parties.vehicle.inquiry",
|
||||
},
|
||||
);
|
||||
carModelYear =
|
||||
resolveVehicleModelYearFromBlame(
|
||||
claim,
|
||||
blameRows[0] as Parameters<typeof resolveVehicleModelYearFromBlame>[1],
|
||||
) ?? undefined;
|
||||
}
|
||||
if (carModelYear == null || !Number.isFinite(carModelYear)) {
|
||||
throw new BadRequestException(
|
||||
"carModelYear is required when it cannot be resolved from the linked blame plate inquiry.",
|
||||
);
|
||||
}
|
||||
|
||||
const { coefficients, lines, errors } = buildCoefficientsFromPartSeverities(
|
||||
selectedNorm,
|
||||
body.partSeverities.map((p) => ({
|
||||
partId: p.partId,
|
||||
severity: p.severity,
|
||||
})),
|
||||
carType,
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException(errors.join(" "));
|
||||
}
|
||||
if (coefficients.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"No valid severity coefficients could be derived from partSeverities.",
|
||||
);
|
||||
}
|
||||
|
||||
const calculated = calculateClaimPriceDrop(
|
||||
body.carPrice,
|
||||
carModelYear,
|
||||
coefficients,
|
||||
);
|
||||
|
||||
const priceDropPayload = {
|
||||
...calculated,
|
||||
partLines: lines,
|
||||
};
|
||||
|
||||
const vehicleSet: Record<string, string> = {};
|
||||
if (body.carName != null && String(body.carName).trim()) {
|
||||
vehicleSet["vehicle.carName"] = String(body.carName).trim();
|
||||
}
|
||||
if (body.carModel != null && String(body.carModel).trim()) {
|
||||
vehicleSet["vehicle.carModel"] = String(body.carModel).trim();
|
||||
}
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: {
|
||||
...vehicleSet,
|
||||
"evaluation.priceDrop": priceDropPayload,
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await this.claimCaseDbService.findById(claimRequestId);
|
||||
|
||||
return {
|
||||
claimRequestId,
|
||||
vehicle: {
|
||||
carName: updated?.vehicle?.carName,
|
||||
carModel: updated?.vehicle?.carModel,
|
||||
carType: updated?.vehicle?.carType,
|
||||
},
|
||||
priceDrop: priceDropPayload,
|
||||
partLines: lines,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Damage expert can modify selected damaged parts before final submit.
|
||||
* Allowed only when claim is EXPERT_REVIEWING and locked by this expert.
|
||||
@@ -3735,13 +4089,19 @@ export class ExpertClaimService {
|
||||
previousNorm,
|
||||
);
|
||||
|
||||
const nextNorm = body.selectedParts.map((p) => ({
|
||||
id: p.id ?? null,
|
||||
name: String(p.name || "").trim(),
|
||||
side: String(p.side || ""),
|
||||
label_fa: String(p.label_fa || "").trim() || String(p.name || "").trim(),
|
||||
...(p.catalogKey?.trim() ? { catalogKey: p.catalogKey.trim() } : {}),
|
||||
}));
|
||||
const nextNorm = body.selectedParts.map((p) =>
|
||||
sanitizeDamageSelectedPartV2(
|
||||
{
|
||||
id: p.id ?? null,
|
||||
name: String(p.name || "").trim(),
|
||||
side: String(p.side || ""),
|
||||
label_fa:
|
||||
String(p.label_fa || "").trim() || String(p.name || "").trim(),
|
||||
...(p.catalogKey?.trim() ? { catalogKey: p.catalogKey.trim() } : {}),
|
||||
},
|
||||
carType,
|
||||
),
|
||||
);
|
||||
|
||||
const matchPreviousIndex = (
|
||||
next: (typeof nextNorm)[0],
|
||||
|
||||
@@ -16,8 +16,15 @@ import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertClaimService } from "./expert-claim.service";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto";
|
||||
import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
|
||||
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
|
||||
import {
|
||||
ClaimPriceDropContextV2Dto,
|
||||
ClaimPriceDropResultV2Dto,
|
||||
UpsertClaimPriceDropV2Dto,
|
||||
} from "./dto/claim-price-drop-v2.dto";
|
||||
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
|
||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||
import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
||||
@@ -43,9 +50,9 @@ export class ExpertClaimV2Controller {
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Count claim cases by grouped status bucket (tenant)",
|
||||
summary: "Count claim cases by grouped status bucket (this damage expert)",
|
||||
description:
|
||||
"IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Scoped to the insurer in the JWT.",
|
||||
"Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), current workflow lock, or a prior damageExpertReply / damageExpertReplyFinal by this expert. IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Does not include the open tenant queue unless this expert has touched the file.",
|
||||
})
|
||||
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||
return await this.expertClaimService.getStatusReportBucketsV2(actor);
|
||||
@@ -71,21 +78,41 @@ export class ExpertClaimV2Controller {
|
||||
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||
}
|
||||
|
||||
@Get("branches")
|
||||
@ApiOperation({
|
||||
summary: "List insurer branches for this damage expert (V2)",
|
||||
description:
|
||||
"Returns branches for the insurance company bound to the expert JWT (`clientKey`). " +
|
||||
"Same payload as v1 `GET expert-claim/branches/:insuranceId` — no client id in the URL.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description:
|
||||
"Branch list (name, code, address, city, state, phoneNumber, isActive, …) for daghi / branch selection in expert replies",
|
||||
})
|
||||
async getInsuranceBranchesV2(@CurrentUser() actor: { clientKey?: string }) {
|
||||
return await this.expertClaimService.retrieveInsuranceBranchesV2(actor);
|
||||
}
|
||||
|
||||
@Get("requests")
|
||||
@ApiOperation({
|
||||
summary: "List available claim requests for damage expert",
|
||||
description:
|
||||
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation (`status=EXPERT_VALIDATING_REPAIR_FACTORS` or legacy `WAITING_FOR_INSURER_APPROVAL` with UNDER_REVIEW at EXPERT_COST_EVALUATION).",
|
||||
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
||||
})
|
||||
async getClaimListV2(@CurrentUser() actor) {
|
||||
return await this.expertClaimService.getClaimListV2(actor);
|
||||
@ApiResponse({ status: 200, type: GetClaimListV2ResponseDto })
|
||||
async getClaimListV2(
|
||||
@CurrentUser() actor,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
): Promise<GetClaimListV2ResponseDto> {
|
||||
return await this.expertClaimService.getClaimListV2(actor, query);
|
||||
}
|
||||
|
||||
@Get("request/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Get claim request detail for damage expert",
|
||||
description:
|
||||
"Returns full claim details including captured images, required documents, damage selections, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
|
||||
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` during damage review, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async getClaimDetailV2(
|
||||
@@ -95,6 +122,45 @@ export class ExpertClaimV2Controller {
|
||||
return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor);
|
||||
}
|
||||
|
||||
@Get("request/:claimRequestId/price-drop")
|
||||
@ApiOperation({
|
||||
summary: "Price-drop context for manual expert entry (V2)",
|
||||
description:
|
||||
"While the claim is locked in EXPERT_REVIEWING: severity labels (جزیی / متوسط / شدید), coefficient catalog, damaged parts with price-drop mapping, suggested Jalali year from linked blame plate inquiry (`ModelField` / `ModelCii`), and saved `evaluation.priceDrop` if any.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiResponse({ status: 200, type: ClaimPriceDropContextV2Dto })
|
||||
async getPriceDropContextV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.getPriceDropContextV2(
|
||||
claimRequestId,
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@Put("request/:claimRequestId/price-drop")
|
||||
@ApiOperation({
|
||||
summary: "Calculate and save price drop (V2)",
|
||||
description:
|
||||
"Expert supplies car market price, optional `vehicle.carName` / `vehicle.carModel`, per-damaged-part severity (`partId` from claim detail), and optional `carModelYear` (defaults from blame inquiry). Persists `evaluation.priceDrop` using `(carPrice × yearCoefficient × sumOfCoefficients) / 400`.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: UpsertClaimPriceDropV2Dto })
|
||||
@ApiResponse({ status: 200, type: ClaimPriceDropResultV2Dto })
|
||||
async upsertPriceDropV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UpsertClaimPriceDropV2Dto,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.upsertPriceDropV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@Put("lock/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Lock a claim request for review",
|
||||
@@ -116,7 +182,7 @@ export class ExpertClaimV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Submit expert damage assessment reply",
|
||||
description:
|
||||
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each part needs `daghi` (V1 rules) and may set `factorNeeded` (repair factor file required from owner) or priced lines only. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
|
||||
"**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
|
||||
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
|
||||
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
|
||||
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
|
||||
@@ -179,7 +245,7 @@ export class ExpertClaimV2Controller {
|
||||
"**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" +
|
||||
"**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
|
||||
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" +
|
||||
"**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
|
||||
"**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
|
||||
"**Outcomes:**\n" +
|
||||
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature.\n" +
|
||||
"- **Any rejected (repriced):** same auto-complete for now (owner acceptance may be added later).\n" +
|
||||
|
||||
95
src/helpers/claim-capture-phase.spec.ts
Normal file
95
src/helpers/claim-capture-phase.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
getClaimCaptureProgress,
|
||||
isClaimCaptureStepComplete,
|
||||
} from "./claim-capture-phase";
|
||||
|
||||
describe("claim-capture-phase", () => {
|
||||
const baseClaim = {
|
||||
vehicle: { carType: "sedan" },
|
||||
damage: {
|
||||
selectedParts: [{ name: "hood", side: "front", label_fa: "کاپوت" }],
|
||||
},
|
||||
media: { carAngles: {}, damagedParts: [] },
|
||||
requiredDocuments: {},
|
||||
};
|
||||
|
||||
it("starts in parts phase", () => {
|
||||
const p = getClaimCaptureProgress(baseClaim);
|
||||
expect(p.sequencePhase).toBe("parts");
|
||||
expect(p.partsComplete).toBe(false);
|
||||
});
|
||||
|
||||
it("moves to angles after all parts", () => {
|
||||
const claim = {
|
||||
...baseClaim,
|
||||
media: {
|
||||
carAngles: {},
|
||||
damagedParts: [
|
||||
{
|
||||
name: "hood",
|
||||
side: "front",
|
||||
label_fa: "کاپوت",
|
||||
path: "/x.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const p = getClaimCaptureProgress(claim);
|
||||
expect(p.sequencePhase).toBe("angles");
|
||||
expect(p.partsComplete).toBe(true);
|
||||
expect(p.anglesComplete).toBe(false);
|
||||
});
|
||||
|
||||
it("does not complete capture step with only capture-phase docs uploaded", () => {
|
||||
const claim = {
|
||||
...baseClaim,
|
||||
media: {
|
||||
carAngles: {},
|
||||
damagedParts: [
|
||||
{
|
||||
name: "hood",
|
||||
side: "front",
|
||||
label_fa: "کاپوت",
|
||||
path: "/x.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
requiredDocuments: {
|
||||
damaged_chassis_number: { uploaded: true },
|
||||
damaged_engine_photo: { uploaded: true },
|
||||
damaged_metal_plate: { uploaded: true },
|
||||
},
|
||||
};
|
||||
expect(isClaimCaptureStepComplete(claim)).toBe(false);
|
||||
expect(getClaimCaptureProgress(claim).sequencePhase).toBe("angles");
|
||||
});
|
||||
|
||||
it("is complete only when parts, angles, and capture-phase docs are done", () => {
|
||||
const claim = {
|
||||
...baseClaim,
|
||||
media: {
|
||||
carAngles: {
|
||||
front: { path: "/f.jpg" },
|
||||
back: { path: "/b.jpg" },
|
||||
left: { path: "/l.jpg" },
|
||||
right: { path: "/r.jpg" },
|
||||
},
|
||||
damagedParts: [
|
||||
{
|
||||
name: "hood",
|
||||
side: "front",
|
||||
label_fa: "کاپوت",
|
||||
path: "/x.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
requiredDocuments: {
|
||||
damaged_chassis_number: { uploaded: true },
|
||||
damaged_engine_photo: { uploaded: true },
|
||||
damaged_metal_plate: { uploaded: true },
|
||||
},
|
||||
};
|
||||
expect(isClaimCaptureStepComplete(claim)).toBe(true);
|
||||
expect(getClaimCaptureProgress(claim).sequencePhase).toBe("complete");
|
||||
});
|
||||
});
|
||||
127
src/helpers/claim-capture-phase.ts
Normal file
127
src/helpers/claim-capture-phase.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
CLAIM_CAR_ANGLE_KEYS,
|
||||
hasClaimCarAngleCapture,
|
||||
hasDamagedPartCapture,
|
||||
} from "src/helpers/claim-car-angle-media";
|
||||
import {
|
||||
catalogLikeKeyFromPart,
|
||||
normalizeDamageSelectedParts,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
import type { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
|
||||
/** Upload during CAPTURE_PART_DAMAGES (after parts + angles). */
|
||||
export const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [
|
||||
"damaged_chassis_number",
|
||||
"damaged_engine_photo",
|
||||
"damaged_metal_plate",
|
||||
] as const;
|
||||
|
||||
export type CapturePhaseSequence =
|
||||
| "parts"
|
||||
| "angles"
|
||||
| "capture_phase_documents"
|
||||
| "complete";
|
||||
|
||||
export interface ClaimCaptureProgress {
|
||||
partsCaptured: number;
|
||||
partsTotal: number;
|
||||
partsComplete: boolean;
|
||||
anglesCaptured: number;
|
||||
anglesTotal: number;
|
||||
anglesComplete: boolean;
|
||||
capturePhaseDocsComplete: boolean;
|
||||
capturePhaseDocsRemaining: number;
|
||||
sequencePhase: CapturePhaseSequence;
|
||||
}
|
||||
|
||||
export function isCapturePhaseDamagedPartyDocKey(key: string): boolean {
|
||||
return (CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS as readonly string[]).includes(
|
||||
key,
|
||||
);
|
||||
}
|
||||
|
||||
function isRequiredDocumentUploadedOnClaim(
|
||||
claimCase: any,
|
||||
key: string,
|
||||
): boolean {
|
||||
const doc =
|
||||
(claimCase?.requiredDocuments as any)?.get?.(key) ??
|
||||
claimCase?.requiredDocuments?.[key];
|
||||
return !!doc?.uploaded;
|
||||
}
|
||||
|
||||
export function getClaimCaptureProgress(
|
||||
claimCase: any,
|
||||
options?: { assumeCapturePhaseDocKey?: string },
|
||||
): ClaimCaptureProgress {
|
||||
const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
const selectedNorm = normalizeDamageSelectedParts(
|
||||
claimCase?.damage?.selectedParts,
|
||||
carType,
|
||||
claimCase?.damage?.selectedOuterParts,
|
||||
);
|
||||
const carAnglesData = claimCase?.media?.carAngles;
|
||||
const damagedPartsData = claimCase?.media?.damagedParts;
|
||||
|
||||
const anglesCaptured = CLAIM_CAR_ANGLE_KEYS.filter((k) =>
|
||||
hasClaimCarAngleCapture(carAnglesData, damagedPartsData, k),
|
||||
).length;
|
||||
const partsCaptured = selectedNorm.filter((sp) => {
|
||||
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
||||
return hasDamagedPartCapture(damagedPartsData, ck, selectedNorm);
|
||||
}).length;
|
||||
|
||||
const partsTotal = selectedNorm.length;
|
||||
const partsComplete = partsTotal === 0 || partsCaptured >= partsTotal;
|
||||
const anglesTotal = CLAIM_CAR_ANGLE_KEYS.length;
|
||||
const anglesComplete = anglesCaptured >= anglesTotal;
|
||||
|
||||
const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
|
||||
(k) => {
|
||||
if (k === options?.assumeCapturePhaseDocKey) return false;
|
||||
return !isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||||
},
|
||||
).length;
|
||||
const capturePhaseDocsComplete = capturePhaseDocsRemaining === 0;
|
||||
|
||||
let sequencePhase: CapturePhaseSequence = "parts";
|
||||
if (partsComplete && !anglesComplete) {
|
||||
sequencePhase = "angles";
|
||||
} else if (partsComplete && anglesComplete && !capturePhaseDocsComplete) {
|
||||
sequencePhase = "capture_phase_documents";
|
||||
} else if (partsComplete && anglesComplete && capturePhaseDocsComplete) {
|
||||
sequencePhase = "complete";
|
||||
}
|
||||
|
||||
return {
|
||||
partsCaptured,
|
||||
partsTotal,
|
||||
partsComplete,
|
||||
anglesCaptured,
|
||||
anglesTotal,
|
||||
anglesComplete,
|
||||
capturePhaseDocsComplete,
|
||||
capturePhaseDocsRemaining,
|
||||
sequencePhase,
|
||||
};
|
||||
}
|
||||
|
||||
export function isClaimCaptureStepComplete(claimCase: any): boolean {
|
||||
const p = getClaimCaptureProgress(claimCase);
|
||||
return (
|
||||
p.partsComplete && p.anglesComplete && p.capturePhaseDocsComplete
|
||||
);
|
||||
}
|
||||
|
||||
export function capturePhaseSequenceMessage(phase: CapturePhaseSequence): string {
|
||||
switch (phase) {
|
||||
case "parts":
|
||||
return "Upload photos for all selected damaged parts first.";
|
||||
case "angles":
|
||||
return "Capture all four car angles (front, back, left, right) next.";
|
||||
case "capture_phase_documents":
|
||||
return "Upload chassis number, engine photo, and damaged metal plate photos last.";
|
||||
default:
|
||||
return "Damage capture is complete.";
|
||||
}
|
||||
}
|
||||
83
src/helpers/claim-price-drop.spec.ts
Normal file
83
src/helpers/claim-price-drop.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
buildCoefficientsFromPartSeverities,
|
||||
calculateClaimPriceDrop,
|
||||
resolvePriceDropPartKeyFromDamagePart,
|
||||
resolveVehicleModelYearFromBlame,
|
||||
} from "./claim-price-drop";
|
||||
|
||||
describe("claim-price-drop", () => {
|
||||
it("maps catalog segment to price drop key", () => {
|
||||
expect(
|
||||
resolvePriceDropPartKeyFromDamagePart({
|
||||
name: "backfender",
|
||||
catalogKey: "left_backfender",
|
||||
}),
|
||||
).toBe("backFender");
|
||||
expect(
|
||||
resolvePriceDropPartKeyFromDamagePart({
|
||||
name: "hood",
|
||||
catalogKey: "front_carHood",
|
||||
}),
|
||||
).toBe("Hood");
|
||||
});
|
||||
|
||||
it("calculates total from formula", () => {
|
||||
const r = calculateClaimPriceDrop(400_000_000, 1394, [2, 3]);
|
||||
expect(r.sumOfSeverity).toBe(5);
|
||||
expect(r.coefficientYear).toBe(2.1);
|
||||
expect(r.total).toBe((400_000_000 * 2.1 * 5) / 400);
|
||||
});
|
||||
|
||||
it("reads model year from blame inquiry", () => {
|
||||
const year = resolveVehicleModelYearFromBlame(
|
||||
{ owner: { userId: "u1" } },
|
||||
{
|
||||
parties: [
|
||||
{
|
||||
role: "FIRST",
|
||||
person: { userId: "u1" },
|
||||
vehicle: {
|
||||
inquiry: { mapped: { ModelField: "1395" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(year).toBe(1395);
|
||||
});
|
||||
|
||||
it("builds coefficients from expert severities", () => {
|
||||
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
name: "backfender",
|
||||
side: "left",
|
||||
label_fa: "گلگیر",
|
||||
catalogKey: "left_backfender",
|
||||
},
|
||||
],
|
||||
[{ partId: 1, severity: "Moderate" }],
|
||||
);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(coefficients).toEqual([3]);
|
||||
});
|
||||
|
||||
it("accepts numeric catalog id (e.g. 201)", () => {
|
||||
const { coefficients, errors, lines } = buildCoefficientsFromPartSeverities(
|
||||
[
|
||||
{
|
||||
id: 201,
|
||||
name: "backfender",
|
||||
side: "left",
|
||||
label_fa: "گلگیر عقب (چپ)",
|
||||
catalogKey: "left_backfender",
|
||||
},
|
||||
],
|
||||
[{ partId: 201, severity: "Moderate" }],
|
||||
);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(coefficients).toEqual([3]);
|
||||
expect(lines[0]?.partId).toBe(201);
|
||||
});
|
||||
});
|
||||
349
src/helpers/claim-price-drop.ts
Normal file
349
src/helpers/claim-price-drop.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
ClaimVehicleTypeV2,
|
||||
} from "src/static/outer-car-parts-catalog";
|
||||
import {
|
||||
catalogPartIdFromSelectedPart,
|
||||
parseCatalogPartIdInput,
|
||||
resolvePartForExpertReply,
|
||||
type DamageSelectedPartV2,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
|
||||
/** Canonical severity values for expert input (API). */
|
||||
export type PriceDropSeverity = "Minor" | "Moderate" | "Severe";
|
||||
|
||||
export const PRICE_DROP_SEVERITY_FA: Record<PriceDropSeverity, string> = {
|
||||
Minor: "جزیی",
|
||||
Moderate: "متوسط",
|
||||
Severe: "شدید",
|
||||
};
|
||||
|
||||
export const PRICE_DROP_SEVERITY_OPTIONS = (
|
||||
Object.entries(PRICE_DROP_SEVERITY_FA) as [PriceDropSeverity, string][]
|
||||
).map(([value, label_fa]) => ({ value, label_fa }));
|
||||
|
||||
/** Part → severity → coefficient (same table as V1 expert-claim). */
|
||||
export const PRICE_DROP_PART_TABLE: Record<
|
||||
string,
|
||||
Partial<Record<string, number>>
|
||||
> = {
|
||||
backFender: { Minor: 2, Moderate: 3, Severe: 5 },
|
||||
backDoor: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
frontDoor: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
frontFender: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
frontBumper: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
Hood: { Minor: 2, moderate: 3, Severe: 4 },
|
||||
Trunk: { minor: 1, moderate: 3, Severe: 5 },
|
||||
Roof: { Minor: 3, Moderate: 5, Severe: 7 },
|
||||
coil: { Minor: 2, Moderate: 3, Severe: 4 },
|
||||
column: { Minor: 2, Moderate: 3, Severe: 4 },
|
||||
frontTray: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
backTray: { Minor: 2, moderate: 4, Severe: 5 },
|
||||
frontChassis: { Minor: 3, Moderate: 5, Severe: 7 },
|
||||
backChassis: { Minor: 2, Moderate: 4, Severe: 6 },
|
||||
carFootrest: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
carFloor: { Minor: 4, moderate: 6, Severe: 8 },
|
||||
};
|
||||
|
||||
/** Jalali model year → year coefficient (V1). */
|
||||
export const PRICE_DROP_YEAR_COEFFICIENT: Record<number, number> = {
|
||||
1393: 2.05,
|
||||
1394: 2.1,
|
||||
1395: 2.2,
|
||||
1396: 2.3,
|
||||
1397: 2.4,
|
||||
1398: 2.5,
|
||||
1399: 2.6,
|
||||
1400: 2.7,
|
||||
1401: 2.8,
|
||||
1402: 2.9,
|
||||
1403: 3,
|
||||
1404: 3,
|
||||
};
|
||||
|
||||
const PRICE_DROP_PART_LABEL_FA: Record<string, string> = {
|
||||
backFender: "گلگیر عقب",
|
||||
backDoor: "در عقب",
|
||||
frontDoor: "در جلو",
|
||||
frontFender: "گلگیر جلو",
|
||||
frontBumper: "سپر جلو",
|
||||
Hood: "کاپوت",
|
||||
Trunk: "صندوق عقب",
|
||||
Roof: "سقف",
|
||||
coil: "لوله",
|
||||
column: "ستون",
|
||||
frontTray: "سینی جلو",
|
||||
backTray: "سینی عقب",
|
||||
frontChassis: "شاسی جلو",
|
||||
backChassis: "شاسی عقب",
|
||||
carFootrest: "پا رکاب",
|
||||
carFloor: "کف خودرو",
|
||||
};
|
||||
|
||||
/** Catalog / damage `name` segments → price-drop part key. */
|
||||
const SEGMENT_ALIASES: Record<string, string> = {
|
||||
backfender: "backFender",
|
||||
backdoor: "backDoor",
|
||||
frontdoor: "frontDoor",
|
||||
frontfender: "frontFender",
|
||||
frontbumper: "frontBumper",
|
||||
backbumper: "frontBumper",
|
||||
carhood: "Hood",
|
||||
hood: "Hood",
|
||||
cartrunk: "Trunk",
|
||||
trunk: "Trunk",
|
||||
roof: "Roof",
|
||||
coil: "coil",
|
||||
column: "column",
|
||||
fronttray: "frontTray",
|
||||
backtray: "backTray",
|
||||
frontchassis: "frontChassis",
|
||||
backchassis: "backChassis",
|
||||
carfootrest: "carFootrest",
|
||||
carfloor: "carFloor",
|
||||
};
|
||||
|
||||
const NORM_TO_PART_KEY = new Map<string, string>();
|
||||
for (const key of Object.keys(PRICE_DROP_PART_TABLE)) {
|
||||
NORM_TO_PART_KEY.set(normalizePriceDropKey(key), key);
|
||||
}
|
||||
for (const [seg, key] of Object.entries(SEGMENT_ALIASES)) {
|
||||
NORM_TO_PART_KEY.set(seg, key);
|
||||
}
|
||||
|
||||
export function normalizePriceDropKey(str: string): string {
|
||||
return String(str ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
export function parsePriceDropNumber(input: number | string): number {
|
||||
if (typeof input === "number" && Number.isFinite(input)) return input;
|
||||
return Number(
|
||||
String(input)
|
||||
.replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
|
||||
.replace(/,/g, "")
|
||||
.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSeverityCoefficient(
|
||||
partKey: string,
|
||||
severity: string,
|
||||
): number | undefined {
|
||||
const row = PRICE_DROP_PART_TABLE[partKey];
|
||||
if (!row) return undefined;
|
||||
const sev = String(severity ?? "").trim();
|
||||
if (row[sev] !== undefined) return row[sev];
|
||||
const cap = sev.charAt(0).toUpperCase() + sev.slice(1).toLowerCase();
|
||||
if (row[cap] !== undefined) return row[cap];
|
||||
const low = sev.toLowerCase();
|
||||
if (row[low] !== undefined) return row[low];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function calculateClaimPriceDrop(
|
||||
carPrice: number | string,
|
||||
carModelYear: number,
|
||||
coefficients: number[],
|
||||
): {
|
||||
carPrice: number;
|
||||
carModel: number;
|
||||
carValue: number[];
|
||||
sumOfSeverity: number;
|
||||
coefficientYear: number;
|
||||
total: number;
|
||||
} {
|
||||
const normalizePrice = parsePriceDropNumber(carPrice);
|
||||
const coefficient = PRICE_DROP_YEAR_COEFFICIENT[carModelYear] ?? 1;
|
||||
const carValue = coefficients.filter((n) => Number.isFinite(n));
|
||||
const sumOfSeverity = carValue.reduce((a, c) => a + c, 0);
|
||||
const total = (normalizePrice * coefficient * sumOfSeverity) / 400;
|
||||
|
||||
return {
|
||||
carPrice: normalizePrice,
|
||||
carModel: carModelYear,
|
||||
carValue,
|
||||
sumOfSeverity,
|
||||
coefficientYear: coefficient,
|
||||
total: total || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPriceDropCatalogForApi(): Array<{
|
||||
key: string;
|
||||
label_fa: string;
|
||||
severities: Record<PriceDropSeverity, number | undefined>;
|
||||
}> {
|
||||
return Object.keys(PRICE_DROP_PART_TABLE).map((key) => {
|
||||
const row = PRICE_DROP_PART_TABLE[key];
|
||||
return {
|
||||
key,
|
||||
label_fa: PRICE_DROP_PART_LABEL_FA[key] ?? key,
|
||||
severities: {
|
||||
Minor: resolveSeverityCoefficient(key, "Minor"),
|
||||
Moderate: resolveSeverityCoefficient(key, "Moderate"),
|
||||
Severe: resolveSeverityCoefficient(key, "Severe"),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePriceDropPartKeyFromDamagePart(part: {
|
||||
name?: string;
|
||||
catalogKey?: string;
|
||||
}): string | null {
|
||||
const candidates: string[] = [];
|
||||
if (part.catalogKey) {
|
||||
for (const seg of String(part.catalogKey).split("_")) {
|
||||
if (seg) candidates.push(seg);
|
||||
}
|
||||
}
|
||||
if (part.name) candidates.push(String(part.name));
|
||||
|
||||
for (const raw of candidates) {
|
||||
const norm = normalizePriceDropKey(raw);
|
||||
const hit = NORM_TO_PART_KEY.get(norm);
|
||||
if (hit && PRICE_DROP_PART_TABLE[hit]) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildDamagedPartsPriceDropLines(
|
||||
selectedParts: DamageSelectedPartV2[],
|
||||
): Array<{
|
||||
partId: number | null;
|
||||
name: string;
|
||||
side: string;
|
||||
label_fa: string;
|
||||
priceDropPartKey: string | null;
|
||||
mappable: boolean;
|
||||
}> {
|
||||
return selectedParts.map((sp) => {
|
||||
const priceDropPartKey = resolvePriceDropPartKeyFromDamagePart(sp);
|
||||
return {
|
||||
partId: catalogPartIdFromSelectedPart(sp),
|
||||
name: sp.name,
|
||||
side: sp.side,
|
||||
label_fa: sp.label_fa,
|
||||
priceDropPartKey,
|
||||
mappable: priceDropPartKey != null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve Jalali year from linked blame party inquiry (owner party preferred). */
|
||||
export function resolveVehicleModelYearFromBlame(
|
||||
claim: { owner?: { userId?: unknown } },
|
||||
blame: {
|
||||
parties?: Array<{
|
||||
role?: string;
|
||||
person?: { userId?: unknown };
|
||||
vehicle?: {
|
||||
inquiry?: {
|
||||
mapped?: { ModelField?: unknown; ModelCii?: unknown };
|
||||
ModelField?: unknown;
|
||||
ModelCii?: unknown;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
} | null,
|
||||
): number | null {
|
||||
if (!blame?.parties?.length) return null;
|
||||
const ownerId =
|
||||
claim.owner?.userId != null ? String(claim.owner.userId) : "";
|
||||
let party =
|
||||
blame.parties.find(
|
||||
(p) =>
|
||||
ownerId &&
|
||||
p.person?.userId != null &&
|
||||
String(p.person.userId) === ownerId,
|
||||
) ?? blame.parties.find((p) => p.role === "FIRST");
|
||||
if (!party) party = blame.parties[0];
|
||||
|
||||
const inquiry = party?.vehicle?.inquiry;
|
||||
const mapped = inquiry?.mapped ?? inquiry;
|
||||
const raw =
|
||||
(mapped as { ModelField?: unknown })?.ModelField ??
|
||||
(mapped as { ModelCii?: unknown })?.ModelCii;
|
||||
if (raw == null || raw === "") return null;
|
||||
const n =
|
||||
typeof raw === "number" ? raw : parseInt(String(raw).trim(), 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export interface PriceDropPartSeverityInput {
|
||||
partId: number | string;
|
||||
severity: PriceDropSeverity;
|
||||
}
|
||||
|
||||
export function buildCoefficientsFromPartSeverities(
|
||||
selectedParts: DamageSelectedPartV2[],
|
||||
partSeverities: PriceDropPartSeverityInput[],
|
||||
carType?: ClaimVehicleTypeV2,
|
||||
): {
|
||||
coefficients: number[];
|
||||
lines: Array<{
|
||||
partId: number;
|
||||
priceDropPartKey: string;
|
||||
label_fa: string;
|
||||
severity: PriceDropSeverity;
|
||||
coefficient: number;
|
||||
}>;
|
||||
errors: string[];
|
||||
} {
|
||||
const coefficients: number[] = [];
|
||||
const lines: Array<{
|
||||
partId: number;
|
||||
priceDropPartKey: string;
|
||||
label_fa: string;
|
||||
severity: PriceDropSeverity;
|
||||
coefficient: number;
|
||||
}> = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const row of partSeverities) {
|
||||
const catalogId = parseCatalogPartIdInput(row.partId);
|
||||
if (catalogId == null) {
|
||||
errors.push(`Unknown partId "${row.partId}".`);
|
||||
continue;
|
||||
}
|
||||
const sp = resolvePartForExpertReply(
|
||||
catalogId,
|
||||
selectedParts,
|
||||
carType,
|
||||
);
|
||||
if (!sp) {
|
||||
errors.push(`Unknown partId "${row.partId}".`);
|
||||
continue;
|
||||
}
|
||||
const partKey = resolvePriceDropPartKeyFromDamagePart(sp);
|
||||
if (!partKey) {
|
||||
errors.push(
|
||||
`Part "${row.partId}" (${sp.label_fa}) has no price-drop mapping.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const coef = resolveSeverityCoefficient(partKey, row.severity);
|
||||
if (coef === undefined) {
|
||||
errors.push(
|
||||
`Invalid severity "${row.severity}" for part key "${partKey}".`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
coefficients.push(coef);
|
||||
const linePartId = catalogPartIdFromSelectedPart(sp);
|
||||
if (linePartId == null) {
|
||||
errors.push(`Part "${row.partId}" has no catalog id.`);
|
||||
continue;
|
||||
}
|
||||
lines.push({
|
||||
partId: linePartId,
|
||||
priceDropPartKey: partKey,
|
||||
label_fa: sp.label_fa,
|
||||
severity: row.severity,
|
||||
coefficient: coef,
|
||||
});
|
||||
}
|
||||
|
||||
return { coefficients, lines, errors };
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/clai
|
||||
|
||||
export interface ClaimPricingPartLite {
|
||||
factorNeeded?: boolean;
|
||||
partId?: string;
|
||||
/** Numeric catalog id (legacy string values may still exist in old documents). */
|
||||
partId?: number | string;
|
||||
}
|
||||
|
||||
export function getActiveV2ExpertReply(claim: { evaluation?: Record<string, unknown> }): {
|
||||
|
||||
73
src/helpers/expert-portfolio.spec.ts
Normal file
73
src/helpers/expert-portfolio.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
ExpertFileActivityType,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import { expertPortfolioFileIdsFromActivityEvents } from "./expert-portfolio";
|
||||
|
||||
describe("expertPortfolioFileIdsFromActivityEvents", () => {
|
||||
const fid = new Types.ObjectId();
|
||||
|
||||
it("includes handled files after HANDLED", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
occurredAt: new Date("2026-01-01T11:00:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(true);
|
||||
});
|
||||
|
||||
it("includes currently checked files", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps handled files after a later UNCHECKED (e.g. resend unlock)", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
occurredAt: new Date("2026-01-01T10:30:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
occurredAt: new Date("2026-01-01T11:00:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes files after UNCHECKED without HANDLED", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:30:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(false);
|
||||
});
|
||||
});
|
||||
113
src/helpers/expert-portfolio.ts
Normal file
113
src/helpers/expert-portfolio.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Types } from "mongoose";
|
||||
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import {
|
||||
blameCaseAccessibleToExpert,
|
||||
claimCaseTouchesClient,
|
||||
} from "src/helpers/tenant-scope";
|
||||
|
||||
export interface ExpertFileActivityEventRow {
|
||||
fileId: Types.ObjectId | string;
|
||||
eventType: ExpertFileActivityType;
|
||||
occurredAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct file ids where the expert is actively checking (CHECKED, not UNCHECKED)
|
||||
* or has handled the file (HANDLED), replayed in chronological order.
|
||||
*/
|
||||
export function expertPortfolioFileIdsFromActivityEvents(
|
||||
events: ExpertFileActivityEventRow[],
|
||||
): Set<string> {
|
||||
const sorted = [...events].sort(
|
||||
(a, b) =>
|
||||
a.occurredAt.getTime() - b.occurredAt.getTime() ||
|
||||
String(a.fileId).localeCompare(String(b.fileId)),
|
||||
);
|
||||
const stateByFile = new Map<string, { checked: boolean; handled: boolean }>();
|
||||
|
||||
for (const e of sorted) {
|
||||
const fid = String(e.fileId);
|
||||
const prev = stateByFile.get(fid) ?? { checked: false, handled: false };
|
||||
if (e.eventType === ExpertFileActivityType.CHECKED) {
|
||||
if (!prev.handled) prev.checked = true;
|
||||
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
|
||||
prev.checked = false;
|
||||
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
|
||||
prev.handled = true;
|
||||
prev.checked = false;
|
||||
}
|
||||
stateByFile.set(fid, prev);
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (const [fid, st] of stateByFile) {
|
||||
if (st.handled || st.checked) ids.add(fid);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function objectIdsFromStringSet(ids: Set<string>): Types.ObjectId[] {
|
||||
return [...ids]
|
||||
.filter((id) => Types.ObjectId.isValid(id))
|
||||
.map((id) => new Types.ObjectId(id));
|
||||
}
|
||||
|
||||
/** Damage-expert claim portfolio: assigned (lock/check) or substantive work on the file. */
|
||||
export function claimDocInExpertPortfolio(
|
||||
doc: Record<string, unknown>,
|
||||
expertId: string,
|
||||
activityFileIds: Set<string>,
|
||||
clientKey: string,
|
||||
): boolean {
|
||||
if (!claimCaseTouchesClient(doc, clientKey)) return false;
|
||||
const id = String(doc._id ?? "");
|
||||
if (activityFileIds.has(id)) return true;
|
||||
|
||||
const lockedBy = (doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined)
|
||||
?.lockedBy?.actorId;
|
||||
if (lockedBy != null && String(lockedBy) === String(expertId)) return true;
|
||||
|
||||
const evaluation = doc.evaluation as Record<string, unknown> | undefined;
|
||||
for (const key of ["damageExpertReply", "damageExpertReplyFinal"] as const) {
|
||||
const reply = evaluation?.[key] as
|
||||
| { actorDetail?: { actorId?: unknown } }
|
||||
| undefined;
|
||||
const actorId = reply?.actorDetail?.actorId;
|
||||
if (actorId != null && String(actorId) === String(expertId)) return true;
|
||||
}
|
||||
const resendBy = (
|
||||
evaluation?.damageExpertResend as { requestedByExpertId?: unknown } | undefined
|
||||
)?.requestedByExpertId;
|
||||
if (resendBy != null && String(resendBy) === String(expertId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Field-expert blame portfolio: initiated, decided, locked, or file-activity touch. */
|
||||
export function blameDocInExpertPortfolio(
|
||||
doc: Record<string, unknown>,
|
||||
actor: { sub: string; clientKey?: string },
|
||||
activityFileIds: Set<string>,
|
||||
): boolean {
|
||||
if (!blameCaseAccessibleToExpert(doc, actor)) return false;
|
||||
const expertId = String(actor.sub);
|
||||
const id = String(doc._id ?? "");
|
||||
if (activityFileIds.has(id)) return true;
|
||||
|
||||
if (doc.expertInitiated && doc.initiatedByFieldExpertId) {
|
||||
return String(doc.initiatedByFieldExpertId) === expertId;
|
||||
}
|
||||
|
||||
const expert = doc.expert as Record<string, unknown> | undefined;
|
||||
const decidedBy = (expert?.decision as { decidedByExpertId?: unknown } | undefined)
|
||||
?.decidedByExpertId;
|
||||
if (decidedBy != null && String(decidedBy) === expertId) return true;
|
||||
|
||||
const lockedBy =
|
||||
(expert?.resend as { requestedByExpertId?: unknown } | undefined)
|
||||
?.requestedByExpertId ??
|
||||
(doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined)?.lockedBy
|
||||
?.actorId;
|
||||
if (lockedBy != null && String(lockedBy) === expertId) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
26
src/helpers/iran-datetime.spec.ts
Normal file
26
src/helpers/iran-datetime.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { parseIranLocalDateTime } from "./iran-datetime";
|
||||
|
||||
describe("parseIranLocalDateTime", () => {
|
||||
it("parses YYYY-MM-DD + HH:MM as Iran local (not server local)", () => {
|
||||
const instant = parseIranLocalDateTime("2025-05-16", "18:28");
|
||||
expect(instant).not.toBeNull();
|
||||
// 18:28 Iran = 14:58 UTC
|
||||
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
||||
});
|
||||
|
||||
it("parses naive ISO datetime as Iran local", () => {
|
||||
const instant = parseIranLocalDateTime("2025-05-16T18:28");
|
||||
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
||||
});
|
||||
|
||||
it("respects explicit UTC offset in string", () => {
|
||||
const instant = parseIranLocalDateTime("2025-05-16T14:58:00.000Z");
|
||||
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
||||
});
|
||||
|
||||
it("combines Date calendar day in Iran with accidentTime", () => {
|
||||
const dateOnly = new Date("2025-05-16T00:00:00.000Z");
|
||||
const instant = parseIranLocalDateTime(dateOnly, "18:28");
|
||||
expect(instant!.toISOString()).toBe("2025-05-16T14:58:00.000Z");
|
||||
});
|
||||
});
|
||||
82
src/helpers/iran-datetime.ts
Normal file
82
src/helpers/iran-datetime.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/** Iran standard time (Asia/Tehran, UTC+3:30; no DST since 2022). */
|
||||
export const IRAN_TIMEZONE = "Asia/Tehran";
|
||||
|
||||
const IRAN_UTC_OFFSET = "+03:30";
|
||||
|
||||
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const TIME_HM_RE = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
|
||||
const NAIVE_ISO_DATETIME_RE =
|
||||
/^(\d{4}-\d{2}-\d{2})[T\s](\d{1,2}:\d{2}(?::\d{2})?)$/;
|
||||
|
||||
/**
|
||||
* Gregorian calendar date (`YYYY-MM-DD`) for an instant in Iran.
|
||||
*/
|
||||
export function gregorianDateInIran(d: Date): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: IRAN_TIMEZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function normalizeTimeToHms(time: string): string | null {
|
||||
const m = String(time).trim().match(TIME_HM_RE);
|
||||
if (!m) return null;
|
||||
const hh = m[1].padStart(2, "0");
|
||||
const mm = m[2];
|
||||
const ss = m[3] ?? "00";
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function parseIranLocalIso(isoDate: string, timeHms: string): Date | null {
|
||||
const d = new Date(`${isoDate}T${timeHms}${IRAN_UTC_OFFSET}`);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines a calendar date and optional `HH:MM` / `HH:MM:SS` as Iran local
|
||||
* time and returns the corresponding UTC instant.
|
||||
*/
|
||||
export function parseIranLocalDateTime(
|
||||
date: Date | string,
|
||||
time?: string,
|
||||
): Date | null {
|
||||
if (date instanceof Date) {
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const cleanTime = (time ?? "").trim();
|
||||
if (!cleanTime) return date;
|
||||
const timeHms = normalizeTimeToHms(cleanTime);
|
||||
if (!timeHms) return null;
|
||||
return parseIranLocalIso(gregorianDateInIran(date), timeHms);
|
||||
}
|
||||
|
||||
const raw = String(date).trim();
|
||||
if (!raw) return null;
|
||||
|
||||
if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) {
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
const naive = raw.match(NAIVE_ISO_DATETIME_RE);
|
||||
if (naive) {
|
||||
const timeHms = normalizeTimeToHms(naive[2]);
|
||||
if (!timeHms) return null;
|
||||
return parseIranLocalIso(naive[1], timeHms);
|
||||
}
|
||||
|
||||
if (DATE_ONLY_RE.test(raw)) {
|
||||
const cleanTime = (time ?? "").trim();
|
||||
if (!cleanTime) {
|
||||
const d = new Date(`${raw}T00:00:00${IRAN_UTC_OFFSET}`);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
const timeHms = normalizeTimeToHms(cleanTime);
|
||||
if (!timeHms) return null;
|
||||
return parseIranLocalIso(raw, timeHms);
|
||||
}
|
||||
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
51
src/helpers/list-query-v2.spec.ts
Normal file
51
src/helpers/list-query-v2.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { applyListQueryV2 } from "./list-query-v2";
|
||||
|
||||
describe("applyListQueryV2", () => {
|
||||
const rows = [
|
||||
{ publicId: "B100", createdAt: "2026-01-01", status: "OPEN" },
|
||||
{ publicId: "A200", createdAt: "2026-02-01", status: "DONE" },
|
||||
{ publicId: "C050", createdAt: "2026-03-01", status: "OPEN" },
|
||||
];
|
||||
|
||||
const getters = {
|
||||
publicId: (r: (typeof rows)[0]) => r.publicId,
|
||||
createdAt: (r: (typeof rows)[0]) => r.createdAt,
|
||||
status: (r: (typeof rows)[0]) => r.status,
|
||||
};
|
||||
|
||||
it("returns all rows when no pagination query", () => {
|
||||
const r = applyListQueryV2(rows, getters, {});
|
||||
expect(r.total).toBe(3);
|
||||
expect(r.list).toHaveLength(3);
|
||||
expect(r.page).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters by search on publicId", () => {
|
||||
const r = applyListQueryV2(rows, getters, { search: "a200" });
|
||||
expect(r.total).toBe(1);
|
||||
expect(r.list[0].publicId).toBe("A200");
|
||||
});
|
||||
|
||||
it("sorts by publicId ascending", () => {
|
||||
const r = applyListQueryV2(rows, getters, {
|
||||
sortBy: "publicId",
|
||||
sortOrder: "asc",
|
||||
});
|
||||
expect(r.list.map((x) => x.publicId)).toEqual(["A200", "B100", "C050"]);
|
||||
});
|
||||
|
||||
it("paginates when page and limit are set", () => {
|
||||
const r = applyListQueryV2(rows, getters, {
|
||||
sortBy: "publicId",
|
||||
sortOrder: "asc",
|
||||
page: 2,
|
||||
limit: 1,
|
||||
});
|
||||
expect(r.total).toBe(3);
|
||||
expect(r.list).toHaveLength(1);
|
||||
expect(r.list[0].publicId).toBe("B100");
|
||||
expect(r.page).toBe(2);
|
||||
expect(r.limit).toBe(1);
|
||||
expect(r.totalPages).toBe(3);
|
||||
});
|
||||
});
|
||||
116
src/helpers/list-query-v2.ts
Normal file
116
src/helpers/list-query-v2.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
|
||||
const MAX_LIMIT = 100;
|
||||
const DEFAULT_LIMIT_WHEN_PAGE_SET = 20;
|
||||
|
||||
export type ListItemGetters<T> = {
|
||||
publicId: (row: T) => string | undefined;
|
||||
createdAt: (row: T) => Date | string | number | undefined;
|
||||
requestNo?: (row: T) => string | undefined;
|
||||
status: (row: T) => string | undefined;
|
||||
/** Extra strings to match against `search` (e.g. claimRequestId, vehicle). */
|
||||
searchExtras?: (row: T) => string[];
|
||||
};
|
||||
|
||||
function normalizeSearch(raw?: string): string | undefined {
|
||||
const t = raw?.trim().toLowerCase();
|
||||
return t || undefined;
|
||||
}
|
||||
|
||||
function toTime(v: Date | string | number | undefined): number {
|
||||
if (v == null) return 0;
|
||||
const n = v instanceof Date ? v.getTime() : new Date(v as string | number).getTime();
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter, sort, and optionally paginate in-memory list results.
|
||||
* Pagination applies only when `query.page` or `query.limit` is set.
|
||||
*/
|
||||
export function applyListQueryV2<T>(
|
||||
rows: T[],
|
||||
getters: ListItemGetters<T>,
|
||||
query: ListQueryV2Dto,
|
||||
): {
|
||||
list: T[];
|
||||
total: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
totalPages?: number;
|
||||
} {
|
||||
const search = normalizeSearch(query.search);
|
||||
let filtered = rows;
|
||||
if (search) {
|
||||
filtered = rows.filter((row) => {
|
||||
const chunks: string[] = [];
|
||||
const pid = getters.publicId(row);
|
||||
if (pid) chunks.push(pid);
|
||||
const rno = getters.requestNo?.(row);
|
||||
if (rno) chunks.push(rno);
|
||||
const st = getters.status(row);
|
||||
if (st) chunks.push(st);
|
||||
const extras = getters.searchExtras?.(row) ?? [];
|
||||
for (const e of extras) {
|
||||
if (e) chunks.push(e);
|
||||
}
|
||||
const hay = chunks.join(" ").toLowerCase();
|
||||
return hay.includes(search);
|
||||
});
|
||||
}
|
||||
|
||||
const sortBy = query.sortBy ?? "createdAt";
|
||||
let sortOrder = query.sortOrder;
|
||||
if (!sortOrder) {
|
||||
sortOrder =
|
||||
sortBy === "publicId" || sortBy === "requestNo" ? "asc" : "desc";
|
||||
}
|
||||
const dir = sortOrder === "asc" ? 1 : -1;
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case "publicId": {
|
||||
const va = String(getters.publicId(a) ?? "");
|
||||
const vb = String(getters.publicId(b) ?? "");
|
||||
return dir * va.localeCompare(vb, "en", { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
case "requestNo": {
|
||||
const va = String(
|
||||
getters.requestNo?.(a) ?? getters.publicId(a) ?? "",
|
||||
);
|
||||
const vb = String(
|
||||
getters.requestNo?.(b) ?? getters.publicId(b) ?? "",
|
||||
);
|
||||
return dir * va.localeCompare(vb, "en", { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
case "status": {
|
||||
const va = String(getters.status(a) ?? "");
|
||||
const vb = String(getters.status(b) ?? "");
|
||||
return dir * va.localeCompare(vb, "en", { sensitivity: "base" });
|
||||
}
|
||||
case "createdAt":
|
||||
default: {
|
||||
const ta = toTime(getters.createdAt(a));
|
||||
const tb = toTime(getters.createdAt(b));
|
||||
return dir * (ta - tb);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const total = sorted.length;
|
||||
const wantsPage = query.page != null;
|
||||
const wantsLimit = query.limit != null;
|
||||
if (!wantsPage && !wantsLimit) {
|
||||
return { list: sorted, total };
|
||||
}
|
||||
|
||||
const limit = Math.min(
|
||||
MAX_LIMIT,
|
||||
Math.max(1, query.limit ?? (wantsPage ? DEFAULT_LIMIT_WHEN_PAGE_SET : MAX_LIMIT)),
|
||||
);
|
||||
const page = Math.max(1, query.page ?? 1);
|
||||
const skip = (page - 1) * limit;
|
||||
const list = sorted.slice(skip, skip + limit);
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
return { list, total, page, limit, totalPages };
|
||||
}
|
||||
122
src/helpers/outer-damage-parts-resolve.spec.ts
Normal file
122
src/helpers/outer-damage-parts-resolve.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
import { buildCoefficientsFromPartSeverities } from "./claim-price-drop";
|
||||
import {
|
||||
catalogPartIdFromSelectedPart,
|
||||
resolveCatalogPartByPartId,
|
||||
resolvePartForExpertReply,
|
||||
resolveSelectedPartByPartId,
|
||||
sanitizeDamageSelectedPartV2,
|
||||
type DamageSelectedPartV2,
|
||||
} from "./outer-damage-parts";
|
||||
|
||||
describe("resolveSelectedPartByPartId", () => {
|
||||
const selected: DamageSelectedPartV2[] = [
|
||||
{
|
||||
id: 12,
|
||||
name: "backfender",
|
||||
side: "left",
|
||||
label_fa: "گلگیر",
|
||||
catalogKey: "left_backfender",
|
||||
},
|
||||
{
|
||||
id: null,
|
||||
name: "hood",
|
||||
side: "front",
|
||||
label_fa: "کاپوت",
|
||||
},
|
||||
];
|
||||
|
||||
it("resolves by numeric catalog id", () => {
|
||||
expect(resolveSelectedPartByPartId(12, selected)).toBe(selected[0]);
|
||||
});
|
||||
|
||||
it("resolves legacy id:N string input", () => {
|
||||
expect(resolveSelectedPartByPartId("id:12", selected)).toBe(selected[0]);
|
||||
});
|
||||
|
||||
it("resolves by index:N", () => {
|
||||
expect(resolveSelectedPartByPartId("index:1", selected)).toBe(selected[1]);
|
||||
});
|
||||
|
||||
it("exposes catalog id as numeric partId", () => {
|
||||
expect(catalogPartIdFromSelectedPart(selected[0])).toBe(12);
|
||||
});
|
||||
|
||||
it("resolves catalog id from outer catalog when not on claim", () => {
|
||||
const hit = resolveCatalogPartByPartId(201, ClaimVehicleTypeV2.HATCHBACK);
|
||||
expect(hit?.id).toBe(201);
|
||||
expect(hit?.side).toBe("left");
|
||||
expect(hit?.catalogKey).toBe("left_backfender");
|
||||
});
|
||||
|
||||
it("resolvePartForExpertReply uses catalog for new expert line", () => {
|
||||
const hit = resolvePartForExpertReply(
|
||||
201,
|
||||
[],
|
||||
ClaimVehicleTypeV2.HATCHBACK,
|
||||
);
|
||||
expect(hit?.id).toBe(201);
|
||||
});
|
||||
|
||||
it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
|
||||
const fixed = sanitizeDamageSelectedPartV2(
|
||||
{
|
||||
id: 201,
|
||||
name: "گلگیر عقب (چپ)",
|
||||
side: "چپ",
|
||||
label_fa: "",
|
||||
catalogKey: "چپ",
|
||||
},
|
||||
ClaimVehicleTypeV2.HATCHBACK,
|
||||
);
|
||||
expect(fixed.side).toBe("left");
|
||||
expect(fixed.name).toBe("backfender");
|
||||
expect(fixed.catalogKey).toBe("left_backfender");
|
||||
expect(catalogPartIdFromSelectedPart(fixed)).toBe(201);
|
||||
});
|
||||
|
||||
it("internal parts have null catalog partId", () => {
|
||||
const row = sanitizeDamageSelectedPartV2({
|
||||
id: 99,
|
||||
name: "قاب موتور",
|
||||
side: "internal",
|
||||
label_fa: "قاب موتور",
|
||||
});
|
||||
expect(row.id).toBeNull();
|
||||
expect(catalogPartIdFromSelectedPart(row)).toBeNull();
|
||||
});
|
||||
|
||||
it("recovers outer part wrongly stored as internal with Persian label", () => {
|
||||
const fixed = sanitizeDamageSelectedPartV2(
|
||||
{
|
||||
id: null,
|
||||
name: "گلگیر عقب (چپ)",
|
||||
side: "internal",
|
||||
label_fa: "گلگیر عقب (چپ)",
|
||||
},
|
||||
ClaimVehicleTypeV2.HATCHBACK,
|
||||
);
|
||||
expect(fixed.id).toBe(201);
|
||||
expect(fixed.side).toBe("left");
|
||||
expect(fixed.name).toBe("backfender");
|
||||
expect(fixed.catalogKey).toBe("left_backfender");
|
||||
});
|
||||
|
||||
it("price drop resolves catalog id when claim row is corrupt", () => {
|
||||
const corrupt: DamageSelectedPartV2[] = [
|
||||
{
|
||||
id: null,
|
||||
name: "گلگیر عقب (چپ)",
|
||||
side: "internal",
|
||||
label_fa: "گلگیر عقب (چپ)",
|
||||
},
|
||||
];
|
||||
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
|
||||
corrupt,
|
||||
[{ partId: 201, severity: "Minor" }],
|
||||
ClaimVehicleTypeV2.HATCHBACK,
|
||||
);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(coefficients).toEqual([2]);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,100 @@ export type DamageSelectedPartV2 = {
|
||||
|
||||
const SIDE_SET = new Set<string>(Object.values(OuterPartSideV2));
|
||||
|
||||
const SIDE_FA_TO_EN: Record<string, OuterPartSideV2> = {
|
||||
چپ: OuterPartSideV2.LEFT,
|
||||
راست: OuterPartSideV2.RIGHT,
|
||||
جلو: OuterPartSideV2.FRONT,
|
||||
عقب: OuterPartSideV2.BACK,
|
||||
بالا: OuterPartSideV2.TOP,
|
||||
};
|
||||
|
||||
function containsPersianText(s: string): boolean {
|
||||
return /[\u0600-\u06FF]/.test(s);
|
||||
}
|
||||
|
||||
/** e.g. `گلگیر عقب (چپ)` → title + optional side in Farsi. */
|
||||
function parsePersianPartLabel(label: string): {
|
||||
titleFa: string;
|
||||
sideFa?: string;
|
||||
} {
|
||||
const t = String(label ?? "").trim();
|
||||
const m = t.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
|
||||
if (m) return { titleFa: m[1].trim(), sideFa: m[2].trim() };
|
||||
return { titleFa: t };
|
||||
}
|
||||
|
||||
/** Match disambiguated Farsi labels back to a catalog row for this car type. */
|
||||
export function findCatalogItemByPersianLabel(
|
||||
label: string,
|
||||
carType: ClaimVehicleTypeV2,
|
||||
): OuterPartCatalogItem | undefined {
|
||||
const catalog = OUTER_PARTS_BY_CAR_TYPE[carType];
|
||||
if (!catalog?.length) return undefined;
|
||||
const { titleFa, sideFa } = parsePersianPartLabel(label);
|
||||
if (!titleFa) return undefined;
|
||||
const sideEn = sideFa ? SIDE_FA_TO_EN[sideFa] : undefined;
|
||||
const candidates = catalog.filter(
|
||||
(c) => String(c.titleFa).trim() === titleFa,
|
||||
);
|
||||
if (!candidates.length) return undefined;
|
||||
if (sideEn) {
|
||||
return candidates.find((c) => c.side === sideEn) ?? candidates[0];
|
||||
}
|
||||
return candidates.length === 1 ? candidates[0] : undefined;
|
||||
}
|
||||
|
||||
/** Non-catalog lines (expert-added interior / free-text); never use catalog numeric ids. */
|
||||
export function isInternalDamageSide(side: string): boolean {
|
||||
const s = String(side ?? "").trim().toLowerCase();
|
||||
if (!s || s === "internal" || s === "other") return true;
|
||||
return !SIDE_SET.has(s);
|
||||
}
|
||||
|
||||
/** Parse API / DB part id (number, `"201"`, or legacy `"id:201"`) → catalog id. */
|
||||
export function parseCatalogPartIdInput(
|
||||
partId: number | string | null | undefined,
|
||||
): number | null {
|
||||
if (partId == null || partId === "") return null;
|
||||
if (typeof partId === "number" && Number.isFinite(partId)) {
|
||||
return Math.trunc(partId);
|
||||
}
|
||||
const raw = String(partId).trim();
|
||||
const legacy = raw.match(/^id:(\d+)$/i);
|
||||
if (legacy) return Number(legacy[1]);
|
||||
if (/^\d+$/.test(raw)) return Number(raw);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sameCatalogPartId(
|
||||
a: number | string | null | undefined,
|
||||
b: number | string | null | undefined,
|
||||
): boolean {
|
||||
const na = parseCatalogPartIdInput(a);
|
||||
const nb = parseCatalogPartIdInput(b);
|
||||
return na != null && nb != null && na === nb;
|
||||
}
|
||||
|
||||
/** API part id: numeric catalog id only (`null` for internal / non-catalog lines). */
|
||||
export function catalogPartIdFromSelectedPart(
|
||||
sp: DamageSelectedPartV2,
|
||||
): number | null {
|
||||
if (isInternalDamageSide(sp.side)) return null;
|
||||
if (sp.id != null && Number.isFinite(sp.id)) return sp.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Internal dedupe / merge key — not for client `partId`. */
|
||||
export function partLookupKey(p: DamageSelectedPartV2): string {
|
||||
const catalogId = catalogPartIdFromSelectedPart(p);
|
||||
if (catalogId != null) return `catalog:${catalogId}`;
|
||||
if (isInternalDamageSide(p.side)) return `internal:${p.name}`;
|
||||
return `legacy:${p.side}|${p.name}`;
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link partLookupKey} for maps or {@link catalogPartIdFromSelectedPart} for APIs. */
|
||||
export const partIdentityKey = partLookupKey;
|
||||
|
||||
/** Farsi side suffix for disambiguated labels, e.g. `گلگیر عقب (چپ)`. */
|
||||
const SIDE_LABEL_FA: Record<string, string> = {
|
||||
[OuterPartSideV2.LEFT]: "چپ",
|
||||
@@ -290,14 +384,189 @@ export function normalizeDamageSelectedParts(
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
return out.map((p) => sanitizeDamageSelectedPartV2(p, carType));
|
||||
}
|
||||
|
||||
export function partIdentityKey(p: DamageSelectedPartV2): string {
|
||||
if (p.id != null && Number.isFinite(p.id)) return `id:${p.id}`;
|
||||
return `${p.side}|${p.name}`;
|
||||
/**
|
||||
* Fix legacy / mistaken rows (Persian in `side`, label in `name`, bad `catalogKey`).
|
||||
* Re-hydrate from catalog when `id` or valid `catalogKey` + `carType` are known.
|
||||
*/
|
||||
export function sanitizeDamageSelectedPartV2(
|
||||
row: DamageSelectedPartV2,
|
||||
carType?: ClaimVehicleTypeV2,
|
||||
): DamageSelectedPartV2 {
|
||||
let { id, name, side, label_fa, catalogKey } = row;
|
||||
const sideTrim = String(side ?? "").trim();
|
||||
const nameTrim = String(name ?? "").trim();
|
||||
let labelTrim = String(label_fa ?? "").trim();
|
||||
|
||||
if (SIDE_FA_TO_EN[sideTrim]) {
|
||||
side = SIDE_FA_TO_EN[sideTrim];
|
||||
}
|
||||
|
||||
if (
|
||||
containsPersianText(nameTrim) &&
|
||||
!containsPersianText(labelTrim) &&
|
||||
labelTrim &&
|
||||
SIDE_SET.has(labelTrim.toLowerCase())
|
||||
) {
|
||||
const swappedSide = labelTrim.toLowerCase();
|
||||
labelTrim = nameTrim;
|
||||
name = nameTrim;
|
||||
side = swappedSide;
|
||||
} else if (containsPersianText(nameTrim) && !labelTrim) {
|
||||
labelTrim = nameTrim;
|
||||
name = normPartSegment(nameTrim) || nameTrim;
|
||||
}
|
||||
|
||||
if (catalogKey && !String(catalogKey).includes("_")) {
|
||||
if (SIDE_FA_TO_EN[String(catalogKey).trim()]) {
|
||||
catalogKey = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const catalog = carType ? OUTER_PARTS_BY_CAR_TYPE[carType] : undefined;
|
||||
|
||||
if (isInternalDamageSide(side) && catalog?.length) {
|
||||
const persianLabel = labelTrim || nameTrim;
|
||||
if (persianLabel && containsPersianText(persianLabel)) {
|
||||
const recovered = findCatalogItemByPersianLabel(persianLabel, carType!);
|
||||
if (recovered) {
|
||||
return catalogItemToSelectedPart(recovered, catalog);
|
||||
}
|
||||
}
|
||||
if (id != null && Number.isFinite(id)) {
|
||||
const byId = catalog.find((c) => c.id === id);
|
||||
if (byId) return catalogItemToSelectedPart(byId, catalog);
|
||||
}
|
||||
}
|
||||
|
||||
if (isInternalDamageSide(side)) {
|
||||
return {
|
||||
id: null,
|
||||
name: nameTrim || labelTrim || name,
|
||||
side: "internal",
|
||||
label_fa: labelTrim || nameTrim || name,
|
||||
};
|
||||
}
|
||||
|
||||
if (catalog?.length) {
|
||||
if (id != null && Number.isFinite(id)) {
|
||||
const byId = catalog.find((c) => c.id === id);
|
||||
if (byId) return catalogItemToSelectedPart(byId, catalog);
|
||||
}
|
||||
const ck = catalogKey?.trim();
|
||||
if (ck && ck.includes("_")) {
|
||||
const byKey = catalog.find((c) => c.key === ck);
|
||||
if (byKey) return catalogItemToSelectedPart(byKey, catalog);
|
||||
}
|
||||
if (SIDE_SET.has(String(side).toLowerCase()) && nameTrim) {
|
||||
const byComposite = catalog.find(
|
||||
(c) => c.key === catalogLikeKeyFromPart({ side, name: nameTrim }),
|
||||
);
|
||||
if (byComposite) return catalogItemToSelectedPart(byComposite, catalog);
|
||||
}
|
||||
const persianLabel = labelTrim || nameTrim;
|
||||
if (persianLabel && containsPersianText(persianLabel)) {
|
||||
const recovered = findCatalogItemByPersianLabel(persianLabel, carType!);
|
||||
if (recovered) return catalogItemToSelectedPart(recovered, catalog);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: id ?? null,
|
||||
name: nameTrim,
|
||||
side: String(side ?? "").trim(),
|
||||
label_fa: labelTrim || nameTrim,
|
||||
...(catalogKey && String(catalogKey).includes("_")
|
||||
? { catalogKey: String(catalogKey).trim() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a client `partId` (numeric catalog id) to a selected part row.
|
||||
*/
|
||||
export function resolveSelectedPartByPartId(
|
||||
partId: number | string,
|
||||
selected: DamageSelectedPartV2[],
|
||||
): DamageSelectedPartV2 | undefined {
|
||||
if (!selected.length) return undefined;
|
||||
|
||||
const catalogId = parseCatalogPartIdInput(partId);
|
||||
if (catalogId != null) {
|
||||
const hits = selected.filter((p) => p.id === catalogId);
|
||||
if (hits.length === 1) return hits[0];
|
||||
if (hits.length > 1) return hits[0];
|
||||
}
|
||||
|
||||
const raw = String(partId ?? "").trim();
|
||||
const indexColon = raw.match(/^index:(\d+)$/i);
|
||||
if (indexColon) {
|
||||
const i = Number(indexColon[1]);
|
||||
if (Number.isInteger(i) && i >= 0 && i < selected.length) {
|
||||
return selected[i];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Resolve a catalog row by numeric id when not on the claim yet. */
|
||||
export function resolveCatalogPartByPartId(
|
||||
partId: number | string,
|
||||
carType?: ClaimVehicleTypeV2,
|
||||
): DamageSelectedPartV2 | undefined {
|
||||
const catalogId = parseCatalogPartIdInput(partId);
|
||||
if (catalogId == null) return undefined;
|
||||
|
||||
if (carType) {
|
||||
const list = OUTER_PARTS_BY_CAR_TYPE[carType];
|
||||
const item = list?.find((c) => c.id === catalogId);
|
||||
if (item) return catalogItemToSelectedPart(item, list);
|
||||
}
|
||||
const global = CATALOG_ITEM_BY_ID.get(catalogId);
|
||||
if (global) {
|
||||
return catalogItemToSelectedPart(global, outerCatalogListForItem(global));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Resolve expert reply / price-drop line by numeric catalog `partId`. */
|
||||
export function resolvePartForExpertReply(
|
||||
partId: number | string,
|
||||
selected: DamageSelectedPartV2[],
|
||||
carType?: ClaimVehicleTypeV2,
|
||||
): DamageSelectedPartV2 | undefined {
|
||||
const catalogId = parseCatalogPartIdInput(partId);
|
||||
if (catalogId == null) return undefined;
|
||||
|
||||
const fromSelected = resolveSelectedPartByPartId(catalogId, selected);
|
||||
if (fromSelected) return fromSelected;
|
||||
|
||||
return resolveCatalogPartByPartId(catalogId, carType);
|
||||
}
|
||||
|
||||
/** Numeric catalog id from a persisted `carPartDamage` snapshot. */
|
||||
export function catalogPartIdFromCarPartDamage(
|
||||
carPartDamage: unknown,
|
||||
): number | null {
|
||||
if (
|
||||
!carPartDamage ||
|
||||
typeof carPartDamage !== "object" ||
|
||||
Array.isArray(carPartDamage)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const o = carPartDamage as Record<string, unknown>;
|
||||
return parseCatalogPartIdInput(
|
||||
typeof o.id === "number" || typeof o.id === "string" ? o.id : null,
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link catalogPartIdFromCarPartDamage}. */
|
||||
export const partIdentityKeyFromCarPartDamage = catalogPartIdFromCarPartDamage;
|
||||
|
||||
function normPartSegment(s: string): string {
|
||||
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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/request-management/dto/blame-list-user-v2.dto.ts
Normal file
23
src/request-management/dto/blame-list-user-v2.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
/** User / field-expert blame list (v2 GET /v2/blame-request-management). */
|
||||
export class GetUserBlameListV2ResponseDto {
|
||||
@ApiProperty({
|
||||
type: "array",
|
||||
description: "Blame requests for the current user (parties stripped)",
|
||||
items: { type: "object", additionalProperties: true },
|
||||
})
|
||||
list: Record<string, unknown>[];
|
||||
|
||||
@ApiProperty({ description: "Count after `search` filter" })
|
||||
total: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
totalPages?: number;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,10 @@ import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.
|
||||
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
|
||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||
import { parseIranLocalDateTime } from "src/helpers/iran-datetime";
|
||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { GetUserBlameListV2ResponseDto } from "src/request-management/dto/blame-list-user-v2.dto";
|
||||
import { AutoCloseRequestService } from "src/utils/cron/cron.service";
|
||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||
import {
|
||||
@@ -327,6 +331,86 @@ export class RequestManagementService {
|
||||
private readonly userAuthService: UserAuthService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Reject CAR_BODY submissions whose accident is older than the per-client
|
||||
* window (see `ClientService.getCarBodyAccidentMaxAgeDays`). The check is
|
||||
* scoped to CAR_BODY because THIRD_PARTY files do not record accidentDate
|
||||
* during the description step.
|
||||
*
|
||||
* `clientId` is best-effort — pass `party.person.clientId`,
|
||||
* `firstPartyDetails.firstPartyClient.clientId`, the SandHub-resolved
|
||||
* `client._id`, etc., whatever is available at the call site. When the
|
||||
* value is missing or invalid the helper falls back to the system default
|
||||
* window so the gate is still enforced.
|
||||
*
|
||||
* Throws `BadRequestException` when the accident instant cannot be parsed,
|
||||
* is in the future, or is older than the configured window.
|
||||
*/
|
||||
private async assertCarBodyAccidentWithinWindow(params: {
|
||||
clientId?: string | Types.ObjectId | null;
|
||||
accidentDate: Date | string | null | undefined;
|
||||
accidentTime?: string | null;
|
||||
}): Promise<void> {
|
||||
const { clientId, accidentDate, accidentTime } = params;
|
||||
if (accidentDate == null) {
|
||||
throw new BadRequestException({
|
||||
code: "CAR_BODY_ACCIDENT_DATE_REQUIRED",
|
||||
message:
|
||||
"CAR_BODY files require an accident date to enforce the submission window.",
|
||||
});
|
||||
}
|
||||
|
||||
const instant = this.parseAccidentInstant(
|
||||
accidentDate,
|
||||
accidentTime ?? undefined,
|
||||
);
|
||||
if (!instant || Number.isNaN(instant.getTime())) {
|
||||
throw new BadRequestException({
|
||||
code: "CAR_BODY_ACCIDENT_DATE_INVALID",
|
||||
message: `Invalid accidentDate/accidentTime: "${String(accidentDate)}"${
|
||||
accidentTime ? ` "${accidentTime}"` : ""
|
||||
}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - instant.getTime();
|
||||
if (ageMs < 0) {
|
||||
throw new BadRequestException({
|
||||
code: "CAR_BODY_ACCIDENT_DATE_IN_FUTURE",
|
||||
message: "Accident date cannot be in the future.",
|
||||
});
|
||||
}
|
||||
|
||||
const maxAgeDays = await this.clientService.getCarBodyAccidentMaxAgeDays(
|
||||
clientId ?? undefined,
|
||||
);
|
||||
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
||||
if (ageDays > maxAgeDays) {
|
||||
throw new BadRequestException({
|
||||
code: "CAR_BODY_ACCIDENT_TOO_OLD",
|
||||
message:
|
||||
`CAR_BODY files must be submitted within ${maxAgeDays} day(s) of the accident. ` +
|
||||
`This accident occurred about ${Math.floor(ageDays)} day(s) ago.`,
|
||||
maxAgeDays,
|
||||
ageDays: Math.floor(ageDays),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort parser that combines a date input with an optional `HH:MM`
|
||||
* time in Iran (Asia/Tehran, UTC+3:30). Accepts a `Date`, an ISO datetime
|
||||
* string, or an ISO date-only string so DTO payloads (which send
|
||||
* `"YYYY-MM-DD"` + `"HH:MM"` in local Iran time) can be passed through
|
||||
* directly. Returns `null` when the result is not a finite instant.
|
||||
*/
|
||||
private parseAccidentInstant(
|
||||
date: Date | string,
|
||||
time?: string,
|
||||
): Date | null {
|
||||
return parseIranLocalDateTime(date, time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked claim cases: block user on damage flow while blame awaits document resend.
|
||||
*/
|
||||
@@ -1226,6 +1310,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;
|
||||
@@ -4059,7 +4150,10 @@ export class RequestManagementService {
|
||||
}));
|
||||
}
|
||||
|
||||
async getAllBlameRequestsV2(user: any): Promise<any> {
|
||||
async getAllBlameRequestsV2(
|
||||
user: any,
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<GetUserBlameListV2ResponseDto> {
|
||||
try {
|
||||
const userIdFilter =
|
||||
user?.sub && Types.ObjectId.isValid(user.sub)
|
||||
@@ -4070,13 +4164,16 @@ export class RequestManagementService {
|
||||
: null;
|
||||
|
||||
const filters = [userIdFilter, phoneFilter].filter(Boolean);
|
||||
if (filters.length === 0) return [];
|
||||
if (filters.length === 0) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
|
||||
const requests = await this.blameRequestDbService.find(
|
||||
filters.length === 1 ? (filters[0] as any) : ({ $or: filters } as any),
|
||||
{
|
||||
select: "requestNo type status blameStatus createdAt updatedAt parties",
|
||||
}
|
||||
select:
|
||||
"publicId requestNo type status blameStatus createdAt updatedAt parties",
|
||||
},
|
||||
);
|
||||
|
||||
const enriched = requests.map((req: any) => {
|
||||
@@ -4096,7 +4193,33 @@ export class RequestManagementService {
|
||||
};
|
||||
});
|
||||
|
||||
return enriched;
|
||||
const paged = applyListQueryV2(enriched, {
|
||||
publicId: (r) => String((r as { publicId?: string }).publicId ?? ""),
|
||||
createdAt: (r) => (r as { createdAt?: Date }).createdAt,
|
||||
requestNo: (r) => String((r as { requestNo?: string }).requestNo ?? ""),
|
||||
status: (r) => String((r as { status?: string }).status ?? ""),
|
||||
searchExtras: (r) => {
|
||||
const row = r as {
|
||||
blameStatus?: string;
|
||||
type?: string;
|
||||
userSide?: string;
|
||||
};
|
||||
return [
|
||||
row.blameStatus,
|
||||
row.type,
|
||||
row.userSide,
|
||||
String((r as { _id?: unknown })._id ?? ""),
|
||||
].filter(Boolean) as string[];
|
||||
},
|
||||
}, query);
|
||||
|
||||
return {
|
||||
list: paged.list,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error("Error: ", err);
|
||||
throw new InternalServerErrorException("Something Went Wrong");
|
||||
@@ -4506,6 +4629,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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
@@ -28,6 +29,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 {
|
||||
@@ -38,6 +40,8 @@ import {
|
||||
CarBodyFormDto,
|
||||
} from "./dto/create-request-management.dto";
|
||||
import { RequestManagementService } from "./request-management.service";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { GetUserBlameListV2ResponseDto } from "./dto/blame-list-user-v2.dto";
|
||||
|
||||
/**
|
||||
* V2 API surface for the redesigned `BlameRequest` model.
|
||||
@@ -53,6 +57,7 @@ import { RequestManagementService } from "./request-management.service";
|
||||
export class RequestManagementV2Controller {
|
||||
constructor(
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
private readonly mediaPolicyService: MediaPolicyService,
|
||||
) { }
|
||||
|
||||
@Post()
|
||||
@@ -69,10 +74,16 @@ export class RequestManagementV2Controller {
|
||||
|
||||
@Get()
|
||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
|
||||
@ApiOperation({
|
||||
summary: "List my blame requests (V2)",
|
||||
description:
|
||||
"All blame files for the current user. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
||||
})
|
||||
async getAllBlameRequestsV2(
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
return this.requestManagementService.getAllBlameRequestsV2(user);
|
||||
@Query() query: ListQueryV2Dto,
|
||||
): Promise<GetUserBlameListV2ResponseDto> {
|
||||
return this.requestManagementService.getAllBlameRequestsV2(user, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,6 +165,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 +234,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 +351,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 +464,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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
||||
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
||||
import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema";
|
||||
import { SandHubService } from "./sand-hub.service";
|
||||
@@ -8,6 +9,7 @@ import { SandHubService } from "./sand-hub.service";
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
SystemSettingsModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: SandHubModel.name, schema: SandHubSchema },
|
||||
]),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import axios from "axios";
|
||||
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
||||
import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema";
|
||||
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||
import { SandHubDetailDto } from "./dto/sand-hub.dto";
|
||||
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
||||
|
||||
@@ -28,14 +29,15 @@ export class SandHubService {
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly sandHubDbService: SandHubDbService,
|
||||
private readonly systemSettingsService: SystemSettingsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* When false (default), no outbound HTTP to SandHub/Tejarat gateways — all inquiries return mocks.
|
||||
* Set `SANDHUB_USE_REAL_API=true` to restore live calls.
|
||||
* When false, no outbound HTTP to SandHub/Tejarat — inquiries use permissive mocks.
|
||||
* Controlled by `system_settings.externalApis.sandHubUseLiveApi` (PATCH /system-settings).
|
||||
*/
|
||||
private useLiveSandHubApis(): boolean {
|
||||
return process.env.SANDHUB_USE_REAL_API === "true";
|
||||
private async useLiveSandHubApis(): Promise<boolean> {
|
||||
return this.systemSettingsService.isSandHubLiveEnabled();
|
||||
}
|
||||
|
||||
/** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */
|
||||
@@ -117,9 +119,12 @@ export class SandHubService {
|
||||
/**
|
||||
* Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API).
|
||||
*/
|
||||
private buildMockSandHubResponseForUrl(url: string): any {
|
||||
private buildMockSandHubResponseForUrl(url: string, payload?: unknown): any {
|
||||
const u = (url || "").toLowerCase();
|
||||
if (u.includes("personal-inquiry")) {
|
||||
const p = payload as { nationalCode?: string } | undefined;
|
||||
const nin =
|
||||
typeof p?.nationalCode === "string" ? p.nationalCode : "-";
|
||||
return {
|
||||
message: "success",
|
||||
data: {
|
||||
@@ -127,7 +132,7 @@ export class SandHubService {
|
||||
lastName: "خانوادگی",
|
||||
fatherName: "-",
|
||||
birthCertificateNumber: "-",
|
||||
nin: "-",
|
||||
nin,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -165,7 +170,7 @@ export class SandHubService {
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
if (!this.useLiveSandHubApis()) {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
return "mock-sandhub-access-token";
|
||||
}
|
||||
if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
|
||||
@@ -205,7 +210,7 @@ export class SandHubService {
|
||||
}
|
||||
|
||||
private async getTejaratAccessToken(): Promise<string> {
|
||||
if (!this.useLiveSandHubApis()) {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
return "mock-tejarat-access-token";
|
||||
}
|
||||
if (
|
||||
@@ -263,7 +268,7 @@ export class SandHubService {
|
||||
}
|
||||
|
||||
private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) {
|
||||
if (!this.useLiveSandHubApis()) {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
this.logger.log(`[MOCK] Tejarat POST skipped: ${url}`);
|
||||
return this.getDefaultMockPlateInquiryRaw();
|
||||
}
|
||||
@@ -328,10 +333,11 @@ export class SandHubService {
|
||||
};
|
||||
|
||||
const requestUrl = `${baseUrl}/block-inquiry-tejarat`;
|
||||
const raw = this.useLiveSandHubApis()
|
||||
const live = await this.useLiveSandHubApis();
|
||||
const raw = live
|
||||
? await this.makeTejaratRequest(requestUrl, requestPayload)
|
||||
: this.getDefaultMockPlateInquiryRaw();
|
||||
if (!this.useLiveSandHubApis()) {
|
||||
if (!live) {
|
||||
this.logger.debug(
|
||||
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||
);
|
||||
@@ -345,9 +351,9 @@ export class SandHubService {
|
||||
}
|
||||
|
||||
private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) {
|
||||
if (!this.useLiveSandHubApis()) {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
this.logger.log(`[MOCK] SandHub POST skipped: ${url}`);
|
||||
return this.buildMockSandHubResponseForUrl(url);
|
||||
return this.buildMockSandHubResponseForUrl(url, payload);
|
||||
}
|
||||
const token = await this.getAccessToken();
|
||||
const INITIAL_DELAY = 1000;
|
||||
@@ -453,7 +459,7 @@ export class SandHubService {
|
||||
const requestUrl = `${base}/block-inquiry-tejarat`;
|
||||
|
||||
let response: any;
|
||||
if (this.useLiveSandHubApis()) {
|
||||
if (await this.useLiveSandHubApis()) {
|
||||
response = await this.makeSandHubRequest(requestUrl, requestPayload);
|
||||
} else {
|
||||
this.logger.debug(
|
||||
|
||||
@@ -63,6 +63,7 @@ export class SmsGatewayService {
|
||||
|
||||
async verifyLookUp(data: VerifyLookUpMessage) {
|
||||
try {
|
||||
console.log(data)
|
||||
const body = await this.sender.verifyLookup(data);
|
||||
if (!isKavenegarSuccess(body)) {
|
||||
const normalized = normalizeKavenegarBody(body);
|
||||
|
||||
46
src/system-settings/dto/system-settings.dto.ts
Normal file
46
src/system-settings/dto/system-settings.dto.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsOptional, ValidateNested } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
export class ExternalApisSettingsDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"When true, SandHub/Tejarat inquiry HTTP APIs are called. When false, mocks are used and validations pass.",
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
sandHubUseLiveApi?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateSystemSettingsDto {
|
||||
@ApiPropertyOptional({ type: ExternalApisSettingsDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ExternalApisSettingsDto)
|
||||
externalApis?: ExternalApisSettingsDto;
|
||||
}
|
||||
|
||||
export class ExternalApisSettingsViewDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Stored in `system_settings.externalApis.sandHubUseLiveApi` — controls SandHubService",
|
||||
example: false,
|
||||
})
|
||||
sandHubUseLiveApi: boolean;
|
||||
}
|
||||
|
||||
export class SystemSettingsResponseDto {
|
||||
@ApiProperty({ example: "global" })
|
||||
key: string;
|
||||
|
||||
@ApiProperty({ type: ExternalApisSettingsViewDto })
|
||||
externalApis: ExternalApisSettingsViewDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Human-readable mode for operators",
|
||||
example: "mock",
|
||||
enum: ["live", "mock"],
|
||||
})
|
||||
sandHubMode: "live" | "mock";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectModel } from "@nestjs/mongoose";
|
||||
import { Model, UpdateQuery } from "mongoose";
|
||||
import {
|
||||
SYSTEM_SETTINGS_GLOBAL_KEY,
|
||||
SystemSettingsModel,
|
||||
} from "../schema/system-settings.schema";
|
||||
|
||||
@Injectable()
|
||||
export class SystemSettingsDbService {
|
||||
constructor(
|
||||
@InjectModel(SystemSettingsModel.name)
|
||||
private readonly model: Model<SystemSettingsModel>,
|
||||
) {}
|
||||
|
||||
findGlobal() {
|
||||
return this.model
|
||||
.findOne({ key: SYSTEM_SETTINGS_GLOBAL_KEY })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
||||
async upsertGlobal(update: UpdateQuery<SystemSettingsModel>) {
|
||||
return this.model
|
||||
.findOneAndUpdate(
|
||||
{ key: SYSTEM_SETTINGS_GLOBAL_KEY },
|
||||
{
|
||||
$setOnInsert: { key: SYSTEM_SETTINGS_GLOBAL_KEY },
|
||||
...update,
|
||||
},
|
||||
{ new: true, upsert: true, lean: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
|
||||
/** Singleton document key — only `global` is used today. */
|
||||
export const SYSTEM_SETTINGS_GLOBAL_KEY = "global";
|
||||
|
||||
/**
|
||||
* Toggle outbound integrations (SandHub / Tejarat inquiry gateways, etc.).
|
||||
* When `sandHubUseLiveApi` is false, {@link SandHubService} skips HTTP and
|
||||
* returns permissive mocks (including default plate/insurance inquiry data).
|
||||
*/
|
||||
export class ExternalApisSettings {
|
||||
/**
|
||||
* `true` → live SandHub/Tejarat HTTP calls.
|
||||
* `false` → no external requests; mocks pass validation checks.
|
||||
*/
|
||||
@Prop({ type: Boolean, required: false, default: false })
|
||||
sandHubUseLiveApi?: boolean;
|
||||
}
|
||||
|
||||
@Schema({ collection: "system_settings", versionKey: false })
|
||||
export class SystemSettingsModel {
|
||||
@Prop({ required: true, unique: true, default: SYSTEM_SETTINGS_GLOBAL_KEY })
|
||||
key: string;
|
||||
|
||||
@Prop({ type: ExternalApisSettings, required: false, default: {} })
|
||||
externalApis?: ExternalApisSettings;
|
||||
}
|
||||
|
||||
export const SystemSettingsSchema =
|
||||
SchemaFactory.createForClass(SystemSettingsModel);
|
||||
51
src/system-settings/system-settings.controller.ts
Normal file
51
src/system-settings/system-settings.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Get, Patch, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { SettingsJwtGuard } from "src/auth/guards/settings-jwt.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import {
|
||||
SystemSettingsResponseDto,
|
||||
UpdateSystemSettingsDto,
|
||||
} from "./dto/system-settings.dto";
|
||||
import { SystemSettingsService } from "./system-settings.service";
|
||||
|
||||
@ApiTags("system-settings")
|
||||
@ApiBearerAuth()
|
||||
@Controller("system-settings")
|
||||
export class SystemSettingsController {
|
||||
constructor(private readonly systemSettingsService: SystemSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(SettingsJwtGuard, RolesGuard)
|
||||
@Roles(RoleEnum.ADMIN, RoleEnum.COMPANY)
|
||||
@ApiOperation({
|
||||
summary: "Get global system settings (external API toggles)",
|
||||
description:
|
||||
"Shows whether SandHub/Tejarat live HTTP is enabled. When disabled, the API uses mocks so flows continue without external connectivity.",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
|
||||
getSettings(): Promise<SystemSettingsResponseDto> {
|
||||
return this.systemSettingsService.getSettingsView();
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@UseGuards(SettingsJwtGuard, RolesGuard)
|
||||
@Roles(RoleEnum.ADMIN)
|
||||
@ApiOperation({
|
||||
summary: "Update global system settings",
|
||||
description:
|
||||
"Set `externalApis.sandHubUseLiveApi` to true for live inquiries, false for mock/offline mode.",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
|
||||
updateSettings(
|
||||
@Body() body: UpdateSystemSettingsDto,
|
||||
): Promise<SystemSettingsResponseDto> {
|
||||
return this.systemSettingsService.updateSettings(body);
|
||||
}
|
||||
}
|
||||
21
src/system-settings/system-settings.module.ts
Normal file
21
src/system-settings/system-settings.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service";
|
||||
import {
|
||||
SystemSettingsModel,
|
||||
SystemSettingsSchema,
|
||||
} from "./entities/schema/system-settings.schema";
|
||||
import { SystemSettingsController } from "./system-settings.controller";
|
||||
import { SystemSettingsService } from "./system-settings.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: SystemSettingsModel.name, schema: SystemSettingsSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [SystemSettingsController],
|
||||
providers: [SystemSettingsService, SystemSettingsDbService],
|
||||
exports: [SystemSettingsService],
|
||||
})
|
||||
export class SystemSettingsModule {}
|
||||
31
src/system-settings/system-settings.service.spec.ts
Normal file
31
src/system-settings/system-settings.service.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { SystemSettingsService } from "./system-settings.service";
|
||||
|
||||
describe("SystemSettingsService", () => {
|
||||
const db = {
|
||||
findGlobal: jest.fn(),
|
||||
upsertGlobal: jest.fn(),
|
||||
};
|
||||
|
||||
let service: SystemSettingsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new SystemSettingsService(db as any);
|
||||
});
|
||||
|
||||
it("defaults to mock when DB field is false", async () => {
|
||||
db.findGlobal.mockResolvedValue({
|
||||
key: "global",
|
||||
externalApis: { sandHubUseLiveApi: false },
|
||||
});
|
||||
await expect(service.isSandHubLiveEnabled()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("uses live mode when DB field is true", async () => {
|
||||
db.findGlobal.mockResolvedValue({
|
||||
key: "global",
|
||||
externalApis: { sandHubUseLiveApi: true },
|
||||
});
|
||||
await expect(service.isSandHubLiveEnabled()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
79
src/system-settings/system-settings.service.ts
Normal file
79
src/system-settings/system-settings.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
SystemSettingsResponseDto,
|
||||
UpdateSystemSettingsDto,
|
||||
} from "./dto/system-settings.dto";
|
||||
import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service";
|
||||
|
||||
@Injectable()
|
||||
export class SystemSettingsService {
|
||||
private readonly logger = new Logger(SystemSettingsService.name);
|
||||
private cache: Record<string, unknown> | null = null;
|
||||
private cacheAt = 0;
|
||||
private readonly cacheTtlMs = 15_000;
|
||||
|
||||
constructor(private readonly db: SystemSettingsDbService) {}
|
||||
|
||||
private async loadGlobal(): Promise<Record<string, unknown>> {
|
||||
const now = Date.now();
|
||||
if (this.cache && now - this.cacheAt < this.cacheTtlMs) {
|
||||
return this.cache;
|
||||
}
|
||||
let doc = await this.db.findGlobal();
|
||||
if (!doc) {
|
||||
doc = await this.db.upsertGlobal({
|
||||
$set: { externalApis: { sandHubUseLiveApi: false } },
|
||||
});
|
||||
this.logger.log(
|
||||
"Created default system_settings document (sandHubUseLiveApi=false)",
|
||||
);
|
||||
}
|
||||
this.cache = doc;
|
||||
this.cacheAt = now;
|
||||
return doc;
|
||||
}
|
||||
|
||||
private invalidateCache(): void {
|
||||
this.cache = null;
|
||||
this.cacheAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether SandHub/Tejarat live HTTP should run (`system_settings` collection).
|
||||
*/
|
||||
async isSandHubLiveEnabled(): Promise<boolean> {
|
||||
const doc = await this.loadGlobal();
|
||||
const externalApis = doc.externalApis as
|
||||
| { sandHubUseLiveApi?: boolean }
|
||||
| undefined;
|
||||
return externalApis?.sandHubUseLiveApi === true;
|
||||
}
|
||||
|
||||
async getSettingsView(): Promise<SystemSettingsResponseDto> {
|
||||
const doc = await this.loadGlobal();
|
||||
const externalApis = doc.externalApis as
|
||||
| { sandHubUseLiveApi?: boolean }
|
||||
| undefined;
|
||||
const sandHubUseLiveApi = externalApis?.sandHubUseLiveApi === true;
|
||||
return {
|
||||
key: String(doc.key ?? "global"),
|
||||
externalApis: { sandHubUseLiveApi },
|
||||
sandHubMode: sandHubUseLiveApi ? "live" : "mock",
|
||||
};
|
||||
}
|
||||
|
||||
async updateSettings(
|
||||
body: UpdateSystemSettingsDto,
|
||||
): Promise<SystemSettingsResponseDto> {
|
||||
const $set: Record<string, unknown> = {};
|
||||
if (body.externalApis?.sandHubUseLiveApi !== undefined) {
|
||||
$set["externalApis.sandHubUseLiveApi"] = body.externalApis.sandHubUseLiveApi;
|
||||
}
|
||||
if (Object.keys($set).length > 0) {
|
||||
await this.db.upsertGlobal({ $set });
|
||||
this.invalidateCache();
|
||||
this.logger.log(`System settings updated: ${JSON.stringify($set)}`);
|
||||
}
|
||||
return this.getSettingsView();
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,29 @@ export class ExpertFileActivityDbService {
|
||||
.lean();
|
||||
}
|
||||
|
||||
/** Activity rows for one expert and file kind (claim or blame panel). */
|
||||
async findByExpert(
|
||||
expertId: string | Types.ObjectId,
|
||||
fileType: ExpertFileKind,
|
||||
): Promise<
|
||||
Array<{
|
||||
fileId: Types.ObjectId;
|
||||
eventType: ExpertFileActivityType;
|
||||
occurredAt: Date;
|
||||
}>
|
||||
> {
|
||||
return this.activityModel
|
||||
.find(
|
||||
{
|
||||
expertId: new Types.ObjectId(String(expertId)),
|
||||
fileType,
|
||||
},
|
||||
{ fileId: 1, eventType: 1, occurredAt: 1 },
|
||||
)
|
||||
.sort({ occurredAt: 1, _id: 1 })
|
||||
.lean();
|
||||
}
|
||||
|
||||
/** All file-activity rows for an insurer tenant (blame + claim experts). */
|
||||
async findByTenant(tenantId: string | Types.ObjectId): Promise<
|
||||
Array<{
|
||||
|
||||
34
src/utils/unicode-digits.spec.ts
Normal file
34
src/utils/unicode-digits.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
normalizeMoneyAmountString,
|
||||
normalizeUnicodeDigitsDeep,
|
||||
normalizeUnicodeDigitsToEnglish,
|
||||
parseMoneyAmountToman,
|
||||
} from "./unicode-digits";
|
||||
|
||||
describe("unicode-digits", () => {
|
||||
it("converts Persian digits to ASCII", () => {
|
||||
expect(normalizeUnicodeDigitsToEnglish("۱۲۳۴۵۶۷۸۹۰")).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("normalizes nested request bodies", () => {
|
||||
expect(
|
||||
normalizeUnicodeDigitsDeep({
|
||||
partPrice: "۵۰۰۰۰۰۰",
|
||||
nested: [{ total: "۱۰۰" }],
|
||||
}),
|
||||
).toEqual({
|
||||
partPrice: "5000000",
|
||||
nested: [{ total: "100" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips grouping separators for money strings", () => {
|
||||
expect(normalizeMoneyAmountString("۵,۰۰۰,۰۰۰")).toBe("5000000");
|
||||
});
|
||||
|
||||
it("parses integer Toman amount strings", () => {
|
||||
expect(parseMoneyAmountToman("۵۳۰۰۰۰۰")).toBe(5300000);
|
||||
expect(parseMoneyAmountToman("1000")).toBe(1000);
|
||||
expect(parseMoneyAmountToman("12.5")).toBeNull();
|
||||
});
|
||||
});
|
||||
54
src/utils/unicode-digits.ts
Normal file
54
src/utils/unicode-digits.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
function unicodeCharToAsciiDigit(ch: string): string {
|
||||
if (ch >= "0" && ch <= "9") return ch;
|
||||
const cp = ch.codePointAt(0)!;
|
||||
if (cp >= 0x06f0 && cp <= 0x06f9) return String(cp - 0x06f0);
|
||||
if (cp >= 0x0660 && cp <= 0x0669) return String(cp - 0x0660);
|
||||
if (cp >= 0x0966 && cp <= 0x096f) return String(cp - 0x0966);
|
||||
if (cp >= 0xff10 && cp <= 0xff19) return String(cp - 0xff10);
|
||||
const folded = ch.normalize("NFKC");
|
||||
if (folded.length === 1 && folded >= "0" && folded <= "9") return folded;
|
||||
return ch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert decimal digits in any Unicode script (Persian, Arabic-Indic, etc.)
|
||||
* to ASCII `0-9`. Other characters are left unchanged.
|
||||
*/
|
||||
export function normalizeUnicodeDigitsToEnglish(input: string): string {
|
||||
return Array.from(input).map(unicodeCharToAsciiDigit).join("");
|
||||
}
|
||||
|
||||
/** Strip grouping separators after digit normalization (for money fields). */
|
||||
export function normalizeMoneyAmountString(input: string): string {
|
||||
return normalizeUnicodeDigitsToEnglish(input)
|
||||
.trim()
|
||||
.replace(/[,،\u066C\u060C]/g, "");
|
||||
}
|
||||
|
||||
/** Parse a money string to a whole **Toman** amount, or `null` if invalid. */
|
||||
export function parseMoneyAmountToman(value: unknown): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
const n = normalizeMoneyAmountString(String(value));
|
||||
if (!/^\d+$/.test(n)) return null;
|
||||
const num = Number(n);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return num;
|
||||
}
|
||||
|
||||
export function normalizeUnicodeDigitsDeep<T>(value: T): T {
|
||||
if (value == null) return value;
|
||||
if (typeof value === "string") {
|
||||
return normalizeUnicodeDigitsToEnglish(value) as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeUnicodeDigitsDeep(item)) as T;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = normalizeUnicodeDigitsDeep(v);
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user