Compare commits

..

32 Commits

Author SHA1 Message Date
39c4855b95 Merge pull request 'Deactivated Valiation Whitelist for now' (#78) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#78
2026-05-20 15:27:46 +03:30
SepehrYahyaee
35487ad033 Deactivated Valiation Whitelist for now 2026-05-20 15:27:44 +03:30
3dec22595c Merge pull request 'Fix SMS, and added pagination+sorting+searching for GETs' (#77) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#77
2026-05-20 14:57:31 +03:30
SepehrYahyaee
44723259d6 Fix SMS, and added pagination+sorting+searching for GETs 2026-05-20 14:57:10 +03:30
c96e361990 Merge pull request 'Validation for prices and objection' (#76) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#76
2026-05-20 11:09:25 +03:30
SepehrYahyaee
e4dfe7c572 Validation for prices and objection 2026-05-20 11:08:47 +03:30
511d478064 Merge pull request 'YARA-883 + Side ID fixes' (#75) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#75
2026-05-18 17:15:13 +03:30
SepehrYahyaee
cef684e37f YARA-883 + Side ID fixes 2026-05-18 17:14:45 +03:30
c81157c431 Merge pull request 'YARA-850, YARA-885' (#74) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#74
2026-05-18 11:23:11 +03:30
SepehrYahyaee
e1954cdb37 YARA-885 2026-05-18 11:00:53 +03:30
SepehrYahyaee
7ff3e9fd10 YARA-850 2026-05-18 10:06:14 +03:30
ced8586710 Merge pull request 'YARA-908' (#73) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#73
2026-05-18 09:30:09 +03:30
SepehrYahyaee
f0ba8949cb YARA-908 2026-05-18 09:29:41 +03:30
fb224360ab Merge pull request 'Fixed step for car-capture video' (#72) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#72
2026-05-17 15:39:58 +03:30
SepehrYahyaee
600c6bd7ed Fixed step for car-capture video 2026-05-17 15:39:23 +03:30
129be58cc9 Merge pull request 'Toggle External API' (#71) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#71
2026-05-16 16:23:44 +03:30
SepehrYahyaee
094816ce8f Toggle External API 2026-05-16 16:23:01 +03:30
f2848a6179 Merge pull request 'Fix 404 for invalid email of actors, added APIs for client settings' (#70) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#70
2026-05-16 15:47:59 +03:30
SepehrYahyaee
7797a4ddab Fix 404 for invalid email of actors, added APIs for client settings 2026-05-16 15:47:31 +03:30
09eb6cc5c0 Merge pull request 'Fixed 3 upload documents for capture part' (#69) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#69
2026-05-16 11:16:22 +03:30
SepehrYahyaee
7c76149c95 Fixed 3 upload documents for capture part 2026-05-16 11:16:08 +03:30
d562e09aab Merge pull request 'Fixed timing issue with UTC' (#68) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#68
2026-05-16 10:28:48 +03:30
SepehrYahyaee
2c851725a5 Fixed timing issue with UTC 2026-05-16 10:28:32 +03:30
e4d6246103 Merge pull request 'Fixed persian labels for car angles' (#67) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#67
2026-05-13 13:22:06 +03:30
SepehrYahyaee
b9d15d1ff6 Fixed persian labels for car angles 2026-05-13 13:21:53 +03:30
241634b149 Merge pull request 'YARA-898, YARA-899' (#66) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#66
2026-05-13 09:33:29 +03:30
SepehrYahyaee
7ba3b57cee Merge branch 'main' of git.ittalie.com:s.yahyaee/yara724-api 2026-05-13 09:31:27 +03:30
SepehrYahyaee
5b6409fc2e Added linkToken and linkContext to OTP 2026-05-13 09:23:45 +03:30
fd4cd3128f Merge pull request 'changed logs' (#65) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#65
2026-05-12 13:35:41 +03:30
SepehrYahyaee
e26c533a52 Fixed bugs 2026-05-12 11:07:58 +03:30
SepehrYahyaee
f75e6a4453 YARA-898 2026-05-12 11:00:18 +03:30
SepehrYahyaee
e89cf107ff YARA-899 2026-05-12 10:26:04 +03:30
79 changed files with 4903 additions and 390 deletions

View File

@@ -106,6 +106,9 @@
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
}
}
}

View File

@@ -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 {}

View File

@@ -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")

View File

@@ -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);
}

View File

@@ -97,9 +97,64 @@ export class ActorAuthService {
return res;
}
async validateActor(username: string, pass: string, role): Promise<any> {
/** 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 raw as RoleEnum;
}
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) {
if (!user) {
throw new NotFoundException("Actor account not found");
}
if (user.role !== role) {
throw new UnauthorizedException("user not assigned to this role");
}
@@ -107,38 +162,30 @@ export class ActorAuthService {
throw new UnauthorizedException(
"password is incorrect or access Denied",
);
} else {
}
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");
}
return null;
const actor = await this.validateActor(username, password, role);
return this.issueActorTokens(actor);
}
/** @deprecated Prefer {@link loginFromCredentials}. Kept for internal callers. */
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");
if (user?.access_token && user?.sub) {
return user;
}
return this.loginFromCredentials(user as Record<string, unknown>);
}
async registerActors(

View 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));
}

View 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);
}
}

View File

@@ -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(
{

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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(" ") ?? [];

View 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;
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 },

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;
};
}

View File

@@ -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;
}

View File

@@ -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 })

View File

@@ -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[];
}

View File

@@ -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);

View 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);
}
}

View File

@@ -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],
})

View File

@@ -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);
}
}

View 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>;
}

View File

@@ -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();
}
}

View File

@@ -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);

View 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;
}

View File

@@ -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();
}
}

View 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.";
}
}

View 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,
});
};
}

View 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.";
}
}

View 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,
});
};
}

View 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;

View File

@@ -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;
}
}

View File

@@ -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 experts tenant, grouped for dashboard:
* `IN_PROGRESS` = OPEN + WAITING_FOR_SECOND_PARTY; other {@link CaseStatus} keys unchanged.
* V2: Count blame cases in this field experts 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,

View File

@@ -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 experts 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 {

View File

@@ -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({

View File

@@ -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;
}

View 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>>;
}

View File

@@ -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 })

View File

@@ -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** (experts 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;
}

View File

@@ -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 experts 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 experts 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) => ({
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(),
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],

View File

@@ -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 experts 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" +

View 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");
});
});

View 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.";
}
}

View 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);
});
});

View 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 };
}

View File

@@ -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> }): {

View 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);
});
});

View 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;
}

View 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");
});
});

View 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;
}

View 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);
});
});

View 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 };
}

View 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]);
});
});

View File

@@ -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();
}

View 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 {}

View 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)
}`,
);
}
}
}

View 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;
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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 },
]),

View File

@@ -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(

View File

@@ -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);

View 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";
}

View File

@@ -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();
}
}

View File

@@ -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);

View 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);
}
}

View 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 {}

View 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);
});
});

View 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();
}
}

View File

@@ -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<{

View 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();
});
});

View 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;
}