forked from Yara724/api
Compare commits
187 Commits
integrate-
...
39c4855b95
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c4855b95 | |||
|
|
35487ad033 | ||
| 3dec22595c | |||
|
|
44723259d6 | ||
| c96e361990 | |||
|
|
e4dfe7c572 | ||
| 511d478064 | |||
|
|
cef684e37f | ||
| c81157c431 | |||
|
|
e1954cdb37 | ||
|
|
7ff3e9fd10 | ||
| ced8586710 | |||
|
|
f0ba8949cb | ||
| fb224360ab | |||
|
|
600c6bd7ed | ||
| 129be58cc9 | |||
|
|
094816ce8f | ||
| f2848a6179 | |||
|
|
7797a4ddab | ||
| 09eb6cc5c0 | |||
|
|
7c76149c95 | ||
| d562e09aab | |||
|
|
2c851725a5 | ||
| e4d6246103 | |||
|
|
b9d15d1ff6 | ||
| 241634b149 | |||
|
|
7ba3b57cee | ||
|
|
5b6409fc2e | ||
| fd4cd3128f | |||
| 0d0cec4b20 | |||
|
|
e26c533a52 | ||
|
|
f75e6a4453 | ||
|
|
e89cf107ff | ||
| 07c4e5126a | |||
|
|
010846acd9 | ||
|
|
cc926d4668 | ||
| fdb75d52f7 | |||
|
|
186f6c5837 | ||
|
|
3fb90cf1c9 | ||
| 5e5ad0e1b3 | |||
|
|
82bb232d28 | ||
| 45392ad268 | |||
|
|
9c62dc4d3a | ||
| d1bc64bb6d | |||
|
|
be58b7e47e | ||
| 78e86e5745 | |||
|
|
fe163419c0 | ||
|
|
eb648d8a87 | ||
| b856cb59f9 | |||
|
|
a52b7a0a72 | ||
|
|
7998649a89 | ||
|
|
74c91c73b6 | ||
|
|
9e2cec5bc3 | ||
| e95cb4c255 | |||
|
|
c1a54baaf0 | ||
| f8193b6622 | |||
| 3e889307a1 | |||
|
|
972b4b8719 | ||
| 96c21294db | |||
| c579f8fa1d | |||
| c2d59112cf | |||
|
|
e1115b0632 | ||
| 908292b0c3 | |||
| 55ec6f1fd1 | |||
| d33cff8438 | |||
| 0bb13f4596 | |||
| 70306d42e0 | |||
| d9dc4ecdff | |||
| f66fa5d7b4 | |||
| 6429cb0f2b | |||
| 6f120b0066 | |||
| 8419ec06ae | |||
| def4023185 | |||
| 40606fecf1 | |||
| c567f93e85 | |||
| a9846095c1 | |||
| b6f0e3c821 | |||
| bffb8a3b97 | |||
| 715a9f2467 | |||
| 3813c2b3e3 | |||
| ebf8a9a624 | |||
| 993d809de2 | |||
| 80ef885d3b | |||
|
|
3f608f63f1 | ||
| 6019c9e954 | |||
|
|
8ae6f2c91b | ||
|
|
f999313476 | ||
|
|
98f1d2caf5 | ||
|
|
bbd83da2d5 | ||
|
|
9296795166 | ||
|
|
f456443342 | ||
|
|
bcedd8c6f3 | ||
| 8caf13cf18 | |||
|
|
e90f8dc33c | ||
| 4c4b1a1db7 | |||
|
|
c2f996cc28 | ||
| 362e02ddc4 | |||
|
|
4b0e8a3547 | ||
|
|
885678df7d | ||
| 8dffc46357 | |||
|
|
b5b3b722c6 | ||
|
|
4f8cb43883 | ||
|
|
6c2d178686 | ||
| 9854a58282 | |||
|
|
7184142137 | ||
| d45975b6a7 | |||
|
|
05c5b70b4d | ||
|
|
5d2227b00b | ||
| eef305c42e | |||
|
|
9c87927e6c | ||
| 5618d120e3 | |||
|
|
d9b1537ee4 | ||
| f01882dc3a | |||
|
|
8284aba825 | ||
| 3c15901ecc | |||
|
|
1e1edf9136 | ||
| daad9539ea | |||
|
|
194b71cdb2 | ||
|
|
04ba46ed2a | ||
| c6f7edabd2 | |||
| 75b7e5ecad | |||
| a3156881b8 | |||
| 41c44dcf23 | |||
|
|
95bfc095ce | ||
| b63c2155bc | |||
| 9463142ecf | |||
| a71c4ea980 | |||
|
|
b826a133a4 | ||
| b4501c503e | |||
|
|
a0d337aff0 | ||
| 28e2de459a | |||
| af1a07743e | |||
| fca88bc151 | |||
|
|
e264695db3 | ||
| 277f2d4d66 | |||
|
|
514018beb1 | ||
| 374b15933d | |||
| 08a4d754c1 | |||
| c5b2d7b520 | |||
|
|
5a84080bf1 | ||
| 68b3b2000b | |||
|
|
028ca899fe | ||
|
|
0086b8db4d | ||
|
|
7f5b64f2a6 | ||
| 2898897dd9 | |||
| e8a3873851 | |||
| 131df63a3f | |||
| b9eb0ab5bd | |||
| 4bdb9fd469 | |||
| 38f700c3d7 | |||
|
|
1a1d55bc2e | ||
| a2f1d5ea7f | |||
|
|
9a65071276 | ||
| 12d6fa4d73 | |||
|
|
640b240ada | ||
|
|
f8fbbb7ac6 | ||
|
|
0e2789c209 | ||
| 7661862564 | |||
|
|
a2143fe1c5 | ||
|
|
494e3d93ab | ||
| 9afb079786 | |||
| 25958bb966 | |||
|
|
1d51370b3e | ||
| e44721b7be | |||
| 7ef057dc1f | |||
| 1b68a81204 | |||
| f77da50d1f | |||
| 79c2982e19 | |||
| fb4d166c31 | |||
| 9084f8fafb | |||
| b216a363fb | |||
|
|
b2391751f0 | ||
| df6d2bd04d | |||
| f277d87dfa | |||
| 672ec25438 | |||
| aa871c86f6 | |||
| 553a34054c | |||
| 8af152abc0 | |||
| 0446c83b36 | |||
| 255cfd3eb3 | |||
| b1be5b1e09 | |||
| 7d3565ec51 | |||
| fc599acf4a | |||
|
|
b40270f058 | ||
|
|
8d6fa3daac | ||
| 64dfd1ca8a | |||
| 665ed9e70f |
2895
package-lock.json
generated
2895
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
||||
"@nestjs-modules/mailer": "^1.8.1",
|
||||
"@nestjs/axios": "^3.1.3",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/mapped-types": "*",
|
||||
@@ -44,6 +45,7 @@
|
||||
"class-validator": "^0.14.1",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.22.1",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"form-data": "^4.0.2",
|
||||
@@ -104,6 +106,9 @@
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ export enum WorkflowStep {
|
||||
|
||||
CREATED = "CREATED",
|
||||
|
||||
// ---------- CAR_BODY only (no confession; accident type form) ----------
|
||||
CAR_BODY_ACCIDENT_TYPE = "CAR_BODY_ACCIDENT_TYPE",
|
||||
|
||||
// ---------- First party ----------
|
||||
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
|
||||
FIRST_VIDEO = "FIRST_VIDEO",
|
||||
|
||||
@@ -15,5 +15,7 @@ export enum CaseStatus {
|
||||
|
||||
CANCELLED = "CANCELLED",
|
||||
|
||||
AUTO_CLOSED = "AUTO_CLOSED"
|
||||
}
|
||||
AUTO_CLOSED = "AUTO_CLOSED",
|
||||
|
||||
STOPPED = "STOPPED"
|
||||
}
|
||||
92
src/Types&Enums/blame-request-management/resend-item-ui.ts
Normal file
92
src/Types&Enums/blame-request-management/resend-item-ui.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ResendItemType } from "./resendItemType.enum";
|
||||
|
||||
const RESEND_ITEM_VALUES = new Set<string>(Object.values(ResendItemType));
|
||||
|
||||
/**
|
||||
* Map multipart / client field names and DB typos to canonical {@link ResendItemType} values.
|
||||
*/
|
||||
export function normalizeResendRequestedItemKey(raw: string): string | null {
|
||||
const t = String(raw ?? "").trim();
|
||||
if (!t) return null;
|
||||
if (RESEND_ITEM_VALUES.has(t)) return t;
|
||||
const lower = t.toLowerCase();
|
||||
for (const v of RESEND_ITEM_VALUES) {
|
||||
if (v.toLowerCase() === lower) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Deduplicated list of valid requested item keys. */
|
||||
export function normalizeResendRequestedItemsList(
|
||||
items: string[] | undefined | null,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of items || []) {
|
||||
const c = normalizeResendRequestedItemKey(String(raw));
|
||||
if (c && !seen.has(c)) {
|
||||
seen.add(c);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone `uploadedDocuments` from a Mongoose subdoc (plain object or Map) into a plain object
|
||||
* so merges and {@link isResendPartyItemSatisfied} see existing keys.
|
||||
*/
|
||||
export function cloneResendUploadedDocuments(
|
||||
raw: unknown,
|
||||
): Record<string, unknown> {
|
||||
if (raw == null || typeof raw !== "object") return {};
|
||||
if (raw instanceof Map) {
|
||||
const o: Record<string, unknown> = {};
|
||||
for (const [k, v] of raw.entries()) {
|
||||
o[String(k)] = v;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
return { ...(raw as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
/** How the mobile/web client should collect each resend item (no workflow-step manager). */
|
||||
export type ResendItemInputKind = "document_camera" | "voice" | "video" | "text";
|
||||
|
||||
export function getResendItemInputKind(item: string): ResendItemInputKind {
|
||||
if (item === ResendItemType.VOICE) return "voice";
|
||||
if (item === ResendItemType.VIDEO) return "video";
|
||||
if (item === ResendItemType.DESCRIPTION) return "text";
|
||||
return "document_camera";
|
||||
}
|
||||
|
||||
export function buildResendItemsWithUi(requestedItems: string[]) {
|
||||
const normalized = normalizeResendRequestedItemsList(requestedItems);
|
||||
return normalized.map((item) => ({
|
||||
item,
|
||||
inputKind: getResendItemInputKind(item),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Whether a single requested item is satisfied on a party's resend row (after merges). */
|
||||
export function isResendPartyItemSatisfied(
|
||||
item: string,
|
||||
row: {
|
||||
uploadedDocuments?: Record<string, unknown>;
|
||||
resendVoiceId?: unknown;
|
||||
resendVideoId?: unknown;
|
||||
userTextDescription?: string;
|
||||
},
|
||||
): boolean {
|
||||
const uploaded = row.uploadedDocuments || {};
|
||||
if (item === ResendItemType.DESCRIPTION) {
|
||||
return !!(row.userTextDescription && String(row.userTextDescription).trim());
|
||||
}
|
||||
if (item === ResendItemType.VOICE) {
|
||||
return !!row.resendVoiceId;
|
||||
}
|
||||
if (item === ResendItemType.VIDEO) {
|
||||
return !!row.resendVideoId;
|
||||
}
|
||||
return !!uploaded[item];
|
||||
}
|
||||
@@ -5,8 +5,12 @@ export enum ResendItemType {
|
||||
CAR_CERTIFICATE = "carCertificate",
|
||||
DRIVING_LICENSE = "drivingLicense",
|
||||
CAR_GREEN_CARD = "carGreenCard",
|
||||
|
||||
PLATE = "plate",
|
||||
CAR_PLATE = "carPlate",
|
||||
// Media evidence
|
||||
VOICE = "voice",
|
||||
VIDEO = "video",
|
||||
|
||||
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
|
||||
DESCRIPTION = "description",
|
||||
}
|
||||
@@ -11,11 +11,30 @@ export enum ClaimCaseStatus {
|
||||
// User flow - damage capture
|
||||
CAPTURING_PART_DAMAGES = "CAPTURING_PART_DAMAGES",
|
||||
|
||||
/** Damage expert asked for more documents and/or part photos; owner must upload before the case returns to the expert queue. */
|
||||
WAITING_FOR_USER_RESEND = "WAITING_FOR_USER_RESEND",
|
||||
|
||||
// Expert flow
|
||||
WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT",
|
||||
EXPERT_REVIEWING = "EXPERT_REVIEWING",
|
||||
/**
|
||||
* @deprecated Prefer specific post-expert statuses below. Kept for existing DB rows and reads.
|
||||
* Historically used for all owner/insurer steps after expert pricing.
|
||||
*/
|
||||
WAITING_FOR_INSURER_APPROVAL = "WAITING_FOR_INSURER_APPROVAL",
|
||||
|
||||
/** Expert reply has only priced lines (`factorNeeded=false` everywhere). Owner final accept/reject at `INSURER_REVIEW`. Also set after expert finishes repair-factor validation when the case returns to final owner sign-off. */
|
||||
INSURER_REVIEW_AWAITING_OWNER_SIGN = "INSURER_REVIEW_AWAITING_OWNER_SIGN",
|
||||
|
||||
/** Expert reply mixes priced lines and factor-needed lines: owner signs priced lines, then uploads factors (status stays through both sub-steps). */
|
||||
INSURER_REVIEW_MIXED_FACTORS_PENDING = "INSURER_REVIEW_MIXED_FACTORS_PENDING",
|
||||
|
||||
/** Expert reply requires a repair-factor file for every line before cost validation. */
|
||||
OWNER_REPAIR_FACTOR_UPLOAD_PENDING = "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
|
||||
|
||||
/** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */
|
||||
EXPERT_VALIDATING_REPAIR_FACTORS = "EXPERT_VALIDATING_REPAIR_FACTORS",
|
||||
|
||||
// Final states
|
||||
COMPLETED = "COMPLETED",
|
||||
CANCELLED = "CANCELLED",
|
||||
|
||||
@@ -7,19 +7,27 @@ export enum ClaimWorkflowStep {
|
||||
SELECT_OUTER_PARTS = "SELECT_OUTER_PARTS",
|
||||
SELECT_OTHER_PARTS = "SELECT_OTHER_PARTS",
|
||||
|
||||
// User: Documentation phase
|
||||
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
|
||||
|
||||
// User: Damage capture phase (per part)
|
||||
// User: Damage capture phase (angles + per-part photos) — before document upload in v2
|
||||
CAPTURE_PART_DAMAGES = "CAPTURE_PART_DAMAGES",
|
||||
|
||||
// User: Documentation phase — after captures in v2
|
||||
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
|
||||
|
||||
// User submission complete
|
||||
USER_SUBMISSION_COMPLETE = "USER_SUBMISSION_COMPLETE",
|
||||
|
||||
/** Owner fulfilling damage expert resend (extra documents and/or part images). */
|
||||
USER_EXPERT_RESEND = "USER_EXPERT_RESEND",
|
||||
|
||||
// Expert: Damage assessment
|
||||
EXPERT_DAMAGE_ASSESSMENT = "EXPERT_DAMAGE_ASSESSMENT",
|
||||
/** After user objection: damage expert’s last priced reply (stored in evaluation.damageExpertReplyFinal) */
|
||||
EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY",
|
||||
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
|
||||
|
||||
/** Owner must upload repair factor files for factorNeeded lines (before expert COST_EVALUATION). */
|
||||
OWNER_UPLOAD_FACTOR_DOCUMENTS = "OWNER_UPLOAD_FACTOR_DOCUMENTS",
|
||||
|
||||
// Insurer approval
|
||||
INSURER_REVIEW = "INSURER_REVIEW",
|
||||
|
||||
|
||||
@@ -3,4 +3,6 @@ export enum InPersonDocumentsEnum {
|
||||
CarCertificate = "carCertificate",
|
||||
DrivingLicense = "drivingLicense",
|
||||
CarGreenCard = "carGreenCard",
|
||||
Plate = "plate",
|
||||
CarPlate = "carPlate",
|
||||
}
|
||||
@@ -2,6 +2,9 @@ export enum ClaimRequiredDocumentType {
|
||||
// Car green card
|
||||
CAR_GREEN_CARD = "car_green_card",
|
||||
|
||||
/** National ID card (or similar); may be requested on resend even if not in the initial upload set. */
|
||||
NATIONAL_CARD = "national_card",
|
||||
|
||||
// Damaged party documents
|
||||
DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back",
|
||||
DAMAGED_DRIVING_LICENSE_FRONT = "damaged_driving_license_front",
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum RoleEnum {
|
||||
EXPERT = "expert",
|
||||
DAMAGE_EXPERT = "damage_expert",
|
||||
FIELD_EXPERT = "field_expert",
|
||||
REGISTRAR = "registrar",
|
||||
COMPANY = "company",
|
||||
ADMIN = "admin",
|
||||
USER = "user",
|
||||
|
||||
@@ -25,7 +25,7 @@ export class AiService implements OnModuleInit {
|
||||
username: process.env.AI_USERNAME,
|
||||
password: process.env.AI_PASSWORD,
|
||||
},
|
||||
timeout: 30000, // 30 second timeout
|
||||
timeout: 1000, // 30 second timeout
|
||||
};
|
||||
|
||||
private get profileOptions(): AxiosRequestConfig {
|
||||
|
||||
@@ -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,9 +18,11 @@ 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";
|
||||
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
|
||||
import { CronModule } from "./utils/cron/cron.module";
|
||||
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
|
||||
|
||||
@@ -44,6 +48,10 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
||||
authMechanism: "SCRAM-SHA-256",
|
||||
tls: true,
|
||||
tlsAllowInvalidCertificates: true,
|
||||
connectionFactory: (connection) => {
|
||||
applyIranFaTimestampPlugin(connection);
|
||||
return connection;
|
||||
},
|
||||
},
|
||||
),
|
||||
UsersModule,
|
||||
@@ -53,6 +61,7 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
||||
PlatesModule,
|
||||
RequestManagementModule,
|
||||
SandHubModule,
|
||||
SystemSettingsModule,
|
||||
ExpertBlameModule,
|
||||
ClaimRequestManagementModule,
|
||||
ExpertClaimModule,
|
||||
@@ -63,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 {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
ApiBody,
|
||||
ApiAcceptedResponse,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
ApiBearerAuth,
|
||||
@@ -23,13 +24,13 @@ import {
|
||||
import { LoginActorDto } from "src/auth/dto/actor/login.actor.dto";
|
||||
import { ActorEditUserProfileDto } from "src/auth/dto/actor/profile.actor.dto";
|
||||
import { CreateFieldExpertDto } from "src/auth/dto/actor/create-field-expert.actor.dto";
|
||||
import { CreateRegistrarDto } from "src/auth/dto/actor/create-registrar.actor.dto";
|
||||
import {
|
||||
GenuineRegisterDto,
|
||||
InsurerRegisterDto,
|
||||
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";
|
||||
|
||||
@@ -38,13 +39,33 @@ import { CurrentUser } from "src/decorators/user.decorator";
|
||||
export class ActorAuthController {
|
||||
constructor(private readonly actorAuthService: ActorAuthService) {}
|
||||
|
||||
/**
|
||||
* @deprecated Use the unified actor onboarding flow instead. This endpoint
|
||||
* will be removed in a future release.
|
||||
*/
|
||||
@Post("register/genuine")
|
||||
@ApiOperation({
|
||||
deprecated: true,
|
||||
summary: "[DEPRECATED] Genuine actor registration",
|
||||
description:
|
||||
"Deprecated — kept only for legacy clients. Use the unified actor onboarding flow instead. Will be removed.",
|
||||
})
|
||||
@ApiBody({ type: GenuineRegisterDto })
|
||||
async registerGenuine(@Body() body: GenuineRegisterDto) {
|
||||
return await this.actorAuthService.genuineRegister(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the unified actor onboarding flow instead. This endpoint
|
||||
* will be removed in a future release.
|
||||
*/
|
||||
@Post("register/legal")
|
||||
@ApiOperation({
|
||||
deprecated: true,
|
||||
summary: "[DEPRECATED] Legal actor registration",
|
||||
description:
|
||||
"Deprecated — kept only for legacy clients. Use the unified actor onboarding flow instead. Will be removed.",
|
||||
})
|
||||
@ApiBody({ type: LegalRegisterDto })
|
||||
async registerLegal(@Body() body: LegalRegisterDto) {
|
||||
return await this.actorAuthService.legalRegister(body);
|
||||
@@ -64,16 +85,67 @@ export class ActorAuthController {
|
||||
return await this.actorAuthService.createFieldExpertMock(body);
|
||||
}
|
||||
|
||||
/** Mock: create a registrar for testing. Make private later. */
|
||||
@Post("create-registrar")
|
||||
@ApiBody({ type: CreateRegistrarDto })
|
||||
@ApiAcceptedResponse()
|
||||
async createRegistrar(@Body() body: CreateRegistrarDto) {
|
||||
return await this.actorAuthService.createRegistrarMock(body);
|
||||
}
|
||||
|
||||
@UseGuards(LocalActorAuthGuard)
|
||||
@Post("login")
|
||||
@Roles()
|
||||
@ApiOperation({
|
||||
summary: "Actor login (returns access + refresh tokens)",
|
||||
description:
|
||||
"Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `[\"damage_expert\"]` — together with the actor's email/`username` and password. On success the response contains the JWT pair and the resolved profile.",
|
||||
})
|
||||
@ApiBody({
|
||||
type: LoginActorDto,
|
||||
description: "user verify otp -- call this api and get a tokens",
|
||||
description:
|
||||
"Login payload. Pick one of the swagger examples below to see the exact body shape per role.",
|
||||
examples: {
|
||||
company: {
|
||||
summary: "Insurer / company portal",
|
||||
description: "Sample tenant credentials for the insurer (company) panel.",
|
||||
value: {
|
||||
role: "company",
|
||||
username: "saman_insurer@gmail.com",
|
||||
password: "123321",
|
||||
},
|
||||
},
|
||||
expert: {
|
||||
summary: "Blame expert panel",
|
||||
description: "Sample credentials for a blame (`expert`) account.",
|
||||
value: {
|
||||
role: "expert",
|
||||
username: "blame@gmail.com",
|
||||
password: "123321",
|
||||
},
|
||||
},
|
||||
damage_expert: {
|
||||
summary: "Damage expert (claim) panel",
|
||||
description: "Sample credentials for a damage-expert account.",
|
||||
value: {
|
||||
role: "damage_expert",
|
||||
username: "claim@gmail.com",
|
||||
password: "123321",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: "No actor account exists for the given email and role",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: "Wrong password or role mismatch",
|
||||
})
|
||||
@ApiAcceptedResponse()
|
||||
async login(@Body() body, @Req() req, @ClientKey() client) {
|
||||
return await this.actorAuthService.loginActors(req.user);
|
||||
async login(@Req() req: { user: Record<string, unknown> }) {
|
||||
return req.user;
|
||||
}
|
||||
|
||||
@Post("forget-password")
|
||||
|
||||
@@ -26,7 +26,10 @@ export class UserAuthController {
|
||||
})
|
||||
@ApiAcceptedResponse()
|
||||
async sendOtpRq(@Body() body: UserLoginDto) {
|
||||
const res = await this.userAuthService.sendOtpRequest(body.mobile);
|
||||
const res = await this.userAuthService.sendOtpRequest(body.mobile, {
|
||||
linkToken: body.linkToken,
|
||||
linkContext: body.linkContext,
|
||||
});
|
||||
if (res) {
|
||||
throw new HttpException(res, HttpStatus.ACCEPTED);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,10 @@ import { InsurerExpertDbService } from "src/users/entities/db-service/insurer-ex
|
||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
|
||||
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
// import { MailService } from "src/utils/mail/mail.service";
|
||||
import { OtpService } from "src/utils/otp/otp.service";
|
||||
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
|
||||
|
||||
function pick(obj: Record<string, any>, keys: string[]) {
|
||||
const out: Record<string, any> = {};
|
||||
@@ -46,10 +47,11 @@ export class ActorAuthService {
|
||||
private readonly expertDbService: ExpertDbService,
|
||||
private readonly damageExpertDbService: DamageExpertDbService,
|
||||
private readonly fieldExpertDbService: FieldExpertDbService,
|
||||
private readonly registrarDbService: RegistrarDbService,
|
||||
private readonly insurerExpertDbService: InsurerExpertDbService,
|
||||
// private readonly mailService: MailService, // Mailer disabled – not used
|
||||
private readonly clientDbService: ClientDbService,
|
||||
private readonly otpService: OtpService,
|
||||
private readonly otpService: OtpGeneratorService,
|
||||
) {}
|
||||
|
||||
// TODO convrt to class for dynamic controller
|
||||
@@ -79,6 +81,13 @@ export class ActorAuthService {
|
||||
else
|
||||
res = await this.fieldExpertDbService.findOne({ email: username });
|
||||
break;
|
||||
case RoleEnum.REGISTRAR:
|
||||
if (username == null && userId)
|
||||
res = await this.registrarDbService.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else res = await this.registrarDbService.findOne({ email: username });
|
||||
break;
|
||||
case RoleEnum.COMPANY:
|
||||
res = await this.insurerExpertDbService.findOne({ email: username });
|
||||
break;
|
||||
@@ -88,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");
|
||||
}
|
||||
@@ -98,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(
|
||||
@@ -273,6 +329,10 @@ export class ActorAuthService {
|
||||
actor = await this.fieldExpertDbService.findOne(filter);
|
||||
dbServiceToUpdate = this.fieldExpertDbService as any;
|
||||
}
|
||||
if (!actor) {
|
||||
actor = await this.registrarDbService.findOne(filter);
|
||||
dbServiceToUpdate = this.registrarDbService as any;
|
||||
}
|
||||
|
||||
if (!actor) {
|
||||
throw new NotFoundException("actor not found");
|
||||
@@ -309,6 +369,10 @@ export class ActorAuthService {
|
||||
userExist = await this.fieldExpertDbService.findOne({ email });
|
||||
dbServiceToUpdate = this.fieldExpertDbService;
|
||||
}
|
||||
if (!userExist) {
|
||||
userExist = await this.registrarDbService.findOne({ email });
|
||||
dbServiceToUpdate = this.registrarDbService as any;
|
||||
}
|
||||
if (!userExist) throw new NotFoundException("user not found");
|
||||
const decodeOtp = await this.hashService.compare(otp, userExist.otp);
|
||||
if (!decodeOtp) throw new UnauthorizedException("otp invalid");
|
||||
@@ -383,6 +447,7 @@ export class ActorAuthService {
|
||||
"phone",
|
||||
"mobile",
|
||||
],
|
||||
registrar: ["email"],
|
||||
};
|
||||
|
||||
const allowedFields = allowedFieldsByRole[role];
|
||||
@@ -498,4 +563,34 @@ export class ActorAuthService {
|
||||
throw er;
|
||||
}
|
||||
}
|
||||
|
||||
async createRegistrarMock(body: {
|
||||
email: string;
|
||||
password: string;
|
||||
clientId: string;
|
||||
}) {
|
||||
const hashPassword = await this.hashService.hash(body.password);
|
||||
const payload = {
|
||||
email: body.email.toLowerCase().trim(),
|
||||
password: hashPassword,
|
||||
clientKey: new Types.ObjectId(body.clientId),
|
||||
role: RoleEnum.REGISTRAR,
|
||||
};
|
||||
try {
|
||||
const created = await this.registrarDbService.create(payload as any);
|
||||
return {
|
||||
id: (created as any)._id,
|
||||
email: (created as any).email,
|
||||
clientKey: (created as any).clientKey,
|
||||
role: (created as any).role,
|
||||
};
|
||||
} catch (er) {
|
||||
if (er.code === 11000) {
|
||||
throw new BadRequestException(
|
||||
"A registrar with this email already exists.",
|
||||
);
|
||||
}
|
||||
throw er;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
src/auth/auth-services/user-auth-error.ts
Normal file
46
src/auth/auth-services/user-auth-error.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
export enum UserAuthErrorCode {
|
||||
USER_NOT_FOUND = "USER_NOT_FOUND",
|
||||
OTP_REQUIRED = "OTP_REQUIRED",
|
||||
OTP_EXPIRED = "OTP_EXPIRED",
|
||||
OTP_INVALID = "OTP_INVALID",
|
||||
OTP_REQUEST_TOO_SOON = "OTP_REQUEST_TOO_SOON",
|
||||
LINK_NOT_FOUND = "LINK_NOT_FOUND",
|
||||
LINK_MOBILE_MISMATCH = "LINK_MOBILE_MISMATCH",
|
||||
}
|
||||
|
||||
const messages: Record<UserAuthErrorCode, string> = {
|
||||
[UserAuthErrorCode.USER_NOT_FOUND]: "User not found",
|
||||
[UserAuthErrorCode.OTP_REQUIRED]: "Please request an OTP first",
|
||||
[UserAuthErrorCode.OTP_EXPIRED]: "OTP has expired",
|
||||
[UserAuthErrorCode.OTP_INVALID]: "OTP is invalid",
|
||||
[UserAuthErrorCode.OTP_REQUEST_TOO_SOON]:
|
||||
"Wait for expiry time to finish before requesting another OTP",
|
||||
[UserAuthErrorCode.LINK_NOT_FOUND]: "Linked SMS token was not found",
|
||||
[UserAuthErrorCode.LINK_MOBILE_MISMATCH]:
|
||||
"This mobile number is not allowed to use this SMS link",
|
||||
};
|
||||
|
||||
export function userAuthErrorBody(code: UserAuthErrorCode) {
|
||||
return {
|
||||
code,
|
||||
message: messages[code],
|
||||
};
|
||||
}
|
||||
|
||||
export function throwUserAuthError(code: UserAuthErrorCode): never {
|
||||
if (code === UserAuthErrorCode.OTP_REQUEST_TOO_SOON) {
|
||||
throw new BadRequestException(userAuthErrorBody(code));
|
||||
}
|
||||
|
||||
if (code === UserAuthErrorCode.LINK_MOBILE_MISMATCH) {
|
||||
throw new ForbiddenException(userAuthErrorBody(code));
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(userAuthErrorBody(code));
|
||||
}
|
||||
187
src/auth/auth-services/user-link-access.service.ts
Normal file
187
src/auth/auth-services/user-link-access.service.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectModel } from "@nestjs/mongoose";
|
||||
import { Model, Types } from "mongoose";
|
||||
import {
|
||||
UserAuthErrorCode,
|
||||
throwUserAuthError,
|
||||
} from "src/auth/auth-services/user-auth-error";
|
||||
import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema";
|
||||
import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema";
|
||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
||||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||
|
||||
@Injectable()
|
||||
export class UserLinkAccessService {
|
||||
constructor(
|
||||
@InjectModel(RequestManagementModel.name)
|
||||
private readonly requestManagementModel: Model<RequestManagementModel>,
|
||||
@InjectModel(BlameRequest.name)
|
||||
private readonly blameRequestModel: Model<BlameRequest>,
|
||||
@InjectModel(ClaimRequestManagementModel.name)
|
||||
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
|
||||
private readonly userDbService: UserDbService,
|
||||
) {}
|
||||
|
||||
async assertMobileAllowed(params: {
|
||||
mobile: string;
|
||||
linkToken?: string;
|
||||
linkContext?: string;
|
||||
}): Promise<void> {
|
||||
const linkToken = params.linkToken?.trim();
|
||||
if (!linkToken) return;
|
||||
|
||||
const allowedMobiles = await this.resolveAllowedMobiles(
|
||||
linkToken,
|
||||
params.linkContext,
|
||||
);
|
||||
if (allowedMobiles.length === 0) {
|
||||
throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND);
|
||||
}
|
||||
|
||||
const normalizedMobile = this.normalizePhone(params.mobile);
|
||||
if (
|
||||
!normalizedMobile ||
|
||||
!allowedMobiles.some((mobile) => this.normalizePhone(mobile) === normalizedMobile)
|
||||
) {
|
||||
throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveAllowedMobiles(
|
||||
linkToken: string,
|
||||
linkContext?: string,
|
||||
): Promise<string[]> {
|
||||
if (!Types.ObjectId.isValid(linkToken)) return [];
|
||||
|
||||
const id = new Types.ObjectId(linkToken);
|
||||
const context = this.normalizeContext(linkContext);
|
||||
const allowedMobiles = new Set<string>();
|
||||
|
||||
const [legacyRequest, blameRequest, claimRequest] = await Promise.all([
|
||||
this.requestManagementModel.findById(id).lean().exec(),
|
||||
this.blameRequestModel.findById(id).lean().exec(),
|
||||
this.claimRequestManagementModel.findById(id).lean().exec(),
|
||||
]);
|
||||
|
||||
this.addLegacyRequestPhones(allowedMobiles, legacyRequest, context);
|
||||
this.addBlameRequestPhones(allowedMobiles, blameRequest, context);
|
||||
await this.addClaimOwnerPhone(allowedMobiles, claimRequest);
|
||||
|
||||
return Array.from(allowedMobiles);
|
||||
}
|
||||
|
||||
private addLegacyRequestPhones(
|
||||
allowedMobiles: Set<string>,
|
||||
legacyRequest: any,
|
||||
context?: string,
|
||||
) {
|
||||
if (!legacyRequest) return;
|
||||
|
||||
const shouldAddFirst = !context || this.isFirstContext(context);
|
||||
const shouldAddSecond = !context || this.isSecondContext(context);
|
||||
|
||||
if (shouldAddFirst) {
|
||||
this.addPhone(
|
||||
allowedMobiles,
|
||||
legacyRequest.firstPartyDetails?.firstPartyPhoneNumber,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldAddSecond) {
|
||||
this.addPhone(
|
||||
allowedMobiles,
|
||||
legacyRequest.secondPartyDetails?.secondPartyPhoneNumber,
|
||||
);
|
||||
}
|
||||
|
||||
for (const event of legacyRequest.history || []) {
|
||||
const metadata = event?.metadata;
|
||||
if (shouldAddSecond) this.addPhone(allowedMobiles, metadata?.secondPartyPhone);
|
||||
for (const sent of metadata?.sentTo || []) {
|
||||
if (!context || this.matchesRoleContext(context, sent?.role)) {
|
||||
this.addPhone(allowedMobiles, sent?.phoneNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addBlameRequestPhones(
|
||||
allowedMobiles: Set<string>,
|
||||
blameRequest: any,
|
||||
context?: string,
|
||||
) {
|
||||
if (!blameRequest) return;
|
||||
|
||||
for (const party of blameRequest.parties || []) {
|
||||
if (context && !this.matchesRoleContext(context, party?.role)) continue;
|
||||
this.addPhone(allowedMobiles, party?.person?.phoneNumber);
|
||||
}
|
||||
}
|
||||
|
||||
private async addClaimOwnerPhone(
|
||||
allowedMobiles: Set<string>,
|
||||
claimRequest: any,
|
||||
) {
|
||||
if (!claimRequest) return;
|
||||
|
||||
const ownerUserId = claimRequest.owner?.userId || claimRequest.userId;
|
||||
if (!ownerUserId) return;
|
||||
|
||||
const ownerUserIdText = String(ownerUserId);
|
||||
if (claimRequest.blameRequestId) {
|
||||
const blameRequest = await this.blameRequestModel
|
||||
.findById(claimRequest.blameRequestId)
|
||||
.lean()
|
||||
.exec();
|
||||
const ownerParty = (blameRequest?.parties || []).find(
|
||||
(party: any) =>
|
||||
party?.person?.userId && String(party.person.userId) === ownerUserIdText,
|
||||
);
|
||||
this.addPhone(allowedMobiles, ownerParty?.person?.phoneNumber);
|
||||
}
|
||||
|
||||
if (Types.ObjectId.isValid(ownerUserIdText)) {
|
||||
const user = await this.userDbService.findOne({
|
||||
_id: new Types.ObjectId(ownerUserIdText),
|
||||
});
|
||||
this.addPhone(allowedMobiles, user?.mobile);
|
||||
}
|
||||
}
|
||||
|
||||
private addPhone(allowedMobiles: Set<string>, phone?: string) {
|
||||
const normalized = this.normalizePhone(phone);
|
||||
if (normalized) allowedMobiles.add(normalized);
|
||||
}
|
||||
|
||||
private normalizePhone(phone?: string): string | undefined {
|
||||
if (!phone) return undefined;
|
||||
|
||||
const digits = String(phone).replace(/\D/g, "");
|
||||
if (!digits) return undefined;
|
||||
if (digits.startsWith("0098")) return `0${digits.slice(4)}`;
|
||||
if (digits.startsWith("98") && digits.length === 12) {
|
||||
return `0${digits.slice(2)}`;
|
||||
}
|
||||
if (digits.length === 10 && digits.startsWith("9")) return `0${digits}`;
|
||||
return digits;
|
||||
}
|
||||
|
||||
private normalizeContext(linkContext?: string): string | undefined {
|
||||
return linkContext?.trim().toUpperCase();
|
||||
}
|
||||
|
||||
private matchesRoleContext(context: string, role?: string): boolean {
|
||||
if (this.isFirstContext(context)) return role === PartyRole.FIRST;
|
||||
if (this.isSecondContext(context)) return role === PartyRole.SECOND;
|
||||
return true;
|
||||
}
|
||||
|
||||
private isFirstContext(context: string): boolean {
|
||||
return ["FIRST", "USER", "USER1", "FIRST_PARTY"].includes(context);
|
||||
}
|
||||
|
||||
private isSecondContext(context: string): boolean {
|
||||
return ["SECOND", "USER2", "SECOND_PARTY"].includes(context);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
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";
|
||||
import { OtpService } from "src/utils/otp/otp.service";
|
||||
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
|
||||
|
||||
export interface LinkBinding {
|
||||
linkToken?: string;
|
||||
linkContext?: string;
|
||||
}
|
||||
|
||||
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
||||
@Injectable()
|
||||
@@ -24,18 +26,29 @@ export class UserAuthService {
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly userDbService: UserDbService,
|
||||
private readonly hashService: HashService,
|
||||
private readonly otpCreator: OtpService,
|
||||
private readonly smsManagerService: SmsManagerService,
|
||||
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,12 +78,27 @@ 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,
|
||||
});
|
||||
const otp = this.otpCreator.create();
|
||||
const hashOtp = await this.hashService.hash(otp);
|
||||
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2");
|
||||
const expireMinutes =
|
||||
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0
|
||||
? rawExpireMinutes
|
||||
: 2;
|
||||
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
|
||||
if (!userExist) {
|
||||
await this.smsSender(otp, mobile);
|
||||
/// create otp request
|
||||
@@ -90,14 +118,14 @@ export class UserAuthService {
|
||||
city: "",
|
||||
address: "",
|
||||
state: "",
|
||||
otpExpire: new Date(
|
||||
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
|
||||
).getTime(),
|
||||
otpExpire,
|
||||
});
|
||||
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(
|
||||
{
|
||||
@@ -105,9 +133,7 @@ export class UserAuthService {
|
||||
},
|
||||
{
|
||||
otp: hashOtp,
|
||||
otpExpire: new Date(
|
||||
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
|
||||
).getTime(),
|
||||
otpExpire,
|
||||
},
|
||||
);
|
||||
if (updateTokens) return new LoginDtoRs(userExist);
|
||||
@@ -115,25 +141,19 @@ export class UserAuthService {
|
||||
}
|
||||
|
||||
private async smsSender(otp: string, mobile: string) {
|
||||
return this.smsManagerService
|
||||
.verifyLookUp({
|
||||
token: otp,
|
||||
template: process.env.AUTH_SMS_TEMPLATE,
|
||||
receptor: mobile,
|
||||
})
|
||||
.then((smsRes) => {
|
||||
this.logger.log(
|
||||
`${"phone : " + mobile + " " + ", status : " + smsRes["return"].status + ", otp : " + otp} `,
|
||||
);
|
||||
})
|
||||
.catch((er) => {
|
||||
this.logger.error(
|
||||
`${"phone : " + mobile + " " + ", status : " + er["return"].status + ", otp : " + otp} `,
|
||||
const ok = await this.smsOrchestrationService.sendAuthOtp(
|
||||
mobile,
|
||||
otp,
|
||||
process.env.AUTH_SMS_TEMPLATE,
|
||||
);
|
||||
if (!ok) {
|
||||
throw new HttpException(
|
||||
" auth sms send failed",
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"auth sms send failed",
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 +8,24 @@ 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";
|
||||
import { OtpModule } from "src/utils/otp/otp.module";
|
||||
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -20,9 +33,16 @@ import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
HashModule,
|
||||
OtpModule,
|
||||
PassportModule,
|
||||
SmsManagerModule,
|
||||
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,
|
||||
@@ -31,6 +51,7 @@ import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
|
||||
],
|
||||
providers: [
|
||||
UserAuthService,
|
||||
UserLinkAccessService,
|
||||
ActorAuthService,
|
||||
LocalStrategy,
|
||||
LocalActorStrategy,
|
||||
|
||||
18
src/auth/dto/actor/create-registrar.actor.dto.ts
Normal file
18
src/auth/dto/actor/create-registrar.actor.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEmail, IsMongoId, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class CreateRegistrarDto {
|
||||
@ApiProperty({ example: "registrar@sample.com" })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: "securePassword123", minLength: 6 })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: "507f1f77bcf86cd799439011" })
|
||||
@IsMongoId()
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
import { UserModel } from "src/users/entities/schema/user.schema";
|
||||
|
||||
export class UserLoginDto {
|
||||
@ApiProperty({
|
||||
example: "09226187419",
|
||||
type: "string",
|
||||
description: "User login dto",
|
||||
description: "Mobile number (username for OTP login)",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
mobile: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "65f0c7f0c3f8a2a7c8b3d001",
|
||||
type: "string",
|
||||
description: "Raw token from linked SMS URL (?token=...).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
linkToken?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "FIRST",
|
||||
type: "string",
|
||||
description: "Optional route/context hint for linked SMS login.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
linkContext?: string;
|
||||
}
|
||||
|
||||
export class LoginDtoRs extends UserModel {
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class UserVerifyOtp {
|
||||
@ApiProperty({
|
||||
example: "09226187419",
|
||||
type: "string",
|
||||
description: "User login dto",
|
||||
description: "Mobile number (same value sent to send-otp)",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
username: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "258567",
|
||||
type: "string",
|
||||
description: "User login verify dto",
|
||||
description: "OTP code from SMS",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(16)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "65f0c7f0c3f8a2a7c8b3d001",
|
||||
type: "string",
|
||||
description: "Raw token from linked SMS URL (?token=...).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
linkToken?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "FIRST",
|
||||
type: "string",
|
||||
description: "Optional route/context hint for linked SMS login.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
linkContext?: string;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,12 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
const path = request.url;
|
||||
|
||||
if (!token) {
|
||||
if (path === "/actor/login") {
|
||||
const loginData = await this.actorAuthService.loginActors(request.body);
|
||||
if (this.isActorLoginRequest(request)) {
|
||||
const loginData = await this.actorAuthService.loginFromCredentials(
|
||||
request.body ?? {},
|
||||
);
|
||||
request.user = loginData;
|
||||
request.identity = request;
|
||||
request.identity = loginData;
|
||||
return true;
|
||||
} else {
|
||||
throw new UnauthorizedException("Token not found");
|
||||
@@ -44,6 +46,7 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
RoleEnum.DAMAGE_EXPERT,
|
||||
RoleEnum.COMPANY,
|
||||
RoleEnum.FIELD_EXPERT,
|
||||
RoleEnum.REGISTRAR,
|
||||
].includes(payload.role)
|
||||
) {
|
||||
throw new UnauthorizedException("User role is not authorized");
|
||||
@@ -58,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(" ") ?? [];
|
||||
|
||||
@@ -14,21 +14,21 @@ export class GlobalGuard implements CanActivate {
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
});
|
||||
if (payload.role !== RoleEnum.USER) {
|
||||
console.log(
|
||||
"🚀 ~ GlobalGuard ~ canActivate ~ request.user.role:",
|
||||
request.user.role,
|
||||
);
|
||||
|
||||
if (payload.role !== RoleEnum.USER && payload.role !== RoleEnum.FIELD_EXPERT) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
request.user = payload;
|
||||
request.identity = request.user;
|
||||
} catch {
|
||||
|
||||
41
src/auth/guards/settings-jwt.guard.ts
Normal file
41
src/auth/guards/settings-jwt.guard.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Request } from "express";
|
||||
|
||||
/**
|
||||
* Verifies Bearer JWT for platform settings routes. Does not restrict by role;
|
||||
* pair with {@link RolesGuard} on handlers.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SettingsJwtGuard implements CanActivate {
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException("Token not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
});
|
||||
(request as any).user = payload;
|
||||
(request as any).identity = payload;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(" ") ?? [];
|
||||
return type === "Bearer" ? token : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NotAcceptableException,
|
||||
} from "@nestjs/common";
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import {
|
||||
UserAuthErrorCode,
|
||||
throwUserAuthError,
|
||||
} from "src/auth/auth-services/user-auth-error";
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||
|
||||
@Injectable()
|
||||
@@ -13,13 +13,14 @@ export class LocalUserAuthGuard extends AuthGuard("local") {
|
||||
}
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const { username, password } = request.body;
|
||||
let isValidUser = await this.userAuthService.validateUser(
|
||||
const { username, password, linkToken, linkContext } = request.body;
|
||||
const isValidUser = await this.userAuthService.validateUser(
|
||||
username,
|
||||
password,
|
||||
{ linkToken, linkContext },
|
||||
);
|
||||
if (!isValidUser) {
|
||||
throw new NotAcceptableException("otp is wrong");
|
||||
throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
|
||||
}
|
||||
request["user"] = isValidUser;
|
||||
return true;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Strategy } from "passport-local";
|
||||
import {
|
||||
UserAuthErrorCode,
|
||||
throwUserAuthError,
|
||||
} from "src/auth/auth-services/user-auth-error";
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||
|
||||
@Injectable()
|
||||
@@ -12,7 +16,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
async validate(username: string, password: string): Promise<any> {
|
||||
const user = await this.userAuthService.validateUser(username, password);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("user not found please register");
|
||||
throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { diskStorage } from "multer";
|
||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
||||
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
@@ -49,6 +49,7 @@ export class ClaimRequestManagementController {
|
||||
) {}
|
||||
|
||||
@ApiParam({ name: "blameId" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("/:blameId")
|
||||
async createClaimRequest(
|
||||
@Param("blameId") requestId: string,
|
||||
@@ -62,6 +63,7 @@ export class ClaimRequestManagementController {
|
||||
}
|
||||
|
||||
@ApiBody({ type: CarDamagePartDto })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("/car-part-damage/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
async carPartDamage(
|
||||
@@ -76,6 +78,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("/car-other-part")
|
||||
async getCarOtherParts() {
|
||||
const carOtherPart = await readFile(
|
||||
@@ -104,6 +107,7 @@ export class ClaimRequestManagementController {
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("/car-other-part-damage/:claimRequestID")
|
||||
async carOtherPartDamage(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -119,6 +123,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("required-documents-status/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
async getRequiredDocumentsStatus(
|
||||
@@ -129,6 +134,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("car-part-image-required/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
async getImageRequired(@Param("claimRequestID") requestId) {
|
||||
@@ -171,6 +177,7 @@ export class ClaimRequestManagementController {
|
||||
enum: ClaimRequiredDocumentType,
|
||||
description: "Type of required document to upload",
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("upload-required-document/:claimRequestID")
|
||||
async uploadRequiredDocument(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -223,6 +230,7 @@ export class ClaimRequestManagementController {
|
||||
name: "partId",
|
||||
description: "The ID of the specific car part being photographed.",
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("capture-car-part-damage/:claimRequestID/:partId")
|
||||
async captureCarPartDamage(
|
||||
@Param("partId") partId: string,
|
||||
@@ -263,6 +271,7 @@ export class ClaimRequestManagementController {
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("car-capture/:claimRequestID")
|
||||
async captureVideoCapture(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -274,11 +283,13 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("requests/")
|
||||
async getRequest(@CurrentUser() currentUser) {
|
||||
return await this.claimRequestManagementService.myRequests(currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("request/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
myRequests(
|
||||
@@ -288,6 +299,7 @@ export class ClaimRequestManagementController {
|
||||
return this.claimRequestManagementService.requestDetails(requestId, user);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("request/reply/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@UseInterceptors(
|
||||
@@ -326,6 +338,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("request/resend/:claimRequestId/objection")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiConsumes("application/json")
|
||||
@@ -343,6 +356,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("request/resend/:claimRequestId")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@@ -393,6 +407,7 @@ export class ClaimRequestManagementController {
|
||||
* User satisfaction rating for a completed claim file.
|
||||
* Only the damaged user (claim owner) can rate their claim after it is closed.
|
||||
*/
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("request/:claimRequestId/user-rating")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: UserRatingDto })
|
||||
@@ -408,6 +423,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@@ -452,6 +468,7 @@ export class ClaimRequestManagementController {
|
||||
|
||||
@ApiBody({ type: InPersonVisitDto })
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(
|
||||
@Param("id") requestId: string,
|
||||
@@ -466,6 +483,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("branches/:insuranceId")
|
||||
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||
@@ -473,6 +491,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("fanavaran-submit/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
|
||||
@@ -481,6 +500,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("fanavaran-submit/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { ClaimRequestManagementController } from "./claim-request-management.controller";
|
||||
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
|
||||
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
||||
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
||||
@@ -45,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: [
|
||||
@@ -54,6 +56,7 @@ import { JwtModule } from "@nestjs/jwt";
|
||||
AiModule,
|
||||
SandHubModule,
|
||||
ClientModule,
|
||||
MediaPolicyModule,
|
||||
JwtModule.register({}),
|
||||
MongooseModule.forFeature([
|
||||
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||
@@ -85,7 +88,11 @@ import { JwtModule } from "@nestjs/jwt";
|
||||
ClaimRequiredDocumentDbService,
|
||||
ClaimAccessGuard,
|
||||
],
|
||||
controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller],
|
||||
controllers: [
|
||||
ClaimRequestManagementController,
|
||||
ClaimRequestManagementV2Controller,
|
||||
RegistrarClaimV1Controller,
|
||||
],
|
||||
exports: [
|
||||
ClaimRequestManagementService,
|
||||
ClaimRequestManagementDbService,
|
||||
@@ -93,6 +100,7 @@ import { JwtModule } from "@nestjs/jwt";
|
||||
DamageImageDbService,
|
||||
VideoCaptureDbService,
|
||||
ClaimRequiredDocumentDbService,
|
||||
ClaimSignDbService,
|
||||
],
|
||||
})
|
||||
export class ClaimRequestManagementModule {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,56 +2,83 @@ import {
|
||||
Controller,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
BadRequestException,
|
||||
Param,
|
||||
Query,
|
||||
Post,
|
||||
Patch,
|
||||
Put,
|
||||
Body,
|
||||
UseGuards,
|
||||
Get,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
} from "@nestjs/common";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { extname } from "path";
|
||||
import { Types } from "mongoose";
|
||||
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 { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/select-outer-parts-v2.dto";
|
||||
import {
|
||||
OuterPartCatalogItemDto,
|
||||
SelectOuterPartsV2Dto,
|
||||
SelectOuterPartsV2ResponseDto,
|
||||
} from "./dto/select-outer-parts-v2.dto";
|
||||
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
|
||||
import { CapturePartV2Dto, CapturePartV2ResponseDto } from "./dto/capture-part-v2.dto";
|
||||
import {
|
||||
CapturePartV2Dto,
|
||||
CapturePartV2ResponseDto,
|
||||
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";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
|
||||
@ApiTags("claim-request-management (v2)")
|
||||
@Controller("v2/claim-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard, RolesGuard)
|
||||
@Roles(RoleEnum.USER)
|
||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
|
||||
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);
|
||||
return await this.claimRequestManagementService.getMyClaimsV2(
|
||||
user.sub,
|
||||
user,
|
||||
query,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
@@ -68,7 +95,8 @@ export class ClaimRequestManagementV2Controller {
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Get Claim Details (V2)",
|
||||
description: "Get full details of a claim request. Only the claim owner can access.",
|
||||
description:
|
||||
"Returns the claim snapshot for **USER** (owner) or **FIELD_EXPERT** when permitted. Owners get `ownerGuidance`: `{ phaseKey, headline, nextActions[] (method + pathTemplate), objectionAllowed }` mapped from `status` / `claimStatus` / workflow so the client can show the correct screen without duplicating orchestration logic. FIELD_EXPERT does not receive `ownerGuidance`. Core payload includes documents, captures, evaluation replies, optional expert resend, and masked bank info.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -91,6 +119,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
return await this.claimRequestManagementService.getClaimDetailsV2(
|
||||
claimRequestId,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -100,6 +129,208 @@ export class ClaimRequestManagementV2Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Acknowledge a damage-expert resend that only contains instructions (no extra documents or part photos).
|
||||
*/
|
||||
@Post("request/:claimRequestId/expert-resend/acknowledge")
|
||||
@ApiOperation({
|
||||
summary: "Acknowledge expert resend (instructions only)",
|
||||
description:
|
||||
"Use when `workflow.currentStep` is USER_EXPERT_RESEND and the expert did not list any `resendDocuments` or `resendCarParts`. " +
|
||||
"Returns the claim to WAITING_FOR_DAMAGE_EXPERT. If documents or parts were requested, upload them via the existing upload/capture endpoints instead.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiResponse({ status: 200, description: "Claim returned to expert queue" })
|
||||
@ApiResponse({ status: 400, description: "Resend requires uploads or wrong step" })
|
||||
async acknowledgeExpertResend(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
try {
|
||||
return await this.claimRequestManagementService.acknowledgeExpertResendInstructionsV2(
|
||||
claimRequestId,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to acknowledge expert resend",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection).
|
||||
*/
|
||||
@Put("request/:claimRequestId/objection")
|
||||
@ApiOperation({
|
||||
summary: "Submit user objection (V2)",
|
||||
description:
|
||||
"**Windows:** (1) **Insurer-review:** `ClaimCaseStatus` in **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, with `workflow.currentStep=INSURER_REVIEW` and no recorded **final** `evaluation.ownerInsurerApproval` — including **mixed** priced+factor gate (`NEEDS_REVISION` before priced-line signature for factors) or **final** totals (`claimStatus=APPROVED`). Not allowed while uploading factors (`OWNER_UPLOAD_FACTOR_DOCUMENTS`) or expert validation (`EXPERT_COST_EVALUATION`/`EXPERT_VALIDATING_REPAIR_FACTORS`).\n" +
|
||||
"(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" +
|
||||
"`objectionParts` may only reference **priced** repair lines (`factorNeeded=false`). Factor-only lines cannot be disputed until they have expert pricing.\n\n" +
|
||||
"After **`damageExpertReplyFinal`** exists (final reply following a prior objection), **no second objection** — owner uses **owner-insurer-approval/sign** to accept/reject and close the case.\n\n" +
|
||||
"Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({ type: UserObjectionV2Dto })
|
||||
@ApiResponse({ status: 200, description: "Objection stored" })
|
||||
@ApiResponse({ status: 400, description: "No active resend or empty payload" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Objection already submitted" })
|
||||
async submitUserObjectionV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UserObjectionV2Dto,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
try {
|
||||
return await this.claimRequestManagementService.handleUserObjectionV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to submit objection",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: User satisfaction rating after the claim case is completed (same intent as v1 PUT …/request/:id/user-rating).
|
||||
*/
|
||||
@Put("request/:claimRequestId/user-rating")
|
||||
@ApiOperation({
|
||||
summary: "Submit user satisfaction rating (V2)",
|
||||
description:
|
||||
"Only the claim owner (damaged party) may submit. Allowed when `status` is `COMPLETED`. " +
|
||||
"Stores scores on `ClaimCase.userRating` (0–5). One submission per case.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({ type: UserRatingDto })
|
||||
@ApiResponse({ status: 200, description: "Rating saved" })
|
||||
@ApiResponse({ status: 400, description: "Claim not completed or invalid scores" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Rating already submitted" })
|
||||
async addUserRatingV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() ratingDto: UserRatingDto,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
try {
|
||||
return await this.claimRequestManagementService.addUserRatingV2(
|
||||
claimRequestId,
|
||||
ratingDto,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to save rating",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Owner signature — priced-line gate (mixed factors) or final accept/reject.
|
||||
*/
|
||||
@Put("request/:claimRequestId/owner-insurer-approval/sign")
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "Claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Sign priced lines or final claim pricing (owner)",
|
||||
description:
|
||||
"Multipart: `sign`, `agree`, `branchId`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" +
|
||||
"**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" +
|
||||
"**Phase B — Final:** `claimStatus=APPROVED`, no `evaluation.ownerInsurerApproval` yet. `agree=true` → `COMPLETED`; `agree=false` → `REJECTED`.\n\n" +
|
||||
"Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or `FINAL_APPROVAL` for UI state.",
|
||||
})
|
||||
@ApiBody({
|
||||
description: "Signature file, agreement, and branch",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["sign", "agree", "branchId"],
|
||||
properties: {
|
||||
sign: { type: "string", format: "binary", description: "Signature image" },
|
||||
agree: {
|
||||
type: "boolean",
|
||||
description: "true to accept expert pricing and complete the claim",
|
||||
},
|
||||
branchId: {
|
||||
type: "string",
|
||||
description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" })
|
||||
@ApiResponse({ status: 400, description: "Wrong step/status or missing file" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Already signed" })
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-sign",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
|
||||
callback(null, `${base}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
async submitOwnerInsurerApprovalSignV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body("agree") agree: string | boolean,
|
||||
@Body("branchId") branchId: string,
|
||||
@CurrentUser() user: any,
|
||||
@UploadedFile() sign: Express.Multer.File,
|
||||
) {
|
||||
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 {
|
||||
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
||||
claimRequestId,
|
||||
agreed,
|
||||
typeof branchId === "string" ? branchId : "",
|
||||
sign,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to submit signature",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post("create-from-blame/:blameRequestId")
|
||||
@ApiParam({
|
||||
name: "blameRequestId",
|
||||
@@ -127,6 +358,66 @@ export class ClaimRequestManagementV2Controller {
|
||||
/**
|
||||
* V2 API: Select damaged outer car parts (Step 2 of claim workflow)
|
||||
*/
|
||||
@Get("outer-parts-catalog")
|
||||
@ApiOperation({
|
||||
summary: "Get outer parts catalog (V2)",
|
||||
description:
|
||||
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Outer parts catalog",
|
||||
type: [OuterPartCatalogItemDto],
|
||||
})
|
||||
async getOuterPartsCatalog(
|
||||
@Query("carType") carType?: ClaimVehicleTypeV2,
|
||||
): Promise<OuterPartCatalogItemDto[]> {
|
||||
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||
}
|
||||
|
||||
@Get("branches/:insuranceId")
|
||||
@ApiOperation({
|
||||
summary: "Get insurer branches (V2)",
|
||||
description:
|
||||
"Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "insuranceId",
|
||||
description: "Insurer client id (MongoDB ObjectId)",
|
||||
example: "60d5ec49e7b2f8001c8e4d2a",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of branches for insurer",
|
||||
})
|
||||
async getInsuranceBranchesV2(@Param("insuranceId") insuranceId: string) {
|
||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||
insuranceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("car-other-part")
|
||||
@ApiOperation({
|
||||
summary: "Get other (non-body) parts catalog",
|
||||
description:
|
||||
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Other parts catalog",
|
||||
})
|
||||
async getCarOtherPartsV2() {
|
||||
const raw = await readFile(
|
||||
`${process.cwd()}/src/static/car-part.json`,
|
||||
"utf-8",
|
||||
);
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||
@@ -161,41 +452,35 @@ export class ClaimRequestManagementV2Controller {
|
||||
})
|
||||
@ApiBody({
|
||||
type: SelectOuterPartsV2Dto,
|
||||
description: "Array of selected damaged outer parts",
|
||||
description:
|
||||
"Selected vehicle type + selected outer part IDs from catalog",
|
||||
examples: {
|
||||
example1: {
|
||||
summary: "Minor front damage",
|
||||
summary: "Sedan - minor front damage",
|
||||
value: {
|
||||
selectedParts: ["hood", "front_bumper", "front_right_fender"],
|
||||
carType: "sedan",
|
||||
selectedPartIds: [19, 21, 16],
|
||||
},
|
||||
},
|
||||
example2: {
|
||||
summary: "Side impact damage",
|
||||
summary: "SUV - left side impact",
|
||||
value: {
|
||||
selectedParts: [
|
||||
"front_left_door",
|
||||
"rear_left_door",
|
||||
"front_left_fender",
|
||||
"rear_left_fender",
|
||||
],
|
||||
carType: "suv",
|
||||
selectedPartIds: [102, 103, 104, 107],
|
||||
},
|
||||
},
|
||||
example3: {
|
||||
summary: "Rear-end collision",
|
||||
summary: "Hatchback - rear-end collision",
|
||||
value: {
|
||||
selectedParts: ["rear_bumper", "trunk", "rear_right_fender"],
|
||||
carType: "hatchback",
|
||||
selectedPartIds: [225, 226, 210],
|
||||
},
|
||||
},
|
||||
example4: {
|
||||
summary: "Multiple damage areas",
|
||||
summary: "Pickup - two sides + roof",
|
||||
value: {
|
||||
selectedParts: [
|
||||
"hood",
|
||||
"front_bumper",
|
||||
"front_right_door",
|
||||
"rear_bumper",
|
||||
"trunk",
|
||||
],
|
||||
carType: "pickup",
|
||||
selectedPartIds: [319, 312, 330],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -231,6 +516,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
claimRequestId,
|
||||
body,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -252,13 +538,14 @@ export class ClaimRequestManagementV2Controller {
|
||||
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
||||
|
||||
**Purpose:** User selects non-body damaged parts and provides bank information for payment.
|
||||
Optional: upload car green card file in the same step.
|
||||
|
||||
**Validations:**
|
||||
- Claim must exist
|
||||
- User must be the claim owner (damaged party from blame case)
|
||||
- Current workflow step must be SELECT_OTHER_PARTS
|
||||
- Bank information must not have been submitted previously
|
||||
- Sheba number must be exactly 24 digits
|
||||
- Sheba (sheba) accepted as IR + 24 digits or only 24 digits
|
||||
- National code must be exactly 10 digits
|
||||
|
||||
**Valid Other Parts (Optional):**
|
||||
@@ -267,8 +554,8 @@ export class ClaimRequestManagementV2Controller {
|
||||
- headlight, taillight, mirror, glass
|
||||
|
||||
**After Success:**
|
||||
- Workflow moves to: UPLOAD_REQUIRED_DOCUMENTS (Step 4)
|
||||
- User can proceed to upload required documents
|
||||
- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
|
||||
- User captures car angles and damaged-part photos, then uploads required documents (Step 5)
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
@@ -276,42 +563,38 @@ export class ClaimRequestManagementV2Controller {
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-required-document",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `other-parts-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiBody({
|
||||
type: SelectOtherPartsV2Dto,
|
||||
description: "Other parts selection and bank information",
|
||||
examples: {
|
||||
example1: {
|
||||
summary: "Only bank info (no other parts damaged)",
|
||||
value: {
|
||||
otherParts: [],
|
||||
shebaNumber: "123456789012345678901234",
|
||||
nationalCodeOfOwner: "1234567890",
|
||||
},
|
||||
},
|
||||
example2: {
|
||||
summary: "Engine and suspension damage",
|
||||
value: {
|
||||
otherParts: ["engine", "suspension"],
|
||||
shebaNumber: "123456789012345678901234",
|
||||
nationalCodeOfOwner: "1234567890",
|
||||
},
|
||||
},
|
||||
example3: {
|
||||
summary: "Multiple systems damaged",
|
||||
value: {
|
||||
otherParts: ["engine", "brake_system", "electrical", "headlight"],
|
||||
shebaNumber: "123456789012345678901234",
|
||||
nationalCodeOfOwner: "1234567890",
|
||||
},
|
||||
},
|
||||
example4: {
|
||||
summary: "Lighting and glass damage",
|
||||
value: {
|
||||
otherParts: ["headlight", "taillight", "mirror", "glass"],
|
||||
shebaNumber: "123456789012345678901234",
|
||||
nationalCodeOfOwner: "1234567890",
|
||||
description:
|
||||
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer` like THIRD_PARTY flow. Optional file can be uploaded as car green card.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
otherParts: {
|
||||
oneOf: [
|
||||
{ type: "array", items: { type: "string" } },
|
||||
{ type: "string", description: "JSON string array for multipart" },
|
||||
],
|
||||
example: ["engine", "suspension"],
|
||||
},
|
||||
sheba: { type: "string", example: "IR123456789012345678901234" },
|
||||
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
required: ["sheba", "nationalCodeOfInsurer"],
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
@@ -339,12 +622,17 @@ export class ClaimRequestManagementV2Controller {
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOtherPartsV2Dto,
|
||||
@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,
|
||||
body,
|
||||
user.sub,
|
||||
user,
|
||||
file,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -364,11 +652,13 @@ export class ClaimRequestManagementV2Controller {
|
||||
summary: "Get Capture Requirements (V2)",
|
||||
description: `
|
||||
**Get list of what needs to be captured:**
|
||||
- Required documents (13 items)
|
||||
- Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item)
|
||||
- Car angles (4 items: front, back, left, right)
|
||||
- Damaged parts (based on selected outer parts)
|
||||
|
||||
Returns status of each item (uploaded/captured or not).
|
||||
|
||||
**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({
|
||||
@@ -397,6 +687,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
return await this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||
claimRequestId,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -430,20 +721,19 @@ Returns status of each item (uploaded/captured or not).
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Upload Required Document (V2 - Step 4)",
|
||||
summary: "Upload Required Document (V2 - Step 5)",
|
||||
description: `
|
||||
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim)
|
||||
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
||||
|
||||
**Upload one of the 13 required documents:**
|
||||
- car_green_card
|
||||
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
||||
- damaged_driving_license_front/back
|
||||
- damaged_chassis_number, damaged_engine_photo
|
||||
- damaged_car_card_front/back, damaged_metal_plate
|
||||
- guilty_driving_license_front/back
|
||||
- guilty_car_card_front/back, guilty_metal_plate
|
||||
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
|
||||
|
||||
**When all 13 documents are uploaded:**
|
||||
- Workflow automatically moves to: CAPTURE_PART_DAMAGES (Step 5)
|
||||
**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE and the claim is ready for damage expert review.
|
||||
|
||||
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to upload documents on behalf of the damaged party.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
@@ -459,7 +749,6 @@ Returns status of each item (uploaded/captured or not).
|
||||
documentKey: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"car_green_card",
|
||||
"damaged_driving_license_front",
|
||||
"damaged_driving_license_back",
|
||||
"damaged_chassis_number",
|
||||
@@ -473,7 +762,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
"guilty_car_card_back",
|
||||
"guilty_metal_plate",
|
||||
],
|
||||
example: "car_green_card",
|
||||
example: "damaged_driving_license_front",
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
@@ -501,12 +790,14 @@ 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,
|
||||
body,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -538,15 +829,17 @@ Returns status of each item (uploaded/captured or not).
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Capture Car Angle or Damaged Part (V2 - Step 5)",
|
||||
summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
|
||||
description: `
|
||||
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 5 of Claim)
|
||||
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
|
||||
|
||||
**Capture types:**
|
||||
1. **angle**: Car angles (front, back, left, right) - 4 required
|
||||
2. **part**: Damaged parts based on selectedParts from Step 2
|
||||
|
||||
**All captures must be completed before user can submit claim.**
|
||||
**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.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
@@ -592,12 +885,14 @@ 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,
|
||||
body,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -606,4 +901,131 @@ Returns status of each item (uploaded/captured or not).
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Upload repair factor for a part (same intent as v1 PATCH …/request/reply/:claimRequestId/:partId/upload-factor).
|
||||
*/
|
||||
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Upload repair factor file for a factor-needed part (V2)",
|
||||
description:
|
||||
"Part must have `factorNeeded: true` on the active reply (`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`). One file per part.\n\n" +
|
||||
"**Requires:** `ClaimCaseStatus` **`OWNER_REPAIR_FACTOR_UPLOAD_PENDING`** or **`INSURER_REVIEW_MIXED_FACTORS_PENDING`** (or legacy **`WAITING_FOR_INSURER_APPROVAL`**), `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`.\n\n" +
|
||||
"**Mixed priced+factor replies:** owner must complete **owner-insurer-approval/sign** (priced-line phase) first so `evaluation.ownerPricedPartsApproval` exists.\n\n" +
|
||||
"When every `factorNeeded` line has `factorLink`, the case moves to **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION` for damage expert validation.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId", description: "Claim case ID" })
|
||||
@ApiParam({ name: "partId", description: "Part id from expert reply" })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-factors",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
callback(null, `-${unique}-${file.originalname}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async uploadFactorForPartV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Param("partId") partId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
try {
|
||||
return await this.claimRequestManagementService.uploadClaimFactorV2(
|
||||
claimRequestId,
|
||||
partId,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to upload factor",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API: Upload car walk-around video (same behavior as v1 PATCH claim-request-management/car-capture/:id)
|
||||
*/
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/car-capture-videos/",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
const filename = `claim-video-${unique}${ex}`;
|
||||
callback(null, filename);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@Patch("car-capture/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Upload car walk-around video (V2)",
|
||||
description:
|
||||
"Multipart upload of the car capture video during CAPTURE_PART_DAMAGES. " +
|
||||
"Stores file metadata in `claim-video-capture` and sets `ClaimCase.media.videoCaptureId`. " +
|
||||
"Only one video per claim; path is stored on the video document (not duplicated on the case).",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Video uploaded successfully",
|
||||
type: VideoCaptureV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Wrong workflow step or missing file" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Video already uploaded" })
|
||||
async captureVideoCaptureV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@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,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to upload car capture video",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
||||
|
||||
/**
|
||||
* V2 DTO for capturing car angle or damaged part
|
||||
@@ -18,7 +17,8 @@ export class CapturePartV2Dto {
|
||||
captureType: 'angle' | 'part';
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Key of the angle or part being captured',
|
||||
description:
|
||||
'When captureType is angle: front | back | left | right. When part: catalog id as string (e.g. "101"), 0-based index (e.g. "0"), or full catalog key (e.g. left_backfender). Prefer id or index for parts.',
|
||||
example: 'front',
|
||||
})
|
||||
@IsNotEmpty({ message: 'Capture key is required' })
|
||||
@@ -78,4 +78,32 @@ export class CapturePartV2ResponseDto {
|
||||
example: 'Angle captured successfully. 6 captures remaining.',
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'True when expert-requested part resends are complete and the claim returned to the expert queue.',
|
||||
})
|
||||
expertResendComplete?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response DTO for car walk-around video upload (V2)
|
||||
*/
|
||||
export class VideoCaptureV2ResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Claim case ID',
|
||||
example: '507f1f77bcf86cd799439011',
|
||||
})
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID of the stored video document (claim-video-capture)',
|
||||
example: '507f1f77bcf86cd799439012',
|
||||
})
|
||||
videoId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Success message',
|
||||
example: 'Video capture uploaded successfully.',
|
||||
})
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for required document item
|
||||
@@ -34,6 +35,13 @@ export class RequiredDocumentItem {
|
||||
enum: ['general', 'damaged_party', 'guilty_party'],
|
||||
})
|
||||
category: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'When true, the client should upload this file during CAPTURE_PART_DAMAGES (same POST upload-document endpoint and `requiredDocuments` keys). Capture cannot finish until these are uploaded.',
|
||||
example: true,
|
||||
})
|
||||
preferUploadDuringCapture?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,10 +79,17 @@ export class CarAngleItem {
|
||||
*/
|
||||
export class DamagedPartItem {
|
||||
@ApiProperty({
|
||||
description: 'Part key',
|
||||
example: 'hood',
|
||||
description:
|
||||
'Side-agnostic part name (matches catalog suffix); use with `side` to disambiguate',
|
||||
example: 'backfender',
|
||||
})
|
||||
key: string;
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Vehicle side / region (left, right, front, back, top)',
|
||||
example: 'left',
|
||||
})
|
||||
side?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Display label in Farsi',
|
||||
@@ -82,6 +97,12 @@ export class DamagedPartItem {
|
||||
})
|
||||
label_fa: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Deprecated: same as `name` (kept for older clients)',
|
||||
example: 'backfender',
|
||||
})
|
||||
key?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Display label in English',
|
||||
example: 'Hood',
|
||||
@@ -93,6 +114,12 @@ export class DamagedPartItem {
|
||||
example: false,
|
||||
})
|
||||
captured: boolean;
|
||||
|
||||
/** Static catalog id (same as `damagedParts[].id` in capture requirements) when the part comes from the outer-parts catalog. */
|
||||
@ApiPropertyOptional({ example: 12 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,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],
|
||||
@@ -159,5 +200,7 @@ export class GetCaptureRequirementsV2ResponseDto {
|
||||
anglesTotal: number;
|
||||
partsCaptured: number;
|
||||
partsTotal: number;
|
||||
capturePhaseDocsRemaining: number;
|
||||
postCaptureDocumentsRemaining: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,78 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { DamageSelectedPartV2BodyDto } from './damage-selected-part-v2.dto';
|
||||
|
||||
/** Suggested HTTP call for the owner UI (`pathTemplate`: replace placeholders). */
|
||||
export class ClaimDetailsOwnerNextActionV2Dto {
|
||||
@ApiProperty({ description: 'Stable UI key', example: 'FINAL_SIGN' })
|
||||
key: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'HTTP verb', example: 'PUT' })
|
||||
method?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Path under API root',
|
||||
example: 'v2/claim-request-management/request/{claimRequestId}/owner-insurer-approval/sign',
|
||||
})
|
||||
pathTemplate?: string;
|
||||
|
||||
@ApiProperty({ description: 'What this endpoint does for the user' })
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Server-derived hints: which phase the claim is in and what to offer next (owners only). */
|
||||
export class ClaimDetailsOwnerGuidanceV2Dto {
|
||||
@ApiProperty({
|
||||
description: 'Machine-readable phase',
|
||||
enum: [
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
'EXPERT_RESEND',
|
||||
'WAITING_DAMAGE_EXPERT',
|
||||
'EXPERT_REVIEWING',
|
||||
'USER_FLOW',
|
||||
'UPLOAD_FACTORS',
|
||||
'EXPERT_VALIDATING_FACTORS',
|
||||
'SIGN_PRICED_LINES',
|
||||
'INSURER_REVIEW_NEEDS_REVISION',
|
||||
'FINAL_SIGN_OR_REJECT',
|
||||
'INSURER_APPROVAL_FALLBACK',
|
||||
],
|
||||
})
|
||||
phaseKey: string;
|
||||
|
||||
@ApiProperty({ description: 'Short headline for the current phase' })
|
||||
headline: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Longer UX copy' })
|
||||
detail?: string;
|
||||
|
||||
@ApiProperty({ type: [ClaimDetailsOwnerNextActionV2Dto] })
|
||||
nextActions: ClaimDetailsOwnerNextActionV2Dto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Whether PUT objection is permitted (see server validation for exact rules)',
|
||||
})
|
||||
objectionAllowed?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'How objection relates to priced vs factor-only lines' })
|
||||
objectionHint?: string;
|
||||
}
|
||||
|
||||
/** Active damage-expert resend request (owner must upload or acknowledge). */
|
||||
export class ExpertResendDetailsV2Dto {
|
||||
@ApiPropertyOptional()
|
||||
resendDescription?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
resendDocuments?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [Object] })
|
||||
resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' })
|
||||
fulfilledAt?: Date;
|
||||
}
|
||||
|
||||
export class ClaimDetailsV2ResponseDto {
|
||||
@ApiProperty({ description: 'Claim case ID' })
|
||||
@@ -10,7 +84,11 @@ export class ClaimDetailsV2ResponseDto {
|
||||
@ApiProperty({ description: 'Request number' })
|
||||
requestNo: string;
|
||||
|
||||
@ApiProperty({ description: 'Overall case status' })
|
||||
@ApiProperty({
|
||||
description:
|
||||
"ClaimCaseStatus; see also `ownerGuidance` for UX. Post-expert: INSURER_REVIEW_AWAITING_OWNER_SIGN | INSURER_REVIEW_MIXED_FACTORS_PENDING | OWNER_REPAIR_FACTOR_UPLOAD_PENDING | EXPERT_VALIDATING_REPAIR_FACTORS; legacy WAITING_FOR_INSURER_APPROVAL may still appear.",
|
||||
example: "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
|
||||
})
|
||||
status: string;
|
||||
|
||||
@ApiProperty({ description: 'Claim damage status' })
|
||||
@@ -28,9 +106,13 @@ export class ClaimDetailsV2ResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Blame request number' })
|
||||
blameRequestNo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Owner info' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Claim owner (damaged party): ids for the user and their insurer client scope',
|
||||
})
|
||||
owner?: {
|
||||
userId: string;
|
||||
clientId?: string;
|
||||
userClientKey?: string;
|
||||
fullName?: string;
|
||||
};
|
||||
|
||||
@@ -42,8 +124,11 @@ export class ClaimDetailsV2ResponseDto {
|
||||
plate?: any;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: 'Selected outer damaged parts' })
|
||||
selectedParts?: string[];
|
||||
@ApiPropertyOptional({
|
||||
description: 'Selected outer damaged parts (ordered objects with id, name, side, label_fa)',
|
||||
type: [DamageSelectedPartV2BodyDto],
|
||||
})
|
||||
selectedParts?: DamageSelectedPartV2BodyDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Selected other damaged parts' })
|
||||
otherParts?: string[];
|
||||
@@ -54,14 +139,73 @@ export class ClaimDetailsV2ResponseDto {
|
||||
nationalCodeOfOwner?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required documents status' })
|
||||
requiredDocuments?: Record<string, { uploaded: boolean; fileId?: string }>;
|
||||
@ApiPropertyOptional({ description: 'Required documents status (link instead of id)' })
|
||||
requiredDocuments?: Record<string, { uploaded: boolean; fileUrl?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Car angles captured' })
|
||||
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Per-part capture status and URLs (array index aligns with selectedParts; includes id, name, side, label_fa)',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
index: { type: 'number' },
|
||||
id: { type: 'number', nullable: true },
|
||||
name: { type: 'string' },
|
||||
side: { type: 'string' },
|
||||
label_fa: { type: 'string' },
|
||||
captured: { type: 'boolean' },
|
||||
url: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
fileName: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
damagedParts?: Array<{
|
||||
index: number;
|
||||
id?: number | null;
|
||||
name: string;
|
||||
side: string;
|
||||
label_fa: string;
|
||||
captured: boolean;
|
||||
url?: string;
|
||||
path?: string;
|
||||
fileName?: string;
|
||||
}>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
|
||||
})
|
||||
expertResend?: ExpertResendDetailsV2Dto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Damage expert opinion(s): initial and final (after objection).",
|
||||
type: Object,
|
||||
})
|
||||
evaluation?: {
|
||||
damageExpertReply?: unknown;
|
||||
damageExpertReplyFinal?: unknown;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: ClaimDetailsOwnerGuidanceV2Dto,
|
||||
description:
|
||||
'Owner-only: derived headline, suggested API routes, and whether objection is plausible. Omitted when the actor is FIELD_EXPERT.',
|
||||
})
|
||||
ownerGuidance?: ClaimDetailsOwnerGuidanceV2Dto;
|
||||
|
||||
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
|
||||
userRating?: {
|
||||
progressSpeed: number;
|
||||
registrationEase: number;
|
||||
overallEvaluation: number;
|
||||
comment?: string;
|
||||
createdAt?: Date;
|
||||
};
|
||||
|
||||
@ApiProperty({ description: 'Created at' })
|
||||
createdAt: string;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsInt, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** Stored shape / API payload for one selected outer damaged part (V2). */
|
||||
export class DamageSelectedPartV2BodyDto {
|
||||
@ApiPropertyOptional({ description: "Catalog id when from outer-parts catalog" })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
id?: number;
|
||||
|
||||
@ApiProperty({ example: "backfender" })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: "left" })
|
||||
@IsString()
|
||||
side: string;
|
||||
|
||||
@ApiProperty({ example: "گلگیر عقب" })
|
||||
@IsString()
|
||||
label_fa: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Original catalog key, e.g. left_backfender" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
catalogKey?: string;
|
||||
}
|
||||
@@ -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' })
|
||||
@@ -10,7 +10,11 @@ export class ClaimListItemV2Dto {
|
||||
@ApiProperty({ description: 'Claim request number', example: 'CL12345' })
|
||||
requestNo: string;
|
||||
|
||||
@ApiProperty({ description: 'Overall case status', example: 'WAITING_FOR_DAMAGE_EXPERT' })
|
||||
@ApiProperty({
|
||||
description:
|
||||
"ClaimCaseStatus. Post-expert owner phase includes: INSURER_REVIEW_AWAITING_OWNER_SIGN (priced lines only → final owner sign); INSURER_REVIEW_MIXED_FACTORS_PENDING (priced + factor lines); OWNER_REPAIR_FACTOR_UPLOAD_PENDING (all lines factor-needed); EXPERT_VALIDATING_REPAIR_FACTORS (all factors uploaded, expert validating). Legacy DB rows may still use WAITING_FOR_INSURER_APPROVAL for those flows.",
|
||||
example: "INSURER_REVIEW_AWAITING_OWNER_SIGN",
|
||||
})
|
||||
status: string;
|
||||
|
||||
@ApiProperty({ description: 'Claim damage determination status', example: 'PENDING' })
|
||||
@@ -30,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;
|
||||
}
|
||||
|
||||
@@ -41,34 +41,43 @@ export class SelectOtherPartsV2Dto {
|
||||
otherParts?: OtherCarPart[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Sheba number (IBAN) for payment - 24 digits without IR prefix',
|
||||
description: 'Sheba number (IBAN). Accepted: IR + 24 digits OR only 24 digits',
|
||||
example: 'IR123456789012345678901234',
|
||||
})
|
||||
@IsNotEmpty({ message: 'sheba is required' })
|
||||
@IsString({ message: 'sheba must be a string' })
|
||||
sheba: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Legacy alias of sheba for backward compatibility',
|
||||
example: '123456789012345678901234',
|
||||
pattern: '^[0-9]{24}$',
|
||||
minLength: 24,
|
||||
maxLength: 24,
|
||||
})
|
||||
@IsNotEmpty({ message: 'Sheba number is required' })
|
||||
@IsString({ message: 'Sheba number must be a string' })
|
||||
@Length(24, 24, { message: 'Sheba number must be exactly 24 digits' })
|
||||
@Matches(/^[0-9]{24}$/, {
|
||||
message: 'Sheba number must contain exactly 24 digits (without IR prefix)',
|
||||
})
|
||||
shebaNumber: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
shebaNumber?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'National code of the owner - 10 digits',
|
||||
description: 'National code of insurer/owner - 10 digits',
|
||||
example: '1234567890',
|
||||
pattern: '^[0-9]{10}$',
|
||||
minLength: 10,
|
||||
maxLength: 10,
|
||||
})
|
||||
@IsNotEmpty({ message: 'National code of owner is required' })
|
||||
@IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
|
||||
@IsString({ message: 'National code must be a string' })
|
||||
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
|
||||
@Matches(/^[0-9]{10}$/, {
|
||||
message: 'National code must contain exactly 10 digits',
|
||||
})
|
||||
nationalCodeOfOwner: string;
|
||||
nationalCodeOfInsurer: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Legacy alias for backward compatibility',
|
||||
example: '1234567890',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalCodeOfOwner?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,19 +117,20 @@ export class SelectOtherPartsV2ResponseDto {
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current workflow step',
|
||||
example: 'UPLOAD_REQUIRED_DOCUMENTS',
|
||||
example: 'CAPTURE_PART_DAMAGES',
|
||||
})
|
||||
currentStep: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Next possible workflow step',
|
||||
example: 'CAPTURE_PART_DAMAGES',
|
||||
example: 'UPLOAD_REQUIRED_DOCUMENTS',
|
||||
})
|
||||
nextStep: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Success message',
|
||||
example: 'Other parts and bank information saved successfully. Please proceed to upload required documents.',
|
||||
example:
|
||||
'Other parts and bank information saved successfully. Please capture car angles and damaged parts, then upload required documents.',
|
||||
})
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique } from 'class-validator';
|
||||
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique, IsOptional, IsInt } from 'class-validator';
|
||||
import {
|
||||
ClaimVehicleTypeV2,
|
||||
OuterPartSideV2,
|
||||
} from "src/static/outer-car-parts-catalog";
|
||||
import { DamageSelectedPartV2BodyDto } from "./damage-selected-part-v2.dto";
|
||||
|
||||
/**
|
||||
* Enum for valid outer car parts that can be damaged
|
||||
@@ -43,7 +48,7 @@ export class SelectOuterPartsV2Dto {
|
||||
minItems: 1,
|
||||
maxItems: 13,
|
||||
})
|
||||
@IsNotEmpty({ message: 'Selected parts array cannot be empty' })
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'selectedParts must be an array' })
|
||||
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
|
||||
@ArrayUnique({ message: 'Duplicate parts are not allowed' })
|
||||
@@ -51,7 +56,29 @@ export class SelectOuterPartsV2Dto {
|
||||
each: true,
|
||||
message: 'Invalid part name. Must be one of the valid outer car parts',
|
||||
})
|
||||
selectedParts: OuterCarPart[];
|
||||
selectedParts?: OuterCarPart[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Selected outer part IDs from catalog',
|
||||
example: [9, 10, 4],
|
||||
type: [Number],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'selectedPartIds must be an array' })
|
||||
@ArrayMinSize(1, { message: 'At least one part ID must be selected' })
|
||||
@ArrayUnique({ message: 'Duplicate part IDs are not allowed' })
|
||||
@IsInt({ each: true, message: 'Each selected part ID must be an integer' })
|
||||
selectedPartIds?: number[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Vehicle type for validating available outer parts',
|
||||
enum: ClaimVehicleTypeV2,
|
||||
required: true,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsEnum(ClaimVehicleTypeV2)
|
||||
carType?: ClaimVehicleTypeV2;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,10 +98,18 @@ export class SelectOuterPartsV2ResponseDto {
|
||||
publicId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Array of selected damaged parts',
|
||||
example: ['hood', 'front_right_door', 'rear_bumper'],
|
||||
description:
|
||||
"Ordered selected parts: id, side-agnostic name, side, label_fa (and optional catalogKey)",
|
||||
type: [DamageSelectedPartV2BodyDto],
|
||||
})
|
||||
selectedParts: string[];
|
||||
selectedParts: DamageSelectedPartV2BodyDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Selected part IDs',
|
||||
example: [9, 7, 11],
|
||||
type: [Number],
|
||||
})
|
||||
selectedPartIds: number[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current workflow step',
|
||||
@@ -94,3 +129,65 @@ export class SelectOuterPartsV2ResponseDto {
|
||||
})
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class SetClaimVehicleTypeV2Dto {
|
||||
@ApiProperty({
|
||||
description: "Vehicle type selected by user",
|
||||
enum: ClaimVehicleTypeV2,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsEnum(ClaimVehicleTypeV2)
|
||||
carType: ClaimVehicleTypeV2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape returned by `GET .../outer-parts-catalog` (both user and expert
|
||||
* controllers). It mirrors how items are persisted under
|
||||
* `damage.selectedParts` (see `DamageSelectedPartV2BodyDto`) so the front-end
|
||||
* can match catalog rows to stored selections without any field renaming:
|
||||
* `name` is side-agnostic, `label_fa` is disambiguated with the side in
|
||||
* parentheses, and the original full catalog key (`left_backfender`) is
|
||||
* exposed as `catalogKey`.
|
||||
*/
|
||||
export class OuterPartCatalogItemDto {
|
||||
@ApiProperty({
|
||||
description: "Static catalog id (unique across all car types)",
|
||||
example: 102,
|
||||
})
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Side-agnostic part name (matches stored part `name`)",
|
||||
example: "backWheel",
|
||||
})
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Vehicle side / region",
|
||||
enum: OuterPartSideV2,
|
||||
example: "left",
|
||||
})
|
||||
side: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Display label in Farsi, with side disambiguator in parentheses when needed",
|
||||
example: "چرخ عقب (چپ)",
|
||||
})
|
||||
label_fa: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Original full catalog key (matches stored `catalogKey`)",
|
||||
example: "left_backWheel",
|
||||
required: false,
|
||||
})
|
||||
catalogKey?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Vehicle type this catalog row belongs to",
|
||||
enum: ClaimVehicleTypeV2,
|
||||
required: false,
|
||||
example: "suv",
|
||||
})
|
||||
carType?: ClaimVehicleTypeV2;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
||||
|
||||
@@ -64,4 +64,9 @@ export class UploadRequiredDocumentV2ResponseDto {
|
||||
example: 'Document uploaded successfully. 12 documents remaining.',
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'True when the owner finished every damage-expert resend requirement and the claim is back in the expert queue.',
|
||||
})
|
||||
expertResendComplete?: boolean;
|
||||
}
|
||||
|
||||
32
src/claim-request-management/dto/user-objection-v2.dto.ts
Normal file
32
src/claim-request-management/dto/user-objection-v2.dto.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
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 UserObjectionDto}
|
||||
* with nested validation enabled for the v2 controller pipeline.
|
||||
*/
|
||||
export class UserObjectionV2Dto {
|
||||
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UserObjectionPartDto)
|
||||
objectionParts?: UserObjectionPartDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NewPartDto)
|
||||
newParts?: NewPartDto[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -29,6 +29,16 @@ export class ClaimCaseDbService {
|
||||
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true });
|
||||
}
|
||||
|
||||
async findOneAndUpdate(
|
||||
filter: FilterQuery<ClaimCase>,
|
||||
update: any,
|
||||
options?: { new?: boolean },
|
||||
): Promise<ClaimCaseDocument | null> {
|
||||
return this.claimCaseModel.findOneAndUpdate(filter, update, {
|
||||
new: options?.new !== false,
|
||||
});
|
||||
}
|
||||
|
||||
async find(
|
||||
filter: FilterQuery<ClaimCase>,
|
||||
options?: { lean?: boolean; select?: string },
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Schema as MongooseSchema } from "mongoose";
|
||||
import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimDamageSelection {
|
||||
/**
|
||||
* V2: Array of selected damaged outer car part names
|
||||
* Examples: ['hood', 'front_right_door', 'rear_bumper']
|
||||
* V2: Selected outer damaged parts (ordered). Prefer objects
|
||||
* `{ id, name, side, label_fa, catalogKey? }`; legacy string keys are still accepted until migrated.
|
||||
*/
|
||||
@Prop({ type: [String], default: [] })
|
||||
selectedParts?: string[];
|
||||
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||
selectedParts?: unknown[];
|
||||
|
||||
/**
|
||||
* V2: Array of selected other (non-body) damaged parts
|
||||
@@ -28,4 +29,3 @@ export class ClaimDamageSelection {
|
||||
}
|
||||
export const ClaimDamageSelectionSchema =
|
||||
SchemaFactory.createForClass(ClaimDamageSelection);
|
||||
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Schema as MongooseSchema, Types } from "mongoose";
|
||||
import {
|
||||
ExpertProfileSnapshot,
|
||||
ExpertProfileSnapshotSchema,
|
||||
} from "src/request-management/entities/schema/expert-profile-snapshot.schema";
|
||||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||||
import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimPartPricing {
|
||||
@Prop({ type: String })
|
||||
partId: string;
|
||||
@Prop({ type: Number })
|
||||
partId: number;
|
||||
|
||||
@Prop({ type: String })
|
||||
carPartDamage?: string;
|
||||
/**
|
||||
* Unified outer-part snapshot `{ id?, name, side, label_fa, catalogKey? }` (same as
|
||||
* `damage.selectedParts` / capture). Legacy: string or `{ part?, side? }` (expert UI).
|
||||
* Must be Mixed — objects cannot cast to String.
|
||||
*/
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
carPartDamage?: unknown;
|
||||
|
||||
@Prop({ type: String })
|
||||
typeOfDamage?: string;
|
||||
@@ -23,8 +32,9 @@ export class ClaimPartPricing {
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
totalPayment?: string | number;
|
||||
|
||||
/** V1/V2: string/number legacy, or `{ option, price?, branchId? }` after expert normalization. */
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
daghi?: string | number;
|
||||
daghi?: string | number | Record<string, unknown>;
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
factorNeeded?: boolean;
|
||||
@@ -67,6 +77,43 @@ export class ClaimUserComment {
|
||||
export const ClaimUserCommentSchema =
|
||||
SchemaFactory.createForClass(ClaimUserComment);
|
||||
|
||||
/** Owner acceptance + signature after expert pricing (post-expert insurer `INSURER_REVIEW` — `INSURER_REVIEW_AWAITING_OWNER_SIGN` / `INSURER_REVIEW_MIXED_FACTORS_PENDING` / legacy `WAITING_FOR_INSURER_APPROVAL`). */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimOwnerInsurerApproval {
|
||||
@Prop({ type: Boolean, required: true })
|
||||
agree: boolean;
|
||||
|
||||
/** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */
|
||||
@Prop({ type: Types.ObjectId })
|
||||
branchId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
signDetailId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Date, default: () => new Date() })
|
||||
signedAt?: Date;
|
||||
}
|
||||
export const ClaimOwnerInsurerApprovalSchema =
|
||||
SchemaFactory.createForClass(ClaimOwnerInsurerApproval);
|
||||
|
||||
/** Owner signed acceptance of priced lines only — required before uploading factors when reply is mixed priced + factorNeeded. */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimOwnerPricedPartsApproval {
|
||||
@Prop({ type: Boolean, required: true })
|
||||
agree: boolean;
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
branchId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
signDetailId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Date, default: () => new Date() })
|
||||
signedAt?: Date;
|
||||
}
|
||||
export const ClaimOwnerPricedPartsApprovalSchema =
|
||||
SchemaFactory.createForClass(ClaimOwnerPricedPartsApproval);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimExpertReply {
|
||||
@Prop({ type: String })
|
||||
@@ -83,14 +130,19 @@ export class ClaimExpertReply {
|
||||
|
||||
@Prop({ type: ClaimUserCommentSchema })
|
||||
userComment?: ClaimUserComment;
|
||||
|
||||
/** Damage expert profile at reply time (from `damage-expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
}
|
||||
export const ClaimExpertReplySchema =
|
||||
SchemaFactory.createForClass(ClaimExpertReply);
|
||||
|
||||
/** One line item the user disputes on an expert-priced part */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjection {
|
||||
@Prop({ type: String, required: true })
|
||||
partId: string;
|
||||
export class ClaimUserObjectionPart {
|
||||
@Prop({ type: Number, required: true })
|
||||
partId: number;
|
||||
|
||||
@Prop({ type: String })
|
||||
reason?: string;
|
||||
@@ -103,9 +155,48 @@ export class ClaimUserObjection {
|
||||
|
||||
@Prop({ type: String })
|
||||
typeOfDamage?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
carPartDamage?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
side?: string;
|
||||
}
|
||||
export const ClaimUserObjectionSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjection);
|
||||
export const ClaimUserObjectionPartSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionPart);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjectionNewPart {
|
||||
/** 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;
|
||||
|
||||
@Prop({ type: String })
|
||||
side?: string;
|
||||
}
|
||||
export const ClaimUserObjectionNewPartSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionNewPart);
|
||||
|
||||
/**
|
||||
* Full user objection payload (matches v1 DTO: objectionParts + newParts).
|
||||
* Stored on {@link ClaimEvaluation.objection}.
|
||||
*/
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjectionPayload {
|
||||
@Prop({ type: [ClaimUserObjectionPartSchema], default: [] })
|
||||
objectionParts?: ClaimUserObjectionPart[];
|
||||
|
||||
@Prop({ type: [ClaimUserObjectionNewPartSchema], default: [] })
|
||||
newParts?: ClaimUserObjectionNewPart[];
|
||||
|
||||
@Prop({ type: Date, default: () => new Date() })
|
||||
submittedAt?: Date;
|
||||
}
|
||||
export const ClaimUserObjectionPayloadSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionPayload);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimResendRequest {
|
||||
@@ -117,6 +208,17 @@ export class ClaimResendRequest {
|
||||
|
||||
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||
resendCarParts?: any[];
|
||||
|
||||
/** Set when the owner has satisfied every requested document/part (or acknowledged description-only resend). */
|
||||
@Prop({ type: Date })
|
||||
fulfilledAt?: Date;
|
||||
|
||||
/** 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);
|
||||
@@ -145,6 +247,9 @@ export class ClaimFileRating {
|
||||
|
||||
@Prop({ type: Number, min: 0, max: 5 })
|
||||
guiltyVehicleIdentification?: number;
|
||||
|
||||
@Prop({ type: Number, min: 0, max: 5 })
|
||||
botRating?: number;
|
||||
}
|
||||
export const ClaimFileRatingSchema =
|
||||
SchemaFactory.createForClass(ClaimFileRating);
|
||||
@@ -165,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);
|
||||
|
||||
@@ -182,8 +293,8 @@ export class ClaimEvaluation {
|
||||
@Prop({ type: ClaimResendRequestSchema })
|
||||
damageExpertResend?: ClaimResendRequest | null;
|
||||
|
||||
@Prop({ type: ClaimUserObjectionSchema })
|
||||
objection?: ClaimUserObjection;
|
||||
@Prop({ type: ClaimUserObjectionPayloadSchema })
|
||||
objection?: ClaimUserObjectionPayload;
|
||||
|
||||
@Prop({ type: ClaimUserResendPayloadSchema })
|
||||
userResendDocuments?: ClaimUserResendPayload;
|
||||
@@ -191,11 +302,25 @@ export class ClaimEvaluation {
|
||||
@Prop({ type: ClaimFileRatingSchema })
|
||||
rating?: ClaimFileRating;
|
||||
|
||||
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
|
||||
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
|
||||
|
||||
@Prop({ type: ClaimOwnerPricedPartsApprovalSchema })
|
||||
ownerPricedPartsApproval?: ClaimOwnerPricedPartsApproval;
|
||||
|
||||
@Prop({ type: String })
|
||||
visitLocation?: string;
|
||||
|
||||
@Prop({ type: ClaimPriceDropSchema })
|
||||
priceDrop?: ClaimPriceDrop;
|
||||
|
||||
/** Damage expert profile at last factor validation (`damage-expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
factorValidationExpertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
|
||||
/** Damage expert profile when in-person visit was requested (`damage-expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
inPersonVisitExpertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
}
|
||||
export const ClaimEvaluationSchema =
|
||||
SchemaFactory.createForClass(ClaimEvaluation);
|
||||
|
||||
@@ -50,6 +50,9 @@ export class ClaimOwner {
|
||||
|
||||
@Prop({ type: String })
|
||||
fullName?: string;
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
clientId: Types.ObjectId;
|
||||
}
|
||||
export const ClaimOwnerSchema = SchemaFactory.createForClass(ClaimOwner);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Types } from "mongoose";
|
||||
import { Schema as MongooseSchema, Types } from "mongoose";
|
||||
import { AiImagesModel } from "./ai-image.schema";
|
||||
import { CarGreenCardModel } from "./car-green-card.schema";
|
||||
import { ImageRequiredModel } from "./image-required.schema";
|
||||
@@ -20,6 +20,39 @@ export class CapturedImage {
|
||||
}
|
||||
export const CapturedImageSchema = SchemaFactory.createForClass(CapturedImage);
|
||||
|
||||
/** One row per selected damaged part (index-aligned with `damage.selectedParts`). */
|
||||
@Schema({ _id: false })
|
||||
export class DamagedPartMediaV2Row {
|
||||
@Prop({ type: Number })
|
||||
id?: number;
|
||||
|
||||
@Prop({ type: String })
|
||||
name?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
side?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
label_fa?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
catalogKey?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
path?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
fileName?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
url?: string;
|
||||
|
||||
@Prop({ type: Date })
|
||||
capturedAt?: Date;
|
||||
}
|
||||
export const DamagedPartMediaV2RowSchema =
|
||||
SchemaFactory.createForClass(DamagedPartMediaV2Row);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimMedia {
|
||||
@Prop({ type: CarGreenCardModel })
|
||||
@@ -46,15 +79,10 @@ export class ClaimMedia {
|
||||
carAngles?: Map<string, CapturedImage>;
|
||||
|
||||
/**
|
||||
* V2: Damaged parts captures
|
||||
* Map of part key to captured image
|
||||
* V2: Damaged parts captures — prefer an array (index matches `damage.selectedParts`).
|
||||
* Legacy documents may store a plain object map keyed by part slug until migrated on write.
|
||||
*/
|
||||
@Prop({
|
||||
type: Map,
|
||||
of: CapturedImageSchema,
|
||||
default: () => ({}),
|
||||
})
|
||||
damagedParts?: Map<string, CapturedImage>;
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
damagedParts?: DamagedPartMediaV2Row[] | Record<string, unknown>;
|
||||
}
|
||||
export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia);
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Types } from "mongoose";
|
||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||
import {
|
||||
ExpertProfileSnapshot,
|
||||
ExpertProfileSnapshotSchema,
|
||||
} from "src/request-management/entities/schema/expert-profile-snapshot.schema";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimActorLock {
|
||||
@@ -12,9 +17,29 @@ export class ClaimActorLock {
|
||||
|
||||
@Prop({ type: String, default: "damage_expert" })
|
||||
actorRole: "damage_expert" | "expert" | "admin";
|
||||
|
||||
/** Damage expert profile when lock was taken (`damage-expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
}
|
||||
export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock);
|
||||
|
||||
/** Captures queue fields before a damage-expert lock; restored if the lock TTL expires without a reply. */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimPreLockQueueSnapshot {
|
||||
@Prop({ type: String, enum: ClaimStatus, required: true })
|
||||
claimStatus: ClaimStatus;
|
||||
|
||||
@Prop({ type: String, enum: ClaimWorkflowStep, required: true })
|
||||
currentStep: ClaimWorkflowStep;
|
||||
|
||||
@Prop({ type: String, enum: ClaimWorkflowStep, required: false })
|
||||
nextStep?: ClaimWorkflowStep;
|
||||
}
|
||||
export const ClaimPreLockQueueSnapshotSchema = SchemaFactory.createForClass(
|
||||
ClaimPreLockQueueSnapshot,
|
||||
);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimWorkflow {
|
||||
@Prop({ type: String, enum: ClaimWorkflowStep })
|
||||
@@ -32,8 +57,15 @@ export class ClaimWorkflow {
|
||||
@Prop({ type: Date })
|
||||
lockedAt?: Date;
|
||||
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||
@Prop({ type: Date })
|
||||
expiredAt?: Date;
|
||||
|
||||
@Prop({ type: ClaimActorLockSchema })
|
||||
lockedBy?: ClaimActorLock;
|
||||
|
||||
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
|
||||
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
|
||||
}
|
||||
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ClaimCaseSnapshotSchema,
|
||||
} from "./claim-case.snapshot.schema";
|
||||
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
|
||||
import { UserClaimRating } from "./claim-request-management.schema";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class RequiredDocumentRef {
|
||||
@@ -75,6 +76,12 @@ export class ClaimCase {
|
||||
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
|
||||
claimStatus: ClaimStatus;
|
||||
|
||||
/**
|
||||
* Set while the linked blame file is in WAITING_FOR_DOCUMENT_RESEND so the claim flow can block or message the user.
|
||||
*/
|
||||
@Prop({ default: false })
|
||||
blameDocumentResendPending?: boolean;
|
||||
|
||||
/**
|
||||
* Link to the blame case that originated this claim.
|
||||
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
|
||||
@@ -133,6 +140,15 @@ export class ClaimCase {
|
||||
@Prop({ type: [HistoryEventSchema], default: [] })
|
||||
history?: HistoryEvent[];
|
||||
|
||||
/**
|
||||
* User satisfaction rating (damaged party), after the case is completed.
|
||||
*/
|
||||
@Prop({ type: UserClaimRating, required: false })
|
||||
userRating?: UserClaimRating;
|
||||
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
createdByRegistrarId?: Types.ObjectId;
|
||||
|
||||
/**
|
||||
* Legacy fields kept optional to simplify progressive migration.
|
||||
* If you choose to migrate later, we can remove these.
|
||||
|
||||
@@ -122,6 +122,10 @@ export class SubmitReply {
|
||||
|
||||
@Prop()
|
||||
userComment?: UserComment;
|
||||
|
||||
/** Damage expert profile at reply time (`damage-expert` collection). */
|
||||
@Prop({ type: Object })
|
||||
expertProfileSnapshot?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export class ActorLockDetails {
|
||||
@@ -130,6 +134,10 @@ export class ActorLockDetails {
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
actorId?: Types.ObjectId;
|
||||
|
||||
/** Damage expert profile when lock was taken (`damage-expert` collection). */
|
||||
@Prop({ type: Object })
|
||||
expertProfileSnapshot?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export class ResendCarPartsDto {
|
||||
@@ -149,6 +157,10 @@ export class ClaimSubmitResend {
|
||||
|
||||
@Prop({ required: true })
|
||||
resendCarParts: ResendCarPartsDto[];
|
||||
|
||||
/** Damage expert profile when resend was requested (`damage-expert` collection). */
|
||||
@Prop({ type: Object })
|
||||
expertProfileSnapshot?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export class UserResendDocuments {
|
||||
@@ -360,6 +372,14 @@ export class ClaimRequestManagementModel {
|
||||
@Prop({ type: UserClaimRating, required: false })
|
||||
userRating?: UserClaimRating;
|
||||
|
||||
/** Latest damage expert profile when validating repair factors (V1). */
|
||||
@Prop({ type: Object })
|
||||
factorValidationExpertProfileSnapshot?: Record<string, string | undefined>;
|
||||
|
||||
/** Damage expert profile when in-person visit was requested (V1). */
|
||||
@Prop({ type: Object })
|
||||
damageExpertInPersonVisitProfileSnapshot?: Record<string, string | undefined>;
|
||||
|
||||
@Prop({ type: String, required: false })
|
||||
visitLocation?: string;
|
||||
|
||||
|
||||
135
src/claim-request-management/registrar-claim.v1.controller.ts
Normal file
135
src/claim-request-management/registrar-claim.v1.controller.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { extname } from "path";
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, 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 { SelectOuterPartsV2Dto } from "./dto/select-outer-parts-v2.dto";
|
||||
import { SelectOtherPartsV2Dto } from "./dto/select-other-parts-v2.dto";
|
||||
import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto";
|
||||
import { CapturePartV2Dto } from "./dto/capture-part-v2.dto";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
|
||||
@ApiTags("registrar-claim (v1)")
|
||||
@Controller("registrar-claim")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.REGISTRAR)
|
||||
export class RegistrarClaimV1Controller {
|
||||
constructor(private readonly claimRequestManagementService: ClaimRequestManagementService) {}
|
||||
|
||||
@Post("create-from-blame/:blameRequestId")
|
||||
@ApiParam({ name: "blameRequestId" })
|
||||
createFromBlame(@Param("blameRequestId") blameRequestId: string, @CurrentUser() registrar: any) {
|
||||
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
|
||||
blameRequestId,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("request/:claimRequestId")
|
||||
getClaim(@Param("claimRequestId") claimRequestId: string, @CurrentUser() registrar: any) {
|
||||
return this.claimRequestManagementService.getClaimDetailsV2(
|
||||
claimRequestId,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiBody({ type: SelectOuterPartsV2Dto })
|
||||
selectOuter(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOuterPartsV2Dto,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.selectOuterPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-other-parts/:claimRequestId")
|
||||
@ApiBody({ type: SelectOtherPartsV2Dto })
|
||||
selectOther(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOtherPartsV2Dto,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.selectOtherPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("capture-requirements/:claimRequestId")
|
||||
getCapture(@Param("claimRequestId") claimRequestId: string, @CurrentUser() registrar: any) {
|
||||
return this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||
claimRequestId,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("upload-document/:claimRequestId")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-documents",
|
||||
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiOperation({ summary: "Registrar uploads required claim document" })
|
||||
uploadDoc(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UploadRequiredDocumentV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("capture-part/:claimRequestId")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-captures",
|
||||
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
capturePart(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: CapturePartV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.capturePartV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
71
src/client/client-panel.controller.ts
Normal file
71
src/client/client-panel.controller.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ClientService } from "./client.service";
|
||||
import {
|
||||
ClientSettingsPanelResponseDto,
|
||||
UpdateClientSettingsDto,
|
||||
} from "./dto/client-settings.dto";
|
||||
|
||||
/**
|
||||
* Insurer (company) panel: per-tenant upload limits and CAR_BODY accident window.
|
||||
* Scoped to the JWT `clientKey` — tenants cannot edit other clients.
|
||||
*/
|
||||
@Controller("client-panel")
|
||||
@ApiTags("insurer-client-panel")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.COMPANY)
|
||||
export class ClientPanelController {
|
||||
constructor(private readonly clientService: ClientService) {}
|
||||
|
||||
@Get("settings")
|
||||
@ApiOperation({
|
||||
summary: "Get tenant settings (media limits & CAR_BODY accident window)",
|
||||
description:
|
||||
"Returns configured overrides, effective values (with system defaults), and route upload ceilings for the authenticated insurer's client.",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: ClientSettingsPanelResponseDto })
|
||||
async getSettings(
|
||||
@CurrentUser() insurer: { clientKey?: string },
|
||||
): Promise<ClientSettingsPanelResponseDto> {
|
||||
if (!insurer?.clientKey) {
|
||||
throw new UnauthorizedException("Insurer client key is missing.");
|
||||
}
|
||||
return this.clientService.getPanelSettings(insurer.clientKey);
|
||||
}
|
||||
|
||||
@Patch("settings")
|
||||
@ApiOperation({
|
||||
summary: "Update tenant settings",
|
||||
description:
|
||||
"Partial update of `settings.carBodyAccidentMaxAgeDays` and/or `settings.media` (video, image, voice). " +
|
||||
"Only fields sent in the body are changed. `maxBytes` cannot exceed the route ceiling returned by GET.",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: ClientSettingsPanelResponseDto })
|
||||
async updateSettings(
|
||||
@CurrentUser() insurer: { clientKey?: string },
|
||||
@Body() body: UpdateClientSettingsDto,
|
||||
): Promise<ClientSettingsPanelResponseDto> {
|
||||
if (!insurer?.clientKey) {
|
||||
throw new UnauthorizedException("Insurer client key is missing.");
|
||||
}
|
||||
return this.clientService.updatePanelSettings(insurer.clientKey, body);
|
||||
}
|
||||
}
|
||||
@@ -7,25 +7,27 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
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 { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ClientService } from "./client.service";
|
||||
import { ClientDto } from "./dto/create-client.dto";
|
||||
|
||||
@Controller("client")
|
||||
@ApiTags("client-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard, RolesGuard)
|
||||
@Roles(RoleEnum.ADMIN)
|
||||
export class ClientController {
|
||||
constructor(private readonly clientService: ClientService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(GlobalGuard)
|
||||
@ApiBearerAuth()
|
||||
async addClient(@Body() client: ClientDto) {
|
||||
return await this.clientService.addClient(client);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard)
|
||||
async getClient(@CurrentUser() user) {
|
||||
return await this.clientService.getClients();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { ClientController } from "./client.controller";
|
||||
import { ClientPanelController } from "./client-panel.controller";
|
||||
import { ClientService } from "./client.service";
|
||||
import { BranchDbService } from "./entities/db-service/branch.db.service";
|
||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||
@@ -17,7 +18,7 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [ClientController],
|
||||
controllers: [ClientController, ClientPanelController],
|
||||
providers: [ClientService, ClientDbService, BranchDbService],
|
||||
exports: [ClientService, ClientDbService, BranchDbService],
|
||||
})
|
||||
|
||||
@@ -1,12 +1,70 @@
|
||||
import { BadGatewayException, GoneException, Injectable } from "@nestjs/common";
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
GoneException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
ClientDto,
|
||||
ClientDtoRs,
|
||||
ClientLists,
|
||||
} from "src/client/dto/create-client.dto";
|
||||
import {
|
||||
type ClientSettingsPanelResponseDto,
|
||||
UpdateClientSettingsDto,
|
||||
} from "src/client/dto/client-settings.dto";
|
||||
import type { ClientMediaLimits } from "./entities/schema/client.schema";
|
||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||
|
||||
/**
|
||||
* System-wide default applied when a client document has no
|
||||
* `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the
|
||||
* gate is enforced even for older / unconfigured clients.
|
||||
*/
|
||||
export const DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Media kinds we enforce upload-size bounds for. Each kind has its own
|
||||
* default window (see {@link DEFAULT_MEDIA_LIMITS}) and an optional
|
||||
* per-client override under `settings.media.<kind>`.
|
||||
*/
|
||||
export type MediaKind = "video" | "image" | "voice";
|
||||
|
||||
export interface MediaLimits {
|
||||
/** Inclusive lower bound. `0` allows any size from zero up. */
|
||||
minBytes: number;
|
||||
/** Inclusive upper bound. */
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* System defaults applied when a client (or kind) has no explicit override.
|
||||
*
|
||||
* Important: each upload route also has a `multer.limits.fileSize` hard
|
||||
* ceiling — those route ceilings are the absolute maximum the API can
|
||||
* receive. Per-client `maxBytes` cannot legitimately go above its route's
|
||||
* multer ceiling, so the defaults below are kept at-or-below those.
|
||||
*/
|
||||
export const DEFAULT_MEDIA_LIMITS: Record<MediaKind, MediaLimits> = {
|
||||
video: { minBytes: 256 * 1024, maxBytes: 20 * 1024 * 1024 },
|
||||
image: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 },
|
||||
voice: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 },
|
||||
};
|
||||
|
||||
/** Highest multer `fileSize` used on any route for each kind (policy cannot exceed this). */
|
||||
export const MEDIA_ROUTE_MAX_BYTES: Record<MediaKind, number> = {
|
||||
video: 50 * 1024 * 1024,
|
||||
image: 10 * 1024 * 1024,
|
||||
voice: 10 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN = 1;
|
||||
export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX = 365;
|
||||
|
||||
const MEDIA_KINDS: MediaKind[] = ["video", "image", "voice"];
|
||||
|
||||
@Injectable()
|
||||
export class ClientService {
|
||||
constructor(private readonly clientDbService: ClientDbService) {}
|
||||
@@ -53,4 +111,205 @@ export class ClientService {
|
||||
const list = client.map((element) => new ClientLists(element));
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-client byte bounds for a media kind. Missing bounds fall
|
||||
* back to {@link DEFAULT_MEDIA_LIMITS}. If no clientId is supplied (or it
|
||||
* doesn't resolve to a client document) the defaults are returned.
|
||||
*
|
||||
* The returned object is always fully populated — callers can compare
|
||||
* directly against `file.size`.
|
||||
*/
|
||||
async getMediaLimits(
|
||||
clientId: string | Types.ObjectId | null | undefined,
|
||||
kind: MediaKind,
|
||||
): Promise<MediaLimits> {
|
||||
const defaults = DEFAULT_MEDIA_LIMITS[kind];
|
||||
if (!clientId) return { ...defaults };
|
||||
|
||||
const idString = String(clientId);
|
||||
if (!Types.ObjectId.isValid(idString)) return { ...defaults };
|
||||
|
||||
const client = await this.clientDbService.findOne({
|
||||
_id: new Types.ObjectId(idString),
|
||||
});
|
||||
const override = client?.settings?.media?.[kind];
|
||||
const minBytes =
|
||||
typeof override?.minBytes === "number" &&
|
||||
Number.isFinite(override.minBytes) &&
|
||||
override.minBytes >= 0
|
||||
? override.minBytes
|
||||
: defaults.minBytes;
|
||||
const maxBytes =
|
||||
typeof override?.maxBytes === "number" &&
|
||||
Number.isFinite(override.maxBytes) &&
|
||||
override.maxBytes > 0
|
||||
? override.maxBytes
|
||||
: defaults.maxBytes;
|
||||
return { minBytes, maxBytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-client CAR_BODY accident-age window (in days).
|
||||
*
|
||||
* Falls back to {@link DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS} when:
|
||||
* - no client id is supplied,
|
||||
* - the id is malformed,
|
||||
* - the client document is missing,
|
||||
* - the client has no `settings.carBodyAccidentMaxAgeDays` configured,
|
||||
* - or the configured value is not a positive finite number.
|
||||
*/
|
||||
async getCarBodyAccidentMaxAgeDays(
|
||||
clientId?: string | Types.ObjectId | null,
|
||||
): Promise<number> {
|
||||
if (!clientId) return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
|
||||
|
||||
const idString = String(clientId);
|
||||
if (!Types.ObjectId.isValid(idString)) {
|
||||
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
|
||||
}
|
||||
|
||||
const client = await this.clientDbService.findOne({
|
||||
_id: new Types.ObjectId(idString),
|
||||
});
|
||||
const configured = client?.settings?.carBodyAccidentMaxAgeDays;
|
||||
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
|
||||
return configured;
|
||||
}
|
||||
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
|
||||
}
|
||||
|
||||
private resolveClientObjectId(clientKey: string | Types.ObjectId): Types.ObjectId {
|
||||
const raw = String(clientKey ?? "").trim();
|
||||
if (!Types.ObjectId.isValid(raw)) {
|
||||
throw new BadRequestException("Invalid client key");
|
||||
}
|
||||
return new Types.ObjectId(raw);
|
||||
}
|
||||
|
||||
private async loadClientOrThrow(clientKey: string | Types.ObjectId) {
|
||||
const id = this.resolveClientObjectId(clientKey);
|
||||
const client = await this.clientDbService.findOne({ _id: id });
|
||||
if (!client) {
|
||||
throw new NotFoundException("Client not found");
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
private configuredMediaLimits(
|
||||
stored: ClientMediaLimits | undefined,
|
||||
): { minBytes?: number; maxBytes?: number } | null {
|
||||
if (!stored) return null;
|
||||
const hasMin = typeof stored.minBytes === "number";
|
||||
const hasMax = typeof stored.maxBytes === "number";
|
||||
if (!hasMin && !hasMax) return null;
|
||||
return {
|
||||
...(hasMin ? { minBytes: stored.minBytes } : {}),
|
||||
...(hasMax ? { maxBytes: stored.maxBytes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private validateMediaLimitsPatch(
|
||||
kind: MediaKind,
|
||||
patch: { minBytes?: number; maxBytes?: number },
|
||||
): { minBytes?: number; maxBytes?: number } {
|
||||
const defaults = DEFAULT_MEDIA_LIMITS[kind];
|
||||
const routeMax = MEDIA_ROUTE_MAX_BYTES[kind];
|
||||
const minBytes =
|
||||
patch.minBytes !== undefined ? patch.minBytes : defaults.minBytes;
|
||||
const maxBytes =
|
||||
patch.maxBytes !== undefined ? patch.maxBytes : defaults.maxBytes;
|
||||
|
||||
if (maxBytes > routeMax) {
|
||||
throw new BadRequestException(
|
||||
`${kind} maxBytes (${maxBytes}) cannot exceed the route upload limit of ${routeMax} bytes.`,
|
||||
);
|
||||
}
|
||||
if (minBytes > maxBytes) {
|
||||
throw new BadRequestException(
|
||||
`${kind} minBytes (${minBytes}) cannot be greater than maxBytes (${maxBytes}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const out: { minBytes?: number; maxBytes?: number } = {};
|
||||
if (patch.minBytes !== undefined) out.minBytes = patch.minBytes;
|
||||
if (patch.maxBytes !== undefined) out.maxBytes = patch.maxBytes;
|
||||
return out;
|
||||
}
|
||||
|
||||
async getPanelSettings(
|
||||
clientKey: string | Types.ObjectId,
|
||||
): Promise<ClientSettingsPanelResponseDto> {
|
||||
const client = await this.loadClientOrThrow(clientKey);
|
||||
const clientId = client._id.toString();
|
||||
|
||||
const configuredDays = client.settings?.carBodyAccidentMaxAgeDays;
|
||||
const effectiveDays = await this.getCarBodyAccidentMaxAgeDays(client._id);
|
||||
|
||||
const media = {} as ClientSettingsPanelResponseDto["media"];
|
||||
for (const kind of MEDIA_KINDS) {
|
||||
const effective = await this.getMediaLimits(client._id, kind);
|
||||
media[kind] = {
|
||||
configured: this.configuredMediaLimits(client.settings?.media?.[kind]),
|
||||
effective,
|
||||
systemDefault: { ...DEFAULT_MEDIA_LIMITS[kind] },
|
||||
routeMaxBytes: MEDIA_ROUTE_MAX_BYTES[kind],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
clientId,
|
||||
carBodyAccidentMaxAgeDays: {
|
||||
configured:
|
||||
typeof configuredDays === "number" && Number.isFinite(configuredDays)
|
||||
? configuredDays
|
||||
: null,
|
||||
effective: effectiveDays,
|
||||
systemDefault: DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS,
|
||||
minDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN,
|
||||
maxDays: CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MAX,
|
||||
},
|
||||
media,
|
||||
};
|
||||
}
|
||||
|
||||
async updatePanelSettings(
|
||||
clientKey: string | Types.ObjectId,
|
||||
body: UpdateClientSettingsDto,
|
||||
): Promise<ClientSettingsPanelResponseDto> {
|
||||
const client = await this.loadClientOrThrow(clientKey);
|
||||
const $set: Record<string, unknown> = {};
|
||||
|
||||
if (body.carBodyAccidentMaxAgeDays !== undefined) {
|
||||
$set["settings.carBodyAccidentMaxAgeDays"] = body.carBodyAccidentMaxAgeDays;
|
||||
}
|
||||
|
||||
if (body.media) {
|
||||
for (const kind of MEDIA_KINDS) {
|
||||
const patch = body.media[kind];
|
||||
if (!patch) continue;
|
||||
const validated = this.validateMediaLimitsPatch(kind, patch);
|
||||
if (validated.minBytes !== undefined) {
|
||||
$set[`settings.media.${kind}.minBytes`] = validated.minBytes;
|
||||
}
|
||||
if (validated.maxBytes !== undefined) {
|
||||
$set[`settings.media.${kind}.maxBytes`] = validated.maxBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys($set).length === 0) {
|
||||
throw new BadRequestException("No settings fields to update.");
|
||||
}
|
||||
|
||||
const updated = await this.clientDbService.findByIdAndUpdate(
|
||||
client._id.toString(),
|
||||
{ $set },
|
||||
);
|
||||
if (!updated) {
|
||||
throw new NotFoundException("Client not found");
|
||||
}
|
||||
|
||||
return this.getPanelSettings(client._id);
|
||||
}
|
||||
}
|
||||
|
||||
131
src/client/dto/client-settings.dto.ts
Normal file
131
src/client/dto/client-settings.dto.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import type { MediaKind } from "../client.service";
|
||||
|
||||
export class ClientMediaLimitsDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Minimum upload size in bytes (inclusive). Omit to keep default.",
|
||||
example: 5120,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minBytes?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Maximum upload size in bytes (inclusive). Cannot exceed route ceiling.",
|
||||
example: 8388608,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export class ClientMediaSettingsDto {
|
||||
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClientMediaLimitsDto)
|
||||
video?: ClientMediaLimitsDto;
|
||||
|
||||
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClientMediaLimitsDto)
|
||||
image?: ClientMediaLimitsDto;
|
||||
|
||||
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClientMediaLimitsDto)
|
||||
voice?: ClientMediaLimitsDto;
|
||||
}
|
||||
|
||||
export class UpdateClientSettingsDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Max days between CAR_BODY accident date and submission. Omit to leave unchanged.",
|
||||
example: 7,
|
||||
minimum: 1,
|
||||
maximum: 365,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(365)
|
||||
carBodyAccidentMaxAgeDays?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: ClientMediaSettingsDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClientMediaSettingsDto)
|
||||
media?: ClientMediaSettingsDto;
|
||||
}
|
||||
|
||||
export class MediaLimitsResolvedDto {
|
||||
@ApiProperty({ example: 5120 })
|
||||
minBytes: number;
|
||||
|
||||
@ApiProperty({ example: 8388608 })
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
export class MediaKindSettingsViewDto {
|
||||
@ApiPropertyOptional({ type: ClientMediaLimitsDto })
|
||||
configured: { minBytes?: number; maxBytes?: number } | null;
|
||||
|
||||
@ApiProperty({ type: MediaLimitsResolvedDto })
|
||||
effective: MediaLimitsResolvedDto;
|
||||
|
||||
@ApiProperty({ type: MediaLimitsResolvedDto })
|
||||
systemDefault: MediaLimitsResolvedDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Hard multer ceiling for this kind on upload routes (bytes)",
|
||||
example: 10485760,
|
||||
})
|
||||
routeMaxBytes: number;
|
||||
}
|
||||
|
||||
export class CarBodyAccidentWindowViewDto {
|
||||
@ApiPropertyOptional({ example: 14, nullable: true })
|
||||
configured: number | null;
|
||||
|
||||
@ApiProperty({ example: 14 })
|
||||
effective: number;
|
||||
|
||||
@ApiProperty({ example: 7 })
|
||||
systemDefault: number;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
minDays: number;
|
||||
|
||||
@ApiProperty({ example: 365 })
|
||||
maxDays: number;
|
||||
}
|
||||
|
||||
export class ClientSettingsPanelResponseDto {
|
||||
@ApiProperty({ example: "507f1f77bcf86cd799439011" })
|
||||
clientId: string;
|
||||
|
||||
@ApiProperty({ type: CarBodyAccidentWindowViewDto })
|
||||
carBodyAccidentMaxAgeDays: CarBodyAccidentWindowViewDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Per-kind upload bounds (video / image / voice)",
|
||||
example: {
|
||||
video: {},
|
||||
image: {},
|
||||
voice: {},
|
||||
},
|
||||
})
|
||||
media: Record<MediaKind, MediaKindSettingsViewDto>;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, IsNotEmpty, IsOptional } from "class-validator";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateBranchDto {
|
||||
@ApiProperty({ example: "شهرک غرب" })
|
||||
@@ -31,4 +37,18 @@ export class CreateBranchDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: true, default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
example: "2025-01-01T00:00:00.000Z",
|
||||
description: "Branch activity start datetime (ISO string)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
activityStartDate?: string;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { Types } from "mongoose";
|
||||
|
||||
class ClientName {
|
||||
@ApiProperty({})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
persian: string;
|
||||
|
||||
@ApiProperty({})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
english: string;
|
||||
}
|
||||
class Property {
|
||||
@ApiProperty({})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
smsApiKey: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClientDto {
|
||||
@ApiProperty({ required: true })
|
||||
@ValidateNested()
|
||||
@Type(() => ClientName)
|
||||
clientName: ClientName;
|
||||
|
||||
@ApiProperty({ required: true })
|
||||
@IsNumber()
|
||||
clientCode: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
property: Property;
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => Property)
|
||||
property?: Property;
|
||||
|
||||
@ApiProperty({ examples: ["legal", "genuine"] })
|
||||
@IsIn(["legal", "genuine"])
|
||||
useExpertMode: "legal" | "genuine";
|
||||
}
|
||||
export class ClientDtoRs {
|
||||
@@ -35,6 +48,9 @@ export class ClientDtoRs {
|
||||
useExpertsMode: string;
|
||||
constructor(readonly client) {
|
||||
this.persian = client.clientName.persian;
|
||||
this.english = client.clientName.english;
|
||||
this.clientId = client._id;
|
||||
this.useExpertsMode = client.useExpertMode;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,70 @@ export class BranchDbService {
|
||||
});
|
||||
}
|
||||
|
||||
async findAllWithFilters(
|
||||
insuranceId: string,
|
||||
opts?: {
|
||||
search?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
isActive?: boolean;
|
||||
},
|
||||
): Promise<BranchModel[]> {
|
||||
const filter: any = { clientKey: new Types.ObjectId(insuranceId) };
|
||||
|
||||
if (typeof opts?.isActive === "boolean") {
|
||||
filter.isActive = opts.isActive;
|
||||
}
|
||||
|
||||
if (opts?.from || opts?.to) {
|
||||
filter.$or = [
|
||||
{
|
||||
createdAt: {
|
||||
...(opts.from ? { $gte: opts.from } : {}),
|
||||
...(opts.to ? { $lte: opts.to } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
activityStartDate: {
|
||||
...(opts.from ? { $gte: opts.from } : {}),
|
||||
...(opts.to ? { $lte: opts.to } : {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (opts?.search?.trim()) {
|
||||
const q = opts.search.trim();
|
||||
const rx = new RegExp(q, "i");
|
||||
filter.$and = [
|
||||
...(filter.$and || []),
|
||||
{
|
||||
$or: [
|
||||
{ name: rx },
|
||||
{ code: rx },
|
||||
{ city: rx },
|
||||
{ state: rx },
|
||||
{ address: rx },
|
||||
{ phoneNumber: rx },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return this.branchModel.find(filter).lean();
|
||||
}
|
||||
|
||||
async findByIds(ids: Types.ObjectId[]) {
|
||||
return this.branchModel
|
||||
.find({ _id: { $in: ids } })
|
||||
.select({ _id: 1, name: 1, code: 1, city: 1, state: 1, address: 1, phoneNumber: 1, isActive: 1, activityStartDate: 1, createdAt: 1, updatedAt: 1 })
|
||||
.lean();
|
||||
}
|
||||
|
||||
async findByIdAndUpdate(id: string, update: any): Promise<BranchModel | null> {
|
||||
return this.branchModel.findByIdAndUpdate(id, update, { new: true }).lean();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BranchModel | null> {
|
||||
return this.branchModel.findById(id).lean();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ export class BranchModel {
|
||||
|
||||
@Prop()
|
||||
phoneNumber?: string;
|
||||
|
||||
@Prop({ type: Boolean, default: true })
|
||||
isActive?: boolean;
|
||||
|
||||
@Prop({ type: Date })
|
||||
activityStartDate?: Date;
|
||||
}
|
||||
|
||||
export const BranchSchema = SchemaFactory.createForClass(BranchModel);
|
||||
|
||||
@@ -2,6 +2,63 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
|
||||
export type ClientDocument = ClientModel & Document;
|
||||
|
||||
/**
|
||||
* Per-media (video/image/voice) byte bounds. Either bound is optional so a
|
||||
* tenant can configure only one side (e.g. raise `maxBytes` without setting
|
||||
* a floor). When a bound is missing the system-wide default is used (see
|
||||
* {@link ClientService.getMediaLimits} for the defaults table).
|
||||
*/
|
||||
export class ClientMediaLimits {
|
||||
@Prop({ type: Number, required: false })
|
||||
minBytes?: number;
|
||||
|
||||
@Prop({ type: Number, required: false })
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bag of per-media-kind limits. Each kind is optional and any unset values
|
||||
* fall back to the system default (see `ClientService.getMediaLimits`).
|
||||
*
|
||||
* Note: the system also keeps an absolute multer ceiling per upload route
|
||||
* (e.g. 50MB for car-capture videos, 20MB for blame videos, 10MB for
|
||||
* voice/signature/image). A client-configured `maxBytes` cannot exceed the
|
||||
* route's multer ceiling because multer rejects oversize requests before
|
||||
* the request reaches the policy check.
|
||||
*/
|
||||
export class ClientMediaSettings {
|
||||
@Prop({ type: ClientMediaLimits, required: false })
|
||||
video?: ClientMediaLimits;
|
||||
|
||||
@Prop({ type: ClientMediaLimits, required: false })
|
||||
image?: ClientMediaLimits;
|
||||
|
||||
@Prop({ type: ClientMediaLimits, required: false })
|
||||
voice?: ClientMediaLimits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tenant tunables. Add new policy fields here; consumers read them via
|
||||
* `ClientService` with documented defaults so older client documents keep
|
||||
* working when a field is missing.
|
||||
*/
|
||||
export class ClientSettings {
|
||||
/**
|
||||
* Max number of days between the accident and the moment a CAR_BODY blame
|
||||
* file is allowed to be submitted. When unset, consumers fall back to the
|
||||
* system default (see {@link ClientService.getCarBodyAccidentMaxAgeDays}).
|
||||
*/
|
||||
@Prop({ type: Number, required: false })
|
||||
carBodyAccidentMaxAgeDays?: number;
|
||||
|
||||
/**
|
||||
* Per-kind media upload bounds (V2 upload endpoints). Unset kinds /
|
||||
* bounds fall back to the defaults in `ClientService.getMediaLimits`.
|
||||
*/
|
||||
@Prop({ type: ClientMediaSettings, required: false })
|
||||
media?: ClientMediaSettings;
|
||||
}
|
||||
|
||||
@Schema({ collection: "clients", versionKey: false })
|
||||
export class ClientModel {
|
||||
@Prop({ required: true, unique: true, type: Object })
|
||||
@@ -20,6 +77,9 @@ export class ClientModel {
|
||||
|
||||
@Prop({ required: true, unique: false })
|
||||
clientCode: number;
|
||||
|
||||
@Prop({ type: ClientSettings, required: false, default: {} })
|
||||
settings?: ClientSettings;
|
||||
}
|
||||
|
||||
export const ClientDbSchema = SchemaFactory.createForClass(ClientModel);
|
||||
|
||||
78
src/common/dto/list-query-v2.dto.ts
Normal file
78
src/common/dto/list-query-v2.dto.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
|
||||
/** Allowed sort keys for V2 list endpoints (claims / blame). */
|
||||
export const LIST_SORT_BY_V2 = [
|
||||
"publicId",
|
||||
"createdAt",
|
||||
"requestNo",
|
||||
"status",
|
||||
] as const;
|
||||
|
||||
export type ListSortByV2 = (typeof LIST_SORT_BY_V2)[number];
|
||||
|
||||
/**
|
||||
* Optional query params for V2 lists. If neither `page` nor `limit` is sent,
|
||||
* the full filtered+sorted list is returned (backward compatible).
|
||||
*/
|
||||
export class ListQueryV2Dto {
|
||||
@ApiPropertyOptional({
|
||||
description: "1-based page index (use together with `limit` for pagination).",
|
||||
example: 1,
|
||||
minimum: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Page size (max 100). If only `page` is set, defaults to 20.",
|
||||
example: 20,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: LIST_SORT_BY_V2,
|
||||
description: "Sort field. Default: `createdAt`.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...LIST_SORT_BY_V2])
|
||||
sortBy?: ListSortByV2;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["asc", "desc"],
|
||||
description: "Sort direction. Default: `desc` for dates, `asc` for publicId/requestNo.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["asc", "desc"])
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Case-insensitive substring match on `publicId` and `requestNo` (and other endpoint-specific fields).",
|
||||
example: "A142",
|
||||
maxLength: 64,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
search?: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from "@nestjs/common";
|
||||
import { Observable } from "rxjs";
|
||||
import { normalizeUnicodeDigitsDeep } from "src/utils/unicode-digits";
|
||||
|
||||
/**
|
||||
* Normalizes Unicode decimal digits in JSON request bodies to ASCII before
|
||||
* validation and handlers run (Persian/Arabic-Indic → 0-9).
|
||||
*/
|
||||
@Injectable()
|
||||
export class UnicodeDigitsNormalizeInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const contentType = String(req.headers?.["content-type"] ?? "");
|
||||
|
||||
if (
|
||||
req.body &&
|
||||
typeof req.body === "object" &&
|
||||
!Buffer.isBuffer(req.body) &&
|
||||
!contentType.includes("multipart/form-data")
|
||||
) {
|
||||
req.body = normalizeUnicodeDigitsDeep(req.body);
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
24
src/common/validators/has-objection-entries.validator.ts
Normal file
24
src/common/validators/has-objection-entries.validator.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
ValidationArguments,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "hasObjectionEntries", async: false })
|
||||
export class HasObjectionEntriesConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(_value: unknown, args: ValidationArguments): boolean {
|
||||
const o = args.object as {
|
||||
objectionParts?: unknown[];
|
||||
newParts?: unknown[];
|
||||
};
|
||||
const partsLen = Array.isArray(o.objectionParts) ? o.objectionParts.length : 0;
|
||||
const newLen = Array.isArray(o.newParts) ? o.newParts.length : 0;
|
||||
return partsLen > 0 || newLen > 0;
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return "Provide at least one entry in objectionParts and/or newParts.";
|
||||
}
|
||||
}
|
||||
37
src/common/validators/money-amount-string.validator.ts
Normal file
37
src/common/validators/money-amount-string.validator.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||||
|
||||
@ValidatorConstraint({ name: "isMoneyAmountString", async: false })
|
||||
export class IsMoneyAmountStringConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(value: unknown): boolean {
|
||||
if (value == null || value === "") return true;
|
||||
if (typeof value !== "string") return false;
|
||||
const n = normalizeMoneyAmountString(value);
|
||||
if (!n) return false;
|
||||
return /^\d+(\.\d+)?$/.test(n);
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return "Must be a non-negative amount (digits only, optional decimal).";
|
||||
}
|
||||
}
|
||||
|
||||
export function IsMoneyAmountString(validationOptions?: ValidationOptions) {
|
||||
return (object: object, propertyName: string) => {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsMoneyAmountStringConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
29
src/common/validators/objection-part-content.validator.ts
Normal file
29
src/common/validators/objection-part-content.validator.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
ValidationArguments,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "objectionPartHasContent", async: false })
|
||||
export class ObjectionPartHasContentConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(_value: unknown, args: ValidationArguments): boolean {
|
||||
const o = args.object as {
|
||||
reason?: string;
|
||||
partPrice?: string;
|
||||
partSalary?: string;
|
||||
};
|
||||
const hasReason =
|
||||
typeof o.reason === "string" && o.reason.trim().length >= 3;
|
||||
const hasPrice =
|
||||
o.partPrice != null && String(o.partPrice).trim() !== "";
|
||||
const hasSalary =
|
||||
o.partSalary != null && String(o.partSalary).trim() !== "";
|
||||
return hasReason || hasPrice || hasSalary;
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return "Each objection line needs a reason (min 3 characters) and/or proposed partPrice / partSalary.";
|
||||
}
|
||||
}
|
||||
66
src/common/validators/repair-line-amount-toman.validator.ts
Normal file
66
src/common/validators/repair-line-amount-toman.validator.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from "class-validator";
|
||||
import {
|
||||
REPAIR_LINE_AMOUNT_TOMAN,
|
||||
} from "src/constants/repair-amount-limits";
|
||||
import { parseMoneyAmountToman } from "src/utils/unicode-digits";
|
||||
|
||||
export type RepairLineAmountTomanOptions = {
|
||||
/** When true, `0` is valid (e.g. expert `price` / `salary` split where one side is zero). */
|
||||
allowZero?: boolean;
|
||||
};
|
||||
|
||||
@ValidatorConstraint({ name: "isRepairLineAmountToman", async: false })
|
||||
export class IsRepairLineAmountTomanConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
validate(value: unknown, args: ValidationArguments): boolean {
|
||||
const [minToman, maxToman, allowZero] = args.constraints as [
|
||||
number,
|
||||
number,
|
||||
boolean | undefined,
|
||||
];
|
||||
if (value == null || value === "") return true;
|
||||
const amount = parseMoneyAmountToman(value);
|
||||
if (amount == null) return false;
|
||||
if (allowZero && amount === 0) return true;
|
||||
return amount >= minToman && amount <= maxToman;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const [minToman, maxToman, allowZero] = args.constraints as [
|
||||
number,
|
||||
number,
|
||||
boolean | undefined,
|
||||
];
|
||||
const zeroHint = allowZero ? " Use 0 where one side of price/salary is unused." : "";
|
||||
return (
|
||||
`Amount must be in Toman between ${minToman.toLocaleString("en-US")} and ${maxToman.toLocaleString("en-US")}.${zeroHint}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional repair-line amount in Toman (digits only after unicode normalize). */
|
||||
export function IsRepairLineAmountToman(
|
||||
options?: ValidationOptions & RepairLineAmountTomanOptions,
|
||||
) {
|
||||
const { allowZero, ...validationOptions } = options ?? {};
|
||||
return (object: object, propertyName: string) => {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [
|
||||
REPAIR_LINE_AMOUNT_TOMAN.MIN,
|
||||
REPAIR_LINE_AMOUNT_TOMAN.MAX,
|
||||
allowZero === true,
|
||||
],
|
||||
validator: IsRepairLineAmountTomanConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
12
src/constants/repair-amount-limits.ts
Normal file
12
src/constants/repair-amount-limits.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Per-line and total caps for repair money. All values are **Toman** (no unit conversion in the API).
|
||||
*/
|
||||
export const REPAIR_LINE_AMOUNT_TOMAN = {
|
||||
/** Below this is not credible for a priced repair line (e.g. 1,000 Toman). */
|
||||
MIN: 10_000,
|
||||
/** Aligns with the total assessment cap; rejects absurd values (e.g. 100bn). */
|
||||
MAX: 53_000_000,
|
||||
} as const;
|
||||
|
||||
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
|
||||
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
|
||||
|
||||
export const CustomHeader = createParamDecorator(
|
||||
(data, ctx: ExecutionContext) => {
|
||||
console.log(ctx.switchToHttp().getRequest());
|
||||
console.log(ctx.getType());
|
||||
},
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
||||
|
||||
export class AllRequestDto {
|
||||
@@ -117,11 +118,35 @@ export class AllRequestDtoV2 {
|
||||
type: string;
|
||||
blameStatus: string;
|
||||
partiesInitialForms: { firstParty: string; secondParty: string };
|
||||
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,14 @@ export class ResendRequestDto {
|
||||
description: "Array of resend requests (can include first party, second party, or both)",
|
||||
example: [
|
||||
{
|
||||
partyId: "507f1f77bcf86cd799439011",
|
||||
requestedItems: ["drivingLicense", "carCertificate"],
|
||||
description: "Please resend with better quality",
|
||||
partyId: "681b3b3fb237e5856429e444",
|
||||
requestedItems: ["drivingLicense"],
|
||||
description: "DESC TEST1",
|
||||
},
|
||||
{
|
||||
partyId: "68b6ba552a6e897df34e0f90",
|
||||
requestedItems: ["carCertificate"],
|
||||
description: "DESC TEST2",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ApiParam,
|
||||
ApiProduces,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response, Request } from "express";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
@@ -45,30 +46,35 @@ export class ExpertBlameController {
|
||||
|
||||
// TODO role guard for expert fix
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get()
|
||||
async findAll(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.expertBlameService.findAll(actor);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get(":id")
|
||||
async findOne(@Param("id") id: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.findOne(id, actor.sub);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("lock/:id")
|
||||
async lockRequest(@Param("id") id: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.lockRequest(id, actor);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("request/accident-fields")
|
||||
async getAccidentFields() {
|
||||
return await this.expertBlameService.getAccidentField();
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/submit/:id")
|
||||
@ApiBody({ type: SubmitReplyDto })
|
||||
@ApiParam({ name: "id" })
|
||||
@@ -95,6 +101,7 @@ export class ExpertBlameController {
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/resend/first/:id")
|
||||
@ApiBody({ type: ResendFirstPartyDto })
|
||||
@ApiParam({ name: "id" })
|
||||
@@ -107,6 +114,7 @@ export class ExpertBlameController {
|
||||
return this.handleResendRequest(id, body, actor.sub, req);
|
||||
}
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/resend/second/:id")
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiBody({ type: ResendSecondPartyDto })
|
||||
@@ -120,6 +128,7 @@ export class ExpertBlameController {
|
||||
return this.handleResendRequest(id, body, actor.sub, req);
|
||||
}
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("stream/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
async streamVideo(@Param("requestId") requestId: string) {
|
||||
@@ -127,6 +136,7 @@ export class ExpertBlameController {
|
||||
}
|
||||
|
||||
@UseInterceptors(LoggingInterceptor)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("voice/:requestId/:voiceId")
|
||||
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
||||
@ApiParam({ name: "requestId" })
|
||||
@@ -149,6 +159,7 @@ export class ExpertBlameController {
|
||||
}
|
||||
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.inPersonVisit(requestId, actor);
|
||||
|
||||
@@ -6,9 +6,16 @@ import { ExpertBlameController } from "./expert-blame.controller";
|
||||
import { ExpertBlameV2Controller } from "./expert-blame.v2.controller";
|
||||
import { ExpertBlameService } from "./expert-blame.service";
|
||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, ClientModule, RequestManagementModule, PlatesModule],
|
||||
imports: [
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
RequestManagementModule,
|
||||
PlatesModule,
|
||||
SmsOrchestrationModule,
|
||||
],
|
||||
controllers: [ExpertBlameController, ExpertBlameV2Controller],
|
||||
providers: [ExpertBlameService],
|
||||
exports: [ExpertBlameService],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,15 +6,18 @@ import {
|
||||
InternalServerErrorException,
|
||||
Param,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiBody, ApiParam, ApiTags } from "@nestjs/swagger";
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, 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 { 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";
|
||||
|
||||
@@ -22,14 +25,22 @@ import { ResendRequestDto } from "./dto/resend.dto";
|
||||
@Controller("v2/expert-blame")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
|
||||
export class ExpertBlameV2Controller {
|
||||
constructor(private readonly expertBlameService: ExpertBlameService) {}
|
||||
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(
|
||||
@@ -38,11 +49,28 @@ export class ExpertBlameV2Controller {
|
||||
}
|
||||
}
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Count blame cases by grouped status bucket (this field expert)",
|
||||
description:
|
||||
"Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), workflow lock, expert decision, or expert-initiated blame. IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. Other keys match CaseStatus. Does not include the open WAITING_FOR_EXPERT queue unless this expert has touched the file.",
|
||||
})
|
||||
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||
try {
|
||||
return await this.expertBlameService.getStatusReportBucketsV2(actor);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to load blame status report",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||
async findOne(@Param("id") id: string, @CurrentUser() actor: any) {
|
||||
try {
|
||||
return await this.expertBlameService.findOneV2(id, actor.sub);
|
||||
return await this.expertBlameService.findOneV2(id, actor);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
@@ -73,7 +101,7 @@ export class ExpertBlameV2Controller {
|
||||
@CurrentUser() actor: any,
|
||||
) {
|
||||
try {
|
||||
return await this.expertBlameService.resendRequestV2(id, body, actor.sub);
|
||||
return await this.expertBlameService.resendRequestV2(id, body, actor);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
@@ -93,7 +121,7 @@ export class ExpertBlameV2Controller {
|
||||
@CurrentUser() actor: any,
|
||||
) {
|
||||
try {
|
||||
return await this.expertBlameService.replyRequestV2(id, body, actor.sub);
|
||||
return await this.expertBlameService.replyRequestV2(id, body, actor);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
@@ -101,4 +129,22 @@ export class ExpertBlameV2Controller {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Put("reply/inPerson/:id")
|
||||
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||
@ApiBody({ type: SubmitReplyDto })
|
||||
async submitInPerson(
|
||||
@Param("id") id: string,
|
||||
@Body() body: SubmitReplyDto,
|
||||
@CurrentUser() actor: any,
|
||||
) {
|
||||
try {
|
||||
return await this.expertBlameService.submitInPerson(id, actor.sub);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to submit expert reply for in person",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
|
||||
import { DamageSelectedPartV2BodyDto } from 'src/claim-request-management/dto/damage-selected-part-v2.dto';
|
||||
|
||||
export class ClaimDetailV2ResponseDto {
|
||||
@ApiProperty()
|
||||
@@ -27,6 +29,7 @@ export class ClaimDetailV2ResponseDto {
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
lockedAt: string;
|
||||
expiredAt?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@@ -43,14 +46,30 @@ export class ClaimDetailV2ResponseDto {
|
||||
plate?: any;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Blame file type from linked blame case',
|
||||
enum: BlameRequestType,
|
||||
})
|
||||
blameRequestType?: BlameRequestType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)',
|
||||
example: { car: true, object: false },
|
||||
})
|
||||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||
|
||||
@ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' })
|
||||
blameRequestId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' })
|
||||
blameRequestNo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Selected outer damaged parts' })
|
||||
selectedParts?: string[];
|
||||
@ApiPropertyOptional({
|
||||
description: 'Selected outer damaged parts (id, name, side, label_fa)',
|
||||
type: [DamageSelectedPartV2BodyDto],
|
||||
})
|
||||
selectedParts?: DamageSelectedPartV2BodyDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Selected other damaged parts' })
|
||||
otherParts?: string[];
|
||||
@@ -66,8 +85,125 @@ export class ClaimDetailV2ResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Car angles captured' })
|
||||
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Per-part captures (array index matches selectedParts; includes id, name, side, label_fa, captured, url)',
|
||||
type: 'array',
|
||||
items: {
|
||||
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' },
|
||||
label_fa: { type: 'string' },
|
||||
captured: { type: 'boolean' },
|
||||
url: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
damagedParts?: Array<{
|
||||
index: number;
|
||||
partId: number | null;
|
||||
id?: number | null;
|
||||
name: string;
|
||||
side: string;
|
||||
label_fa: string;
|
||||
captured: boolean;
|
||||
url?: string;
|
||||
}>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'True when user uploaded all required factors and the case awaits expert approve/reject.',
|
||||
})
|
||||
awaitingFactorValidation?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Slice of `claim.evaluation` exposed to the damage expert. ' +
|
||||
'`damageExpertReply` / `damageExpertReplyFinal` are returned only while ' +
|
||||
'awaiting factor validation. `ownerInsurerApproval` and ' +
|
||||
'`ownerPricedPartsApproval` are returned whenever they exist on the ' +
|
||||
'claim — each carries `signLink` (resolved from `signDetailId`) so the ' +
|
||||
'front-end can render the user signature directly. Likewise, ' +
|
||||
'`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` ' +
|
||||
'expose `signLink` when a user comment signature is present.',
|
||||
})
|
||||
evaluation?: {
|
||||
damageExpertReply?: unknown;
|
||||
damageExpertReplyFinal?: unknown;
|
||||
ownerInsurerApproval?: {
|
||||
agree: boolean;
|
||||
branchId?: string;
|
||||
signDetailId?: string;
|
||||
signLink?: string;
|
||||
signedAt?: Date | string;
|
||||
};
|
||||
ownerPricedPartsApproval?: {
|
||||
agree: boolean;
|
||||
branchId?: string;
|
||||
signDetailId?: string;
|
||||
signLink?: string;
|
||||
signedAt?: Date | string;
|
||||
};
|
||||
priceDrop?: {
|
||||
total?: number;
|
||||
carPrice?: number;
|
||||
carModel?: number;
|
||||
carValue?: number[];
|
||||
sumOfSeverity?: number;
|
||||
coefficientYear?: number;
|
||||
partLines?: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Owner objection to expert-priced parts (`evaluation.objection`): disputed lines + optional new parts + submission time.',
|
||||
})
|
||||
objection?: {
|
||||
objectionParts?: Array<Record<string, unknown>>;
|
||||
newParts?: Array<Record<string, unknown>>;
|
||||
submittedAt?: Date | string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).',
|
||||
})
|
||||
videoCapture?: {
|
||||
id: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.',
|
||||
})
|
||||
blameCase?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Same as `blameCase.expert.decision` when present: guilty party id, description, accident fields, decidedAt.',
|
||||
})
|
||||
blameExpertDecision?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Claim payout / banking metadata from the claim file (e.g. Sheba).',
|
||||
})
|
||||
money?: {
|
||||
sheba?: string;
|
||||
nationalCodeOfInsurer?: string;
|
||||
};
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
|
||||
|
||||
export class ClaimListItemV2Dto {
|
||||
@ApiProperty({ description: 'Claim case ID' })
|
||||
@@ -7,7 +8,11 @@ export class ClaimListItemV2Dto {
|
||||
@ApiProperty({ description: 'Public ID shared across blame+claim', example: 'A14235' })
|
||||
publicId: string;
|
||||
|
||||
@ApiProperty({ description: 'Overall case status', example: 'WAITING_FOR_DAMAGE_EXPERT' })
|
||||
@ApiProperty({
|
||||
description:
|
||||
"ClaimCaseStatus (expert list may normalize EXPERT_REVIEWING → WAITING_FOR_DAMAGE_EXPERT). Post-expert owner phase: INSURER_REVIEW_* / OWNER_REPAIR_FACTOR_UPLOAD_PENDING / EXPERT_VALIDATING_REPAIR_FACTORS; see expert reply & validate-factors docs.",
|
||||
example: "WAITING_FOR_DAMAGE_EXPERT",
|
||||
})
|
||||
status: string;
|
||||
|
||||
@ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' })
|
||||
@@ -16,10 +21,12 @@ export class ClaimListItemV2Dto {
|
||||
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
|
||||
locked: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Actor who locked this claim' })
|
||||
@ApiPropertyOptional({ description: 'Actor who locked this claim (only while lock is active)' })
|
||||
lockedBy?: {
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
lockedAt?: string;
|
||||
expiredAt?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
|
||||
@@ -29,14 +36,43 @@ export class ClaimListItemV2Dto {
|
||||
carType?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Blame file type from linked blame case',
|
||||
enum: BlameRequestType,
|
||||
example: BlameRequestType.THIRD_PARTY,
|
||||
})
|
||||
blameRequestType?: BlameRequestType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'CAR_BODY only: first-step flags — accident involved another car (`car`) and/or fixed object (`object`)',
|
||||
example: { car: true, object: false },
|
||||
})
|
||||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||
|
||||
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
|
||||
createdAt: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"True in the expert repair-factor validation queue: `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `currentStep=EXPERT_COST_EVALUATION`.",
|
||||
})
|
||||
awaitingFactorValidation?: boolean;
|
||||
}
|
||||
|
||||
export class GetClaimListV2ResponseDto {
|
||||
@ApiProperty({ type: [ClaimListItemV2Dto] })
|
||||
list: ClaimListItemV2Dto[];
|
||||
|
||||
@ApiProperty({ description: 'Total count' })
|
||||
@ApiProperty({ description: 'Total count after search filter' })
|
||||
total: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Current page when paginating' })
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Page size when paginating' })
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total pages when paginating' })
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
127
src/expert-claim/dto/claim-price-drop-v2.dto.ts
Normal file
127
src/expert-claim/dto/claim-price-drop-v2.dto.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import type { PriceDropSeverity } from "src/helpers/claim-price-drop";
|
||||
|
||||
export class PriceDropPartSeverityV2Dto {
|
||||
@ApiProperty({
|
||||
example: 12,
|
||||
description: "From claim detail `damagedParts[].partId`",
|
||||
})
|
||||
@IsInt()
|
||||
partId: number;
|
||||
|
||||
@ApiProperty({
|
||||
enum: ["Minor", "Moderate", "Severe"],
|
||||
description: "Severity: جزئی / متوسط / شدید",
|
||||
})
|
||||
@IsIn(["Minor", "Moderate", "Severe"])
|
||||
severity: PriceDropSeverity;
|
||||
}
|
||||
|
||||
export class UpsertClaimPriceDropV2Dto {
|
||||
@ApiPropertyOptional({ example: "پژو 206" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
carName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "پژو 206 تیپ 5",
|
||||
description: "Display model string stored on `claim.vehicle.carModel`",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
carModel?: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 450000000,
|
||||
description: "Market price of the car (Toman)",
|
||||
})
|
||||
carPrice: number | string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 1394,
|
||||
description:
|
||||
"Jalali production year (coefficient). Defaults from linked blame plate inquiry when omitted.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
carModelYear?: number;
|
||||
|
||||
@ApiProperty({
|
||||
type: [PriceDropPartSeverityV2Dto],
|
||||
description:
|
||||
"One row per damaged part on the claim; coefficients come from the price-drop table × severity.",
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PriceDropPartSeverityV2Dto)
|
||||
partSeverities: PriceDropPartSeverityV2Dto[];
|
||||
}
|
||||
|
||||
export class ClaimPriceDropContextV2Dto {
|
||||
@ApiProperty()
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
vehicle?: {
|
||||
carName?: string;
|
||||
carModel?: string;
|
||||
carType?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Jalali year from blame plate inquiry (ModelField / ModelCii)",
|
||||
})
|
||||
suggestedCarModelYear?: number | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
priceDrop?: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
label_fa: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
severityOptions: Array<{ value: string; label_fa: string }>;
|
||||
|
||||
@ApiProperty({ description: "Full coefficient table for all price-drop parts" })
|
||||
priceDropCatalog: Array<Record<string, unknown>>;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Claim damaged parts with optional price-drop key mapping",
|
||||
})
|
||||
damagedParts: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export class ClaimPriceDropResultV2Dto {
|
||||
@ApiProperty()
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiProperty()
|
||||
vehicle: {
|
||||
carName?: string;
|
||||
carModel?: string;
|
||||
carType?: string;
|
||||
};
|
||||
|
||||
@ApiProperty()
|
||||
priceDrop: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({ type: "array" })
|
||||
partLines: Array<Record<string, unknown>>;
|
||||
}
|
||||
@@ -6,52 +6,85 @@ import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
ValidateNested,
|
||||
IsEnum,
|
||||
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';
|
||||
export class DaghiDetailsV2Dto {
|
||||
@ApiProperty({
|
||||
enum: DaghiOption,
|
||||
description:
|
||||
'Daghi option: ارزش لوازم بازیافتی, تحویل داغی, فاقد ارزش, با احتساب داغی',
|
||||
})
|
||||
@IsEnum(DaghiOption)
|
||||
option: DaghiOption;
|
||||
|
||||
export class CarPartDamageV2Dto {
|
||||
@ApiProperty({ example: 'left' })
|
||||
@ApiPropertyOptional({
|
||||
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}' (Toman)`,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
side: string;
|
||||
@IsRepairLineAmountToman()
|
||||
price?: string;
|
||||
|
||||
@ApiProperty({ example: 'front_door' })
|
||||
@ApiPropertyOptional({
|
||||
description: `Required when option is '${DaghiOption.DELIVER_DAMAGED_PART}' (Mongo ObjectId string)`,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
part: string;
|
||||
branchId?: string;
|
||||
}
|
||||
|
||||
export class PartPricingV2Dto {
|
||||
@ApiProperty({ example: 'part-001' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
partId: string;
|
||||
|
||||
@ApiProperty({ type: CarPartDamageV2Dto })
|
||||
@ValidateNested()
|
||||
@Type(() => CarPartDamageV2Dto)
|
||||
carPartDamage: CarPartDamageV2Dto;
|
||||
@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;
|
||||
|
||||
@ApiPropertyOptional({ example: '500000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
daghi?: string;
|
||||
@ApiProperty({ type: DaghiDetailsV2Dto })
|
||||
@ValidateNested()
|
||||
@Type(() => DaghiDetailsV2Dto)
|
||||
daghi: DaghiDetailsV2Dto;
|
||||
|
||||
@ApiProperty({ example: false })
|
||||
@ApiProperty({
|
||||
example: false,
|
||||
description:
|
||||
"If true, the owner must upload a repair factor image for this line; expert confirms cost later (validate-factors). Priced lines omit this or set false.",
|
||||
})
|
||||
@IsBoolean()
|
||||
factorNeeded: boolean;
|
||||
}
|
||||
@@ -62,9 +95,41 @@ export class SubmitExpertReplyV2Dto {
|
||||
@IsNotEmpty()
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ type: [PartPricingV2Dto] })
|
||||
@ApiProperty({
|
||||
type: [PartPricingV2Dto],
|
||||
description:
|
||||
"One line per damaged part (`partId` from claim detail `damagedParts[]`), plus pricing, `daghi`, and `factorNeeded`.",
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PartPricingV2Dto)
|
||||
parts: PartPricingV2Dto[];
|
||||
}
|
||||
|
||||
export class ClaimSubmitResendV2Dto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Instructions for the owner. At least one of description, resendDocuments, or resendCarParts must be non-empty.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
resendDescription?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
enum: ClaimRequiredDocumentType,
|
||||
description:
|
||||
"Document keys to re-upload; each value must be a ClaimRequiredDocumentType string (e.g. national_card).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEnum(ClaimRequiredDocumentType, { each: true })
|
||||
resendDocuments?: ClaimRequiredDocumentType[];
|
||||
|
||||
@ApiPropertyOptional({ type: [DamagedPartItem] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DamagedPartItem)
|
||||
resendCarParts?: DamagedPartItem[];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
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 {
|
||||
@@ -31,3 +33,54 @@ export class FactorValidationDto {
|
||||
@Type(() => FactorDecisionDto)
|
||||
decisions: FactorDecisionDto[];
|
||||
}
|
||||
|
||||
/** V2 ClaimCase: expert confirms or overrides part pricing when validating uploaded factors. */
|
||||
class FactorDecisionV2Dto {
|
||||
@ApiProperty({ example: 201 })
|
||||
@IsInt()
|
||||
partId: number;
|
||||
|
||||
@ApiProperty({ enum: [FactorStatus.APPROVED, FactorStatus.REJECTED] })
|
||||
@IsEnum([FactorStatus.APPROVED, FactorStatus.REJECTED])
|
||||
status: FactorStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
rejectionReason?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"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 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 in Toman; or send both price and salary.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsRepairLineAmountToman()
|
||||
totalPayment?: string;
|
||||
}
|
||||
|
||||
export class FactorValidationV2Dto {
|
||||
@ApiProperty({ type: [FactorDecisionV2Dto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FactorDecisionV2Dto)
|
||||
decisions: FactorDecisionV2Dto[];
|
||||
}
|
||||
|
||||
20
src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts
Normal file
20
src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { DamageSelectedPartV2BodyDto } from "src/claim-request-management/dto/damage-selected-part-v2.dto";
|
||||
|
||||
export class UpdateClaimDamagedPartsV2Dto {
|
||||
@ApiProperty({
|
||||
type: [DamageSelectedPartV2BodyDto],
|
||||
description: "Full replacement list of selected damaged outer parts (id, name, side, label_fa)",
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DamageSelectedPartV2BodyDto)
|
||||
selectedParts: DamageSelectedPartV2BodyDto[];
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
@@ -40,11 +41,13 @@ import { FactorValidationDto } from "./dto/factor-validation.dto";
|
||||
export class ExpertClaimController {
|
||||
constructor(private readonly expertClaimService: ExpertClaimService) {}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get()
|
||||
async getAllClaimRequests(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.expertClaimService.getClaimRequestsListForExpert(actor);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiQuery({
|
||||
@@ -64,12 +67,14 @@ export class ExpertClaimController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("imageRequired/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async getImageRequired(@Param("id") id: string, @CurrentUser() currentUser) {
|
||||
return await this.expertClaimService.imageRequired(id, currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("required-documents/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async getRequiredDocuments(
|
||||
@@ -79,11 +84,13 @@ export class ExpertClaimController {
|
||||
return await this.expertClaimService.getRequiredDocuments(id, currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("lock/:id")
|
||||
async lockClaimRequest(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertClaimService.lockClaimRequest(requestId, actor);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/submit/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async submitReplyRequest(
|
||||
@@ -98,6 +105,7 @@ export class ExpertClaimController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("resend/submit/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async submitResend(
|
||||
@@ -117,6 +125,7 @@ export class ExpertClaimController {
|
||||
enum: ["car-capture", "accident"],
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("stream/:id/video")
|
||||
@Header("Accept-Ranges", "bytes")
|
||||
@Header("Content-Type", "video/mp4")
|
||||
@@ -134,6 +143,7 @@ export class ExpertClaimController {
|
||||
enum: ["car-capture", "accident"],
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("link/:id/video")
|
||||
@ApiParam({ name: "id" })
|
||||
async getClaimVideoLink(
|
||||
@@ -143,12 +153,14 @@ export class ExpertClaimController {
|
||||
return this.expertClaimService.getVideoLink(id, query);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("stream/:id/voice")
|
||||
@ApiParam({ name: "id" })
|
||||
async getClaimVoiceLink(@Param("id") id: string) {
|
||||
return await this.expertClaimService.getVoiceLink(id);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("validate-factors/:claimId")
|
||||
@ApiParam({ name: "claimId" })
|
||||
@ApiBody({ type: FactorValidationDto })
|
||||
@@ -166,11 +178,13 @@ export class ExpertClaimController {
|
||||
}
|
||||
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertClaimService.inPersonVisit(requestId, actor);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("branches/:insuranceId")
|
||||
@ApiParam({ name: "insuranceId" })
|
||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ClientModule } from "src/client/client.module";
|
||||
import { ExpertBlameModule } from "src/expert-blame/expert-blame.module";
|
||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||
import { SandHubModule } from "src/sand-hub/sand-hub.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { ExpertClaimController } from "./expert-claim.controller";
|
||||
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
|
||||
@@ -31,6 +32,7 @@ import { ExpertClaimService } from "./expert-claim.service";
|
||||
ExpertBlameModule,
|
||||
AiModule,
|
||||
RequestManagementModule,
|
||||
SmsOrchestrationModule,
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
import { Body, Controller, Get, Param, Patch, Put, UseGuards } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Header, Param, Headers, Patch, Put, UseGuards, Query, Res } from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiPropertyOptional,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
@@ -14,7 +16,19 @@ 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 { SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
|
||||
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";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
|
||||
class InPersonVisitV2Dto {
|
||||
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
|
||||
@@ -29,32 +43,121 @@ class InPersonVisitV2Dto {
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.DAMAGE_EXPERT)
|
||||
export class ExpertClaimV2Controller {
|
||||
constructor(private readonly expertClaimService: ExpertClaimService) {}
|
||||
constructor(
|
||||
private readonly expertClaimService: ExpertClaimService,
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
) {}
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Count claim cases by grouped status bucket (this damage expert)",
|
||||
description:
|
||||
"Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), current workflow lock, or a prior damageExpertReply / damageExpertReplyFinal by this expert. IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Does not include the open tenant queue unless this expert has touched the file.",
|
||||
})
|
||||
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||
return await this.expertClaimService.getStatusReportBucketsV2(actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same catalog as `GET v2/claim-request-management/outer-parts-catalog` so factor/resend payloads use identical part ids.
|
||||
*/
|
||||
@Get("outer-parts-catalog")
|
||||
@ApiOperation({
|
||||
summary: "Get outer parts catalog (V2)",
|
||||
description:
|
||||
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Outer parts catalog",
|
||||
type: [OuterPartCatalogItemDto],
|
||||
})
|
||||
async getOuterPartsCatalog(
|
||||
@Query("carType") carType?: ClaimVehicleTypeV2,
|
||||
): Promise<OuterPartCatalogItemDto[]> {
|
||||
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, plus this expert's own locked/in-progress claims.",
|
||||
"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.sub);
|
||||
@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 status, and damage selections. Claim must have status WAITING_FOR_DAMAGE_EXPERT. If locked, only the locking expert can access it.",
|
||||
"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(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.getClaimDetailV2(
|
||||
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.sub,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +165,10 @@ export class ExpertClaimV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Lock a claim request for review",
|
||||
description:
|
||||
"Claim must have status WAITING_FOR_DAMAGE_EXPERT. Locking sets status to EXPERT_REVIEWING and claimStatus to UNDER_REVIEW. Only one expert can lock a claim at a time.",
|
||||
"Lockable in two queues:\n" +
|
||||
"1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" +
|
||||
"2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" +
|
||||
"Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async lockClaimRequestV2(
|
||||
@@ -76,7 +182,13 @@ export class ExpertClaimV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Submit expert damage assessment reply",
|
||||
description:
|
||||
"Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.",
|
||||
"**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" +
|
||||
"- **No factors:** **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW`, `nextStep=CLAIM_COMPLETED` → owner final sign/reject only.\n\n" +
|
||||
"**Legacy rows** may still use `WAITING_FOR_INSURER_APPROVAL` instead of the specific values above.\n\n" +
|
||||
"**After the owner fulfilled an expert resend** (`damageExpertResend.fulfilledAt`), this expert **cannot** initiate **another resend**—use **reply/submit**, in-person visit, or **validate-factors** as appropriate.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: SubmitExpertReplyV2Dto })
|
||||
@@ -88,6 +200,24 @@ export class ExpertClaimV2Controller {
|
||||
return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor);
|
||||
}
|
||||
|
||||
@Put("reply/resend/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Submit expert damage resend request",
|
||||
description:
|
||||
"Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " +
|
||||
"Owner completes via document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.\n\n" +
|
||||
"**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **400**—the expert may only submit a priced reply or request in-person visit afterward.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: ClaimSubmitResendV2Dto })
|
||||
async submitExpertResendV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: ClaimSubmitResendV2Dto,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.submitResendDocsV2(claimRequestId, body, actor);
|
||||
}
|
||||
|
||||
@Patch(":claimRequestId/visit")
|
||||
@ApiOperation({
|
||||
summary: "Request in-person visit for claim",
|
||||
@@ -107,4 +237,68 @@ export class ExpertClaimV2Controller {
|
||||
body?.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("validate-factors/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Validate uploaded repair factors (V2 ClaimCase)",
|
||||
description:
|
||||
"**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 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" +
|
||||
"- **Partial batch:** returns pending until every factor line has a non-pending decision (no cap error until the batch is complete).",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: FactorValidationV2Dto })
|
||||
async validateClaimFactorsV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: FactorValidationV2Dto,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.validateClaimFactorsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("request/:claimRequestId/damaged-parts")
|
||||
@ApiOperation({
|
||||
summary: "Edit selected damaged parts (V2)",
|
||||
description:
|
||||
"Damage expert can modify selected damaged parts while claim is in EXPERT_REVIEWING and locked by the same expert.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: UpdateClaimDamagedPartsV2Dto })
|
||||
async updateClaimDamagedPartsV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UpdateClaimDamagedPartsV2Dto,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.updateClaimDamagedPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiQuery({
|
||||
name: "query",
|
||||
enum: ["car-capture", "accident"],
|
||||
required: true,
|
||||
})
|
||||
@Get("stream/:id/video")
|
||||
@Header("Accept-Ranges", "bytes")
|
||||
@Header("Content-Type", "video/mp4")
|
||||
async claimStream(
|
||||
@Headers() headers,
|
||||
@Param("id") id: string,
|
||||
@Query("query") query: "car-capture" | "accident",
|
||||
@Res() res,
|
||||
) {
|
||||
return this.expertClaimService.streamServiceV2(id, query, res, headers);
|
||||
}
|
||||
}
|
||||
|
||||
83
src/expert-insurer/dto/create-insurer-expert.dto.ts
Normal file
83
src/expert-insurer/dto/create-insurer-expert.dto.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEmail, IsEnum, IsMongoId, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { UserType } from "src/Types&Enums/userType.enum";
|
||||
|
||||
export class CreateInsurerExpertDto {
|
||||
@ApiProperty({ example: "Ali" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ example: "Ahmadi" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: "1234567890" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
nationalCode: string;
|
||||
|
||||
@ApiProperty({ example: "expert@example.com" })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: "StrongPass!123" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: "09121234567" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
mobile: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "665f0e5ab74d670939b08920",
|
||||
description: "Branch id this expert belongs to",
|
||||
})
|
||||
@IsMongoId()
|
||||
branchId: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: UserType, example: UserType.LEGAL })
|
||||
@IsOptional()
|
||||
@IsEnum(UserType)
|
||||
userType?: UserType;
|
||||
|
||||
@ApiPropertyOptional({ example: "02112345678" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "Tehran" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "Tehran" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "No. 12, Sample St." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export class CreateBlameExpertByInsurerDto extends CreateInsurerExpertDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: [RoleEnum.EXPERT],
|
||||
default: RoleEnum.EXPERT,
|
||||
})
|
||||
role?: RoleEnum.EXPERT;
|
||||
}
|
||||
|
||||
export class CreateClaimExpertByInsurerDto extends CreateInsurerExpertDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: [RoleEnum.DAMAGE_EXPERT],
|
||||
default: RoleEnum.DAMAGE_EXPERT,
|
||||
})
|
||||
role?: RoleEnum.DAMAGE_EXPERT;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
@@ -26,6 +27,10 @@ import { FileRating } from "src/request-management/entities/schema/request-manag
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertInsurerService } from "./expert-insurer.service";
|
||||
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||
import {
|
||||
CreateBlameExpertByInsurerDto,
|
||||
CreateClaimExpertByInsurerDto,
|
||||
} from "./dto/create-insurer-expert.dto";
|
||||
|
||||
@Controller("expert-insurer")
|
||||
@ApiTags("expert-insurer-panel")
|
||||
@@ -35,16 +40,105 @@ import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||
export class ExpertInsurerController {
|
||||
constructor(private readonly expertInsurerService: ExpertInsurerService) {}
|
||||
|
||||
@Get("files")
|
||||
async getAllFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.retrieveAllFilesOfClient(
|
||||
@Get("branches")
|
||||
@ApiQuery({ name: "search", required: false, type: String })
|
||||
@ApiQuery({
|
||||
name: "from",
|
||||
required: false,
|
||||
description: "Optional start datetime (ISO string)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "to",
|
||||
required: false,
|
||||
description: "Optional end datetime (ISO string)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "isActive",
|
||||
required: false,
|
||||
description: "Filter active state (true/false)",
|
||||
})
|
||||
async getInsuranceBranches(
|
||||
@CurrentUser() insurer,
|
||||
@Query("search") search?: string,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
@Query("isActive") isActive?: string,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||
insurer.clientKey,
|
||||
{ search, from, to, isActive },
|
||||
);
|
||||
}
|
||||
|
||||
@Post("branches")
|
||||
@ApiBody({ type: CreateBranchDto })
|
||||
async addBranch(
|
||||
@CurrentUser() insurer,
|
||||
@Body() createBranchDto: CreateBranchDto,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.addBranch(
|
||||
insurer.clientKey,
|
||||
createBranchDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Put("branches/:branchId/status")
|
||||
@ApiParam({ name: "branchId" })
|
||||
@ApiQuery({ name: "isActive", type: Boolean })
|
||||
async setBranchStatus(
|
||||
@CurrentUser() insurer,
|
||||
@Param("branchId") branchId: string,
|
||||
@Query("isActive") isActive: string,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
const normalized = String(isActive).trim().toLowerCase();
|
||||
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) {
|
||||
throw new BadRequestException("isActive must be true/false");
|
||||
}
|
||||
const active = ["true", "1", "yes"].includes(normalized);
|
||||
return this.expertInsurerService.setBranchActive(
|
||||
insurer.clientKey,
|
||||
branchId,
|
||||
active,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("experts/blame")
|
||||
@ApiBody({ type: CreateBlameExpertByInsurerDto })
|
||||
async addBlameExpert(
|
||||
@CurrentUser() insurer,
|
||||
@Body() body: CreateBlameExpertByInsurerDto,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return this.expertInsurerService.addBlameExpert(insurer.clientKey, body);
|
||||
}
|
||||
|
||||
@Post("experts/claim")
|
||||
@ApiBody({ type: CreateClaimExpertByInsurerDto })
|
||||
async addClaimExpert(
|
||||
@CurrentUser() insurer,
|
||||
@Body() body: CreateClaimExpertByInsurerDto,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
|
||||
}
|
||||
|
||||
@ApiQuery({ name: "page", type: Number })
|
||||
@ApiQuery({ name: "response_count", type: Number })
|
||||
@Get("list")
|
||||
@Get("experts/list")
|
||||
async getAllExperts(
|
||||
@Query("page") page: number,
|
||||
@Query("response_count") count: number,
|
||||
@@ -57,43 +151,38 @@ export class ExpertInsurerController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("top-experts")
|
||||
@Get("experts/top")
|
||||
@ApiOperation({
|
||||
summary: "Top blame vs claim experts for this insurer",
|
||||
description:
|
||||
"Response has two arrays: `blameExperts` (roster from expert / blame files) and `claimExperts` (damage-expert roster / claim files). Each item includes `overallAverageRating` derived from ratings stored on those files plus user ratings where present.",
|
||||
})
|
||||
async getTopExperts(@CurrentUser() actor) {
|
||||
return await this.expertInsurerService.getTopExpertsForClient(actor);
|
||||
}
|
||||
|
||||
@Get("top-files")
|
||||
async getTopFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.getTopFilesForClient(
|
||||
@Get("files")
|
||||
async getAllFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.retrieveAllFilesOfClient(
|
||||
insurer.clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("statistics")
|
||||
async getExpertStatistics(@CurrentUser() actor) {
|
||||
return await this.expertInsurerService.getExpertStatisticsReport(actor);
|
||||
}
|
||||
|
||||
@ApiParam({ name: "expertId" })
|
||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
||||
@Get("/:expertId")
|
||||
async requestDetail(
|
||||
@Param("expertId") id: string,
|
||||
@Query("role") role: "claim" | "blame",
|
||||
@Get("files/:publicId")
|
||||
@ApiParam({ name: "publicId" })
|
||||
async getFileDetailsByPublicId(
|
||||
@CurrentUser() insurer,
|
||||
@Param("publicId") publicId: string,
|
||||
) {
|
||||
if (!Types.ObjectId.isValid(new Types.ObjectId(id))) {
|
||||
throw new BadRequestException("Invalid expert ID");
|
||||
}
|
||||
|
||||
if (!["claim", "blame"].includes(role)) {
|
||||
throw new BadRequestException("Invalid role");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.getAllFilesByExpertAndRole(id, role);
|
||||
return await this.expertInsurerService.retrieveFileDetailsByPublicId(
|
||||
insurer.clientKey,
|
||||
publicId,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
description: "Detailed expert rating",
|
||||
description:
|
||||
"One insurer rating for the shared publicId; persisted on claim and/or blame case documents when both exist.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -130,7 +219,7 @@ export class ExpertInsurerController {
|
||||
minimum: 0,
|
||||
maximum: 5,
|
||||
example: 4,
|
||||
description: "برای امتیاز دادن به بات",
|
||||
description: "برای امتیاز دادن به عملکرد بات",
|
||||
},
|
||||
},
|
||||
required: [
|
||||
@@ -149,47 +238,70 @@ export class ExpertInsurerController {
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
||||
@Put("/:requestId/rating")
|
||||
async rateExperts(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() rating: FileRating,
|
||||
@Query("role") role: "claim" | "blame",
|
||||
) {
|
||||
if (!["claim", "blame"].includes(role)) {
|
||||
throw new BadRequestException("Invalid role");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.rateExpertOnFile(
|
||||
requestId,
|
||||
rating,
|
||||
role,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("branches/:insuranceId")
|
||||
@ApiParam({ name: "insuranceId" })
|
||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
console.log("insuranceId", insuranceId);
|
||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||
insuranceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("branches")
|
||||
@ApiBody({ type: CreateBranchDto })
|
||||
async addBranch(
|
||||
@ApiParam({ name: "publicId" })
|
||||
@Put("files/:publicId/rating")
|
||||
async rateExpertsByPublicId(
|
||||
@CurrentUser() insurer,
|
||||
@Body() createBranchDto: CreateBranchDto,
|
||||
@Param("publicId") publicId: string,
|
||||
@Body() rating: FileRating,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
return await this.expertInsurerService.rateExpertByPublicId(
|
||||
publicId,
|
||||
rating,
|
||||
insurer.clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.addBranch(
|
||||
@Get("top-files")
|
||||
async getTopFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.getTopFilesForClient(
|
||||
insurer.clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("statistics")
|
||||
async getExpertStatistics(@CurrentUser() actor) {
|
||||
return await this.expertInsurerService.getExpertStatisticsReport(actor);
|
||||
}
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiQuery({
|
||||
name: "from",
|
||||
required: false,
|
||||
description: "Optional start datetime (ISO string)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "to",
|
||||
required: false,
|
||||
description: "Optional end datetime (ISO string)",
|
||||
})
|
||||
async getInsurerStatusReport(
|
||||
@CurrentUser() actor,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
) {
|
||||
return await this.expertInsurerService.getInsurerFileStatusCounts(
|
||||
actor,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiParam({ name: "expertId" })
|
||||
@ApiOperation({
|
||||
summary: "Files handled by one roster expert (summary rows)",
|
||||
description:
|
||||
"Resolves id against this insurer's `expert` then `damage-expert` roster. Blame branch runs when the id is on the expert roster (field experts). Each item is a small summary (`kind`, ids, statuses, dates)—not full file payloads. Claim matches use final or draft damage-expert reply.",
|
||||
})
|
||||
@Get("/:expertId")
|
||||
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
|
||||
if (!Types.ObjectId.isValid(id)) {
|
||||
throw new BadRequestException("Invalid expert ID");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.getAllFilesForInsurerExpert(
|
||||
id,
|
||||
insurer.clientKey,
|
||||
createBranchDto,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
BranchModel,
|
||||
BranchSchema,
|
||||
} from "src/client/entities/schema/branch.schema";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClaimRequestManagementModule,
|
||||
RequestManagementModule,
|
||||
AuthModule,
|
||||
HashModule,
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
MongooseModule.forFeature([
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
export enum PanelModels {
|
||||
BLAME = "blame",
|
||||
CLAIM = "claim",
|
||||
}
|
||||
149
src/helpers/blame-party-agreement-decision.ts
Normal file
149
src/helpers/blame-party-agreement-decision.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Types } from "mongoose";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
|
||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||
|
||||
/** Default accident taxonomy when guilt is established by mutual party agreement (no field-expert decision). */
|
||||
export const MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS = {
|
||||
accidentWay: { id: 8, label: "جلو به عقب" },
|
||||
accidentReason: {
|
||||
id: 1,
|
||||
fanavaran: 1,
|
||||
label:
|
||||
"عدم رعايت فاصله مناسب با وسيله نقليه و يا عدم توجه به جلو",
|
||||
},
|
||||
accidentType: {
|
||||
id: 1,
|
||||
label: "برخورد یک وسیله نقلیه با یک وسیله نقلیه",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type MutualAgreementDecisionPayload = {
|
||||
guiltyPartyId: Types.ObjectId;
|
||||
description: string;
|
||||
decidedAt: Date;
|
||||
decidedByExpertId?: Types.ObjectId;
|
||||
fields: typeof MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS;
|
||||
};
|
||||
|
||||
function partyPhone(p: { person?: { phoneNumber?: string } } | undefined): string {
|
||||
return (p?.person?.phoneNumber || "").trim();
|
||||
}
|
||||
|
||||
function partyUserId(
|
||||
p: { person?: { userId?: Types.ObjectId } } | undefined,
|
||||
): Types.ObjectId | null {
|
||||
const id = p?.person?.userId;
|
||||
if (!id) return null;
|
||||
return id instanceof Types.ObjectId ? id : new Types.ObjectId(String(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* When the first party chose AGREED (guilty or damaged) and the second party exists,
|
||||
* derive the guilty party and build the synthetic expert.decision payload.
|
||||
* Does not set decidedByExpertId — this is not a field-expert adjudication.
|
||||
*/
|
||||
export function buildMutualAgreementExpertDecision(
|
||||
doc: Record<string, unknown>,
|
||||
): MutualAgreementDecisionPayload | null {
|
||||
if (doc.type !== BlameRequestType.THIRD_PARTY) return null;
|
||||
if (doc.blameStatus !== BlameStatus.AGREED) return null;
|
||||
|
||||
const parties = (doc.parties ?? []) as Array<{
|
||||
role?: string;
|
||||
person?: { userId?: Types.ObjectId; phoneNumber?: string };
|
||||
statement?: {
|
||||
admitsGuilt?: boolean;
|
||||
claimsDamage?: boolean;
|
||||
acceptsExpertOpinion?: boolean;
|
||||
};
|
||||
}>;
|
||||
|
||||
const first = parties.find((p) => p.role === PartyRole.FIRST);
|
||||
const second = parties.find((p) => p.role === PartyRole.SECOND);
|
||||
if (!first || !second) return null;
|
||||
|
||||
const st = first.statement;
|
||||
if (!st) return null;
|
||||
if (st.acceptsExpertOpinion) return null;
|
||||
|
||||
const admits = !!st.admitsGuilt;
|
||||
const claims = !!st.claimsDamage;
|
||||
if (admits === claims) return null;
|
||||
|
||||
let guiltyParty: typeof first;
|
||||
let damagedParty: typeof first;
|
||||
if (admits) {
|
||||
guiltyParty = first;
|
||||
damagedParty = second;
|
||||
} else {
|
||||
guiltyParty = second;
|
||||
damagedParty = first;
|
||||
}
|
||||
|
||||
const guiltyId = partyUserId(guiltyParty);
|
||||
if (!guiltyId) return null;
|
||||
|
||||
const gPhone = partyPhone(guiltyParty);
|
||||
const dPhone = partyPhone(damagedParty);
|
||||
if (!gPhone || !dPhone) return null;
|
||||
|
||||
const description = `با توافق طرفین مقصر و زیان دیده مشخص شد. کاربر ${gPhone} مقصر و کاربر ${dPhone} زیان دیده می باشد.`;
|
||||
|
||||
return {
|
||||
guiltyPartyId: guiltyId,
|
||||
description,
|
||||
decidedAt: new Date(),
|
||||
fields: MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge complementary statement flags onto the second party for expert/claim UIs when
|
||||
* only the first party submitted a formal confession (V2 remote flow).
|
||||
*/
|
||||
export function enrichBlamePartiesForAgreementView(
|
||||
doc: Record<string, unknown>,
|
||||
): Array<Record<string, unknown>> {
|
||||
const parties = (doc.parties ?? []) as Array<Record<string, unknown>>;
|
||||
if (!Array.isArray(parties) || parties.length === 0) return parties;
|
||||
|
||||
const first = parties.find((p) => p.role === PartyRole.FIRST) as
|
||||
| {
|
||||
statement?: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
const secondIdx = parties.findIndex((p) => p.role === PartyRole.SECOND);
|
||||
if (!first?.statement || secondIdx === -1) return parties;
|
||||
|
||||
if (doc.blameStatus !== BlameStatus.AGREED) return parties;
|
||||
|
||||
const st = first.statement as {
|
||||
admitsGuilt?: boolean;
|
||||
claimsDamage?: boolean;
|
||||
acceptsExpertOpinion?: boolean;
|
||||
};
|
||||
if (st.acceptsExpertOpinion) return parties;
|
||||
|
||||
const admits = !!st.admitsGuilt;
|
||||
const claims = !!st.claimsDamage;
|
||||
if (admits === claims) return parties;
|
||||
|
||||
const second = parties[secondIdx] as {
|
||||
statement?: Record<string, unknown>;
|
||||
};
|
||||
const prev = (second.statement ?? {}) as Record<string, unknown>;
|
||||
const mergedStatement = {
|
||||
...prev,
|
||||
admitsGuilt: admits ? false : true,
|
||||
claimsDamage: admits ? true : false,
|
||||
acceptsExpertOpinion: false,
|
||||
};
|
||||
|
||||
const next = [...parties];
|
||||
next[secondIdx] = {
|
||||
...second,
|
||||
statement: mergedStatement,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
95
src/helpers/claim-capture-phase.spec.ts
Normal file
95
src/helpers/claim-capture-phase.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
getClaimCaptureProgress,
|
||||
isClaimCaptureStepComplete,
|
||||
} from "./claim-capture-phase";
|
||||
|
||||
describe("claim-capture-phase", () => {
|
||||
const baseClaim = {
|
||||
vehicle: { carType: "sedan" },
|
||||
damage: {
|
||||
selectedParts: [{ name: "hood", side: "front", label_fa: "کاپوت" }],
|
||||
},
|
||||
media: { carAngles: {}, damagedParts: [] },
|
||||
requiredDocuments: {},
|
||||
};
|
||||
|
||||
it("starts in parts phase", () => {
|
||||
const p = getClaimCaptureProgress(baseClaim);
|
||||
expect(p.sequencePhase).toBe("parts");
|
||||
expect(p.partsComplete).toBe(false);
|
||||
});
|
||||
|
||||
it("moves to angles after all parts", () => {
|
||||
const claim = {
|
||||
...baseClaim,
|
||||
media: {
|
||||
carAngles: {},
|
||||
damagedParts: [
|
||||
{
|
||||
name: "hood",
|
||||
side: "front",
|
||||
label_fa: "کاپوت",
|
||||
path: "/x.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const p = getClaimCaptureProgress(claim);
|
||||
expect(p.sequencePhase).toBe("angles");
|
||||
expect(p.partsComplete).toBe(true);
|
||||
expect(p.anglesComplete).toBe(false);
|
||||
});
|
||||
|
||||
it("does not complete capture step with only capture-phase docs uploaded", () => {
|
||||
const claim = {
|
||||
...baseClaim,
|
||||
media: {
|
||||
carAngles: {},
|
||||
damagedParts: [
|
||||
{
|
||||
name: "hood",
|
||||
side: "front",
|
||||
label_fa: "کاپوت",
|
||||
path: "/x.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
requiredDocuments: {
|
||||
damaged_chassis_number: { uploaded: true },
|
||||
damaged_engine_photo: { uploaded: true },
|
||||
damaged_metal_plate: { uploaded: true },
|
||||
},
|
||||
};
|
||||
expect(isClaimCaptureStepComplete(claim)).toBe(false);
|
||||
expect(getClaimCaptureProgress(claim).sequencePhase).toBe("angles");
|
||||
});
|
||||
|
||||
it("is complete only when parts, angles, and capture-phase docs are done", () => {
|
||||
const claim = {
|
||||
...baseClaim,
|
||||
media: {
|
||||
carAngles: {
|
||||
front: { path: "/f.jpg" },
|
||||
back: { path: "/b.jpg" },
|
||||
left: { path: "/l.jpg" },
|
||||
right: { path: "/r.jpg" },
|
||||
},
|
||||
damagedParts: [
|
||||
{
|
||||
name: "hood",
|
||||
side: "front",
|
||||
label_fa: "کاپوت",
|
||||
path: "/x.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
requiredDocuments: {
|
||||
damaged_chassis_number: { uploaded: true },
|
||||
damaged_engine_photo: { uploaded: true },
|
||||
damaged_metal_plate: { uploaded: true },
|
||||
},
|
||||
};
|
||||
expect(isClaimCaptureStepComplete(claim)).toBe(true);
|
||||
expect(getClaimCaptureProgress(claim).sequencePhase).toBe("complete");
|
||||
});
|
||||
});
|
||||
127
src/helpers/claim-capture-phase.ts
Normal file
127
src/helpers/claim-capture-phase.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
CLAIM_CAR_ANGLE_KEYS,
|
||||
hasClaimCarAngleCapture,
|
||||
hasDamagedPartCapture,
|
||||
} from "src/helpers/claim-car-angle-media";
|
||||
import {
|
||||
catalogLikeKeyFromPart,
|
||||
normalizeDamageSelectedParts,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
import type { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
|
||||
/** Upload during CAPTURE_PART_DAMAGES (after parts + angles). */
|
||||
export const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [
|
||||
"damaged_chassis_number",
|
||||
"damaged_engine_photo",
|
||||
"damaged_metal_plate",
|
||||
] as const;
|
||||
|
||||
export type CapturePhaseSequence =
|
||||
| "parts"
|
||||
| "angles"
|
||||
| "capture_phase_documents"
|
||||
| "complete";
|
||||
|
||||
export interface ClaimCaptureProgress {
|
||||
partsCaptured: number;
|
||||
partsTotal: number;
|
||||
partsComplete: boolean;
|
||||
anglesCaptured: number;
|
||||
anglesTotal: number;
|
||||
anglesComplete: boolean;
|
||||
capturePhaseDocsComplete: boolean;
|
||||
capturePhaseDocsRemaining: number;
|
||||
sequencePhase: CapturePhaseSequence;
|
||||
}
|
||||
|
||||
export function isCapturePhaseDamagedPartyDocKey(key: string): boolean {
|
||||
return (CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS as readonly string[]).includes(
|
||||
key,
|
||||
);
|
||||
}
|
||||
|
||||
function isRequiredDocumentUploadedOnClaim(
|
||||
claimCase: any,
|
||||
key: string,
|
||||
): boolean {
|
||||
const doc =
|
||||
(claimCase?.requiredDocuments as any)?.get?.(key) ??
|
||||
claimCase?.requiredDocuments?.[key];
|
||||
return !!doc?.uploaded;
|
||||
}
|
||||
|
||||
export function getClaimCaptureProgress(
|
||||
claimCase: any,
|
||||
options?: { assumeCapturePhaseDocKey?: string },
|
||||
): ClaimCaptureProgress {
|
||||
const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
const selectedNorm = normalizeDamageSelectedParts(
|
||||
claimCase?.damage?.selectedParts,
|
||||
carType,
|
||||
claimCase?.damage?.selectedOuterParts,
|
||||
);
|
||||
const carAnglesData = claimCase?.media?.carAngles;
|
||||
const damagedPartsData = claimCase?.media?.damagedParts;
|
||||
|
||||
const anglesCaptured = CLAIM_CAR_ANGLE_KEYS.filter((k) =>
|
||||
hasClaimCarAngleCapture(carAnglesData, damagedPartsData, k),
|
||||
).length;
|
||||
const partsCaptured = selectedNorm.filter((sp) => {
|
||||
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
||||
return hasDamagedPartCapture(damagedPartsData, ck, selectedNorm);
|
||||
}).length;
|
||||
|
||||
const partsTotal = selectedNorm.length;
|
||||
const partsComplete = partsTotal === 0 || partsCaptured >= partsTotal;
|
||||
const anglesTotal = CLAIM_CAR_ANGLE_KEYS.length;
|
||||
const anglesComplete = anglesCaptured >= anglesTotal;
|
||||
|
||||
const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
|
||||
(k) => {
|
||||
if (k === options?.assumeCapturePhaseDocKey) return false;
|
||||
return !isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||||
},
|
||||
).length;
|
||||
const capturePhaseDocsComplete = capturePhaseDocsRemaining === 0;
|
||||
|
||||
let sequencePhase: CapturePhaseSequence = "parts";
|
||||
if (partsComplete && !anglesComplete) {
|
||||
sequencePhase = "angles";
|
||||
} else if (partsComplete && anglesComplete && !capturePhaseDocsComplete) {
|
||||
sequencePhase = "capture_phase_documents";
|
||||
} else if (partsComplete && anglesComplete && capturePhaseDocsComplete) {
|
||||
sequencePhase = "complete";
|
||||
}
|
||||
|
||||
return {
|
||||
partsCaptured,
|
||||
partsTotal,
|
||||
partsComplete,
|
||||
anglesCaptured,
|
||||
anglesTotal,
|
||||
anglesComplete,
|
||||
capturePhaseDocsComplete,
|
||||
capturePhaseDocsRemaining,
|
||||
sequencePhase,
|
||||
};
|
||||
}
|
||||
|
||||
export function isClaimCaptureStepComplete(claimCase: any): boolean {
|
||||
const p = getClaimCaptureProgress(claimCase);
|
||||
return (
|
||||
p.partsComplete && p.anglesComplete && p.capturePhaseDocsComplete
|
||||
);
|
||||
}
|
||||
|
||||
export function capturePhaseSequenceMessage(phase: CapturePhaseSequence): string {
|
||||
switch (phase) {
|
||||
case "parts":
|
||||
return "Upload photos for all selected damaged parts first.";
|
||||
case "angles":
|
||||
return "Capture all four car angles (front, back, left, right) next.";
|
||||
case "capture_phase_documents":
|
||||
return "Upload chassis number, engine photo, and damaged metal plate photos last.";
|
||||
default:
|
||||
return "Damage capture is complete.";
|
||||
}
|
||||
}
|
||||
181
src/helpers/claim-car-angle-media.ts
Normal file
181
src/helpers/claim-car-angle-media.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Car-angle captures must live under {@code media.carAngles} with lowercase keys
|
||||
* {@code front | back | left | right}. Clients sometimes send Title Case keys or
|
||||
* mistakenly use {@code captureType: "part"} for angles, which previously wrote
|
||||
* into {@code media.damagedParts} and broke step-completion checks.
|
||||
*/
|
||||
|
||||
import type { DamageSelectedPartV2 } from "src/helpers/outer-damage-parts";
|
||||
import {
|
||||
damageSelectedPartsToLookupStrings,
|
||||
mediaRowHasCapture,
|
||||
resolvePartCaptureIndex,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
|
||||
export const CLAIM_CAR_ANGLE_KEYS = ["front", "back", "left", "right"] as const;
|
||||
export type ClaimCarAngleKey = (typeof CLAIM_CAR_ANGLE_KEYS)[number];
|
||||
|
||||
export function canonicalizeClaimCarAngleKey(
|
||||
raw: string | undefined | null,
|
||||
): ClaimCarAngleKey | null {
|
||||
const t = String(raw ?? "").trim().toLowerCase();
|
||||
return (CLAIM_CAR_ANGLE_KEYS as readonly string[]).includes(t)
|
||||
? (t as ClaimCarAngleKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getCaptureBlob(data: unknown, key: string): unknown {
|
||||
if (data == null) return undefined;
|
||||
if (data instanceof Map) return (data as Map<string, unknown>).get(key);
|
||||
return (data as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
/** Prefer carAngles; fall back to legacy mis-placed blobs under damagedParts. */
|
||||
export function getClaimCarAngleCaptureBlob(
|
||||
carAnglesData: unknown,
|
||||
damagedPartsData: unknown,
|
||||
angle: ClaimCarAngleKey,
|
||||
): unknown {
|
||||
return (
|
||||
getCaptureBlob(carAnglesData, angle) ??
|
||||
getCaptureBlob(damagedPartsData, angle) ??
|
||||
getCaptureBlob(
|
||||
damagedPartsData,
|
||||
angle.charAt(0).toUpperCase() + angle.slice(1),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasClaimCarAngleCapture(
|
||||
carAnglesData: unknown,
|
||||
damagedPartsData: unknown,
|
||||
angle: ClaimCarAngleKey,
|
||||
): boolean {
|
||||
return getClaimCarAngleCaptureBlob(carAnglesData, damagedPartsData, angle) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field names under media.damagedParts that should be removed when we persist the
|
||||
* same shot under media.carAngles (migration). Skips keys that are also real selected part ids.
|
||||
*/
|
||||
export function legacyAngleDamagedPartFieldKeysToUnset(
|
||||
canonical: ClaimCarAngleKey,
|
||||
rawCaptureKey: string,
|
||||
selectedParts: unknown,
|
||||
carType?: import("src/static/outer-car-parts-catalog").ClaimVehicleTypeV2,
|
||||
selectedOuterPartsLegacy?: unknown,
|
||||
): string[] {
|
||||
const selected = new Set(
|
||||
damageSelectedPartsToLookupStrings(
|
||||
selectedParts,
|
||||
carType,
|
||||
selectedOuterPartsLegacy,
|
||||
),
|
||||
);
|
||||
const variants = new Set(
|
||||
[
|
||||
rawCaptureKey,
|
||||
canonical,
|
||||
canonical.charAt(0).toUpperCase() + canonical.slice(1),
|
||||
].filter(Boolean),
|
||||
);
|
||||
const out: string[] = [];
|
||||
for (const k of variants) {
|
||||
if (canonicalizeClaimCarAngleKey(k) === null) continue;
|
||||
if (selected.has(k)) continue;
|
||||
out.push(k);
|
||||
}
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
/** Same English title derived from snake_case keys as capture-requirements UI (legacy client storage). */
|
||||
export function legacyDamagedPartTitleEnFromPartKey(partKey: string): string {
|
||||
return String(partKey)
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/_/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function normalizeDamagedPartKeyLoose(s: string): string {
|
||||
return String(s).toLowerCase().replace(/[\s_-]/g, "");
|
||||
}
|
||||
|
||||
export function getDamagedPartCaptureBlob(
|
||||
damagedPartsData: unknown,
|
||||
partKey: string,
|
||||
selectedNormalized?: DamageSelectedPartV2[],
|
||||
): unknown {
|
||||
const pk = String(partKey ?? "").trim();
|
||||
if (!pk || damagedPartsData == null) return undefined;
|
||||
if (Array.isArray(damagedPartsData)) {
|
||||
const norm = selectedNormalized ?? [];
|
||||
const idx = resolvePartCaptureIndex(
|
||||
pk,
|
||||
norm,
|
||||
damagedPartsData as Record<string, unknown>[],
|
||||
);
|
||||
if (idx >= 0) {
|
||||
const row = (damagedPartsData as unknown[])[idx];
|
||||
if (mediaRowHasCapture(row)) return row;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
let b = getCaptureBlob(damagedPartsData, pk);
|
||||
if (b) return b;
|
||||
const titleEn = legacyDamagedPartTitleEnFromPartKey(pk);
|
||||
if (titleEn !== pk) {
|
||||
b = getCaptureBlob(damagedPartsData, titleEn);
|
||||
if (b) return b;
|
||||
}
|
||||
const target = normalizeDamagedPartKeyLoose(pk);
|
||||
const keys =
|
||||
damagedPartsData instanceof Map
|
||||
? Array.from((damagedPartsData as Map<string, unknown>).keys())
|
||||
: Object.keys(damagedPartsData as Record<string, unknown>);
|
||||
for (const k of keys) {
|
||||
if (normalizeDamagedPartKeyLoose(k) === target) {
|
||||
b = getCaptureBlob(damagedPartsData, k);
|
||||
if (b) return b;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a capture exists for catalog/selected part key {@code partKey}, including blobs stored
|
||||
* under humanized keys (e.g. {@code Left Backfender} vs {@code left_backfender}).
|
||||
*/
|
||||
export function hasDamagedPartCapture(
|
||||
damagedPartsData: unknown,
|
||||
partKey: string,
|
||||
selectedNormalized?: DamageSelectedPartV2[],
|
||||
): boolean {
|
||||
return (
|
||||
getDamagedPartCaptureBlob(damagedPartsData, partKey, selectedNormalized) !=
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a client {@code captureKey} to the canonical {@code damage.selectedParts} id when unambiguous.
|
||||
*/
|
||||
export function resolveCanonicalDamagedPartStorageKey(
|
||||
captureKeyRaw: string,
|
||||
selectedParts: unknown,
|
||||
carType?: import("src/static/outer-car-parts-catalog").ClaimVehicleTypeV2,
|
||||
selectedOuterPartsLegacy?: unknown,
|
||||
): string {
|
||||
const raw = String(captureKeyRaw ?? "").trim();
|
||||
if (!raw) return raw;
|
||||
const parts = damageSelectedPartsToLookupStrings(
|
||||
selectedParts,
|
||||
carType,
|
||||
selectedOuterPartsLegacy,
|
||||
);
|
||||
if (parts.includes(raw)) return raw;
|
||||
const n = normalizeDamagedPartKeyLoose(raw);
|
||||
const matches = parts.filter((p) => normalizeDamagedPartKeyLoose(p) === n);
|
||||
if (matches.length === 1) return matches[0];
|
||||
return raw;
|
||||
}
|
||||
341
src/helpers/claim-details-v2-owner-guidance.ts
Normal file
341
src/helpers/claim-details-v2-owner-guidance.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||
import {
|
||||
classifyV2ExpertPricingParts,
|
||||
claimCaseStatusAllowsOwnerInsurerSignStep,
|
||||
claimCaseStatusIsV2OwnerPostExpertPipeline,
|
||||
getActiveV2ExpertReply,
|
||||
objectionDisallowedDueToOutstandingFactorWorkflow,
|
||||
} from "src/helpers/claim-v2-expert-reply-workflow";
|
||||
|
||||
const BASE = "v2/claim-request-management";
|
||||
|
||||
/** One actionable next step for owner UI (`pathTemplate` embeds claim id; replace `{partId}` per line item). */
|
||||
export interface ClaimDetailsOwnerNextActionV2 {
|
||||
key: string;
|
||||
method?: string;
|
||||
pathTemplate?: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Derived hints so the client can route without re-implementing server rules (returned only for claim owners, not FIELD_EXPERT). */
|
||||
export interface ClaimDetailsOwnerGuidanceV2 {
|
||||
phaseKey: string;
|
||||
headline: string;
|
||||
detail?: string;
|
||||
nextActions: ClaimDetailsOwnerNextActionV2[];
|
||||
objectionAllowed?: boolean;
|
||||
objectionHint?: string;
|
||||
}
|
||||
|
||||
function objectionWindowForOwner(claim: any): {
|
||||
pricingEligible: boolean;
|
||||
resendEligible: boolean;
|
||||
} {
|
||||
const r = claim.evaluation?.damageExpertResend;
|
||||
const hasExpertResend = !!(
|
||||
r &&
|
||||
(String(r.resendDescription || "").trim() !== "" ||
|
||||
(Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) ||
|
||||
(Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0))
|
||||
);
|
||||
|
||||
const pendingResend =
|
||||
hasExpertResend &&
|
||||
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND &&
|
||||
claim.workflow?.currentStep === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||||
|
||||
let pricingEligible = false;
|
||||
if (
|
||||
claimCaseStatusAllowsOwnerInsurerSignStep(claim.status) &&
|
||||
claim.workflow?.currentStep === ClaimWorkflowStep.INSURER_REVIEW &&
|
||||
!claim.evaluation?.ownerInsurerApproval?.signedAt
|
||||
) {
|
||||
if (!objectionDisallowedDueToOutstandingFactorWorkflow(claim)) {
|
||||
const active = getActiveV2ExpertReply(
|
||||
claim as { evaluation?: Record<string, unknown> },
|
||||
);
|
||||
const shape = classifyV2ExpertPricingParts(active?.parts ?? []);
|
||||
const atMixed =
|
||||
claim.claimStatus === ClaimStatus.NEEDS_REVISION &&
|
||||
shape.mixedFactorAndPrice &&
|
||||
!claim.evaluation?.ownerPricedPartsApproval?.signedAt;
|
||||
const atFinal = claim.claimStatus === ClaimStatus.APPROVED;
|
||||
pricingEligible = atMixed || atFinal;
|
||||
}
|
||||
}
|
||||
|
||||
return { pricingEligible, resendEligible: !!pendingResend };
|
||||
}
|
||||
|
||||
/** Mirrors server rules for PUT .../objection (read-only hint for UI). */
|
||||
export function ownerMaySubmitObjectionForClaimDetails(claim: any): boolean {
|
||||
if (claim.evaluation?.objection?.submittedAt) return false;
|
||||
if (claim.evaluation?.damageExpertReplyFinal) return false;
|
||||
const { pricingEligible, resendEligible } = objectionWindowForOwner(claim);
|
||||
if (resendEligible) return true;
|
||||
return (
|
||||
pricingEligible &&
|
||||
!claim.evaluation?.ownerInsurerApproval?.signedAt &&
|
||||
!objectionDisallowedDueToOutstandingFactorWorkflow(claim)
|
||||
);
|
||||
}
|
||||
|
||||
function objectionHintWhen(allowed: boolean): string | undefined {
|
||||
return allowed
|
||||
? "Optional: PUT v2/claim-request-management/request/{claimRequestId}/objection — only priced repair lines (`factorNeeded=false` in the expert reply)."
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build owner-facing guidance for GET claim details V2.
|
||||
* Caller should omit this field for FIELD_EXPERT viewers.
|
||||
*/
|
||||
export function buildClaimDetailsV2OwnerGuidance(
|
||||
claim: any,
|
||||
claimRequestId: string,
|
||||
): ClaimDetailsOwnerGuidanceV2 {
|
||||
const id = claimRequestId;
|
||||
const act = (key: string, method: string, path: string, description: string) =>
|
||||
({ key, method, pathTemplate: path, description });
|
||||
|
||||
const objectionAllowed = ownerMaySubmitObjectionForClaimDetails(claim);
|
||||
const objectionHint = objectionHintWhen(objectionAllowed);
|
||||
|
||||
if (claim.status === ClaimCaseStatus.COMPLETED) {
|
||||
return {
|
||||
phaseKey: "COMPLETED",
|
||||
headline: "Claim completed",
|
||||
detail:
|
||||
claim.userRating?.createdAt
|
||||
? "Rating already submitted."
|
||||
: "You may submit a satisfaction rating once.",
|
||||
nextActions: [
|
||||
act(
|
||||
"USER_RATING",
|
||||
"PUT",
|
||||
`${BASE}/request/${id}/user-rating`,
|
||||
"Optional satisfaction rating",
|
||||
),
|
||||
],
|
||||
objectionAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (claim.status === ClaimCaseStatus.REJECTED || claim.claimStatus === ClaimStatus.REJECTED) {
|
||||
return {
|
||||
phaseKey: "REJECTED",
|
||||
headline: "Claim rejected",
|
||||
detail: "This case is closed as rejected.",
|
||||
nextActions: [],
|
||||
objectionAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (claim.status === ClaimCaseStatus.CANCELLED) {
|
||||
return {
|
||||
phaseKey: "CANCELLED",
|
||||
headline: "Claim cancelled",
|
||||
nextActions: [],
|
||||
objectionAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
|
||||
return {
|
||||
phaseKey: "EXPERT_RESEND",
|
||||
headline: "Expert requested more information",
|
||||
detail:
|
||||
"Upload requested documents or damaged-part photos, or acknowledge if the expert gave instructions only.",
|
||||
nextActions: [
|
||||
act(
|
||||
"UPLOAD_DOC",
|
||||
"POST",
|
||||
`${BASE}/upload-document/${id}`,
|
||||
"Multipart: required document upload",
|
||||
),
|
||||
act(
|
||||
"CAPTURE_PART",
|
||||
"POST",
|
||||
`${BASE}/capture-part/${id}`,
|
||||
"Multipart: damaged-part or angle capture",
|
||||
),
|
||||
act(
|
||||
"RESEND_ACK",
|
||||
"POST",
|
||||
`${BASE}/request/${id}/expert-resend/acknowledge`,
|
||||
"If no document/part list — instructions only",
|
||||
),
|
||||
],
|
||||
objectionAllowed: ownerMaySubmitObjectionForClaimDetails(claim),
|
||||
objectionHint: objectionHintWhen(ownerMaySubmitObjectionForClaimDetails(claim)),
|
||||
};
|
||||
}
|
||||
|
||||
if (claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
|
||||
return {
|
||||
phaseKey: "WAITING_DAMAGE_EXPERT",
|
||||
headline: "Waiting for damage expert",
|
||||
detail: "Your file is in the expert queue.",
|
||||
nextActions: [],
|
||||
objectionAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (claim.status === ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||
return {
|
||||
phaseKey: "EXPERT_REVIEWING",
|
||||
headline: "Expert is reviewing",
|
||||
detail: "A damage expert is assessing your claim.",
|
||||
nextActions: [],
|
||||
objectionAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!claimCaseStatusIsV2OwnerPostExpertPipeline(claim.status)) {
|
||||
return {
|
||||
phaseKey: "USER_FLOW",
|
||||
headline: "Continue your claim registration",
|
||||
detail: "Complete remaining selection, captures, documents, or bank info.",
|
||||
nextActions: [
|
||||
act(
|
||||
"CAPTURE_REQUIREMENTS",
|
||||
"GET",
|
||||
`${BASE}/capture-requirements/${id}`,
|
||||
"Shows remaining angles, parts, documents",
|
||||
),
|
||||
act(
|
||||
"UPLOAD_DOC",
|
||||
"POST",
|
||||
`${BASE}/upload-document/${id}`,
|
||||
"Upload required document when workflow allows",
|
||||
),
|
||||
act(
|
||||
"CAPTURE_PART",
|
||||
"POST",
|
||||
`${BASE}/capture-part/${id}`,
|
||||
"Upload angle or damaged-part photo when workflow allows",
|
||||
),
|
||||
],
|
||||
objectionAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const step = claim.workflow?.currentStep;
|
||||
const cs = claim.claimStatus;
|
||||
|
||||
if (step === ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS && cs === ClaimStatus.NEEDS_REVISION) {
|
||||
return {
|
||||
phaseKey: "UPLOAD_FACTORS",
|
||||
headline: "Upload repair factor files",
|
||||
detail:
|
||||
"One upload per factor-needed part. When all factors are uploaded, the expert validates costs.",
|
||||
nextActions: [
|
||||
act(
|
||||
"UPLOAD_FACTOR",
|
||||
"PATCH",
|
||||
`${BASE}/request/reply/${id}/{partId}/upload-factor`,
|
||||
"Multipart `file` — use each expert reply `partId` with factorNeeded=true",
|
||||
),
|
||||
],
|
||||
objectionAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (step === ClaimWorkflowStep.EXPERT_COST_EVALUATION && cs === ClaimStatus.UNDER_REVIEW) {
|
||||
return {
|
||||
phaseKey: "EXPERT_VALIDATING_FACTORS",
|
||||
headline: "Expert is validating factors",
|
||||
detail: "No owner action until the expert finishes validating repair-factor pricing.",
|
||||
nextActions: [],
|
||||
objectionAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (step === ClaimWorkflowStep.INSURER_REVIEW) {
|
||||
if (cs === ClaimStatus.NEEDS_REVISION) {
|
||||
const active = getActiveV2ExpertReply(
|
||||
claim as { evaluation?: Record<string, unknown> },
|
||||
);
|
||||
const shape = classifyV2ExpertPricingParts(active?.parts ?? []);
|
||||
if (
|
||||
shape.mixedFactorAndPrice &&
|
||||
!claim.evaluation?.ownerPricedPartsApproval?.signedAt
|
||||
) {
|
||||
const ow = objectionWindowForOwner(claim);
|
||||
const allowObj = ow.pricingEligible;
|
||||
return {
|
||||
phaseKey: "SIGN_PRICED_LINES",
|
||||
headline: "Sign acceptance of priced lines",
|
||||
detail:
|
||||
"Required before uploading repair-factor files for mixed priced + factor-needed replies.",
|
||||
nextActions: [
|
||||
act(
|
||||
"SIGN_PRICED_PARTS",
|
||||
"PUT",
|
||||
`${BASE}/request/${id}/owner-insurer-approval/sign`,
|
||||
"Multipart sign + agree + branchId",
|
||||
),
|
||||
],
|
||||
objectionAllowed: allowObj,
|
||||
objectionHint: objectionHintWhen(allowObj),
|
||||
};
|
||||
}
|
||||
|
||||
/** NEEDS_REVISION at INSURER_REVIEW without mixed gate — all-factor initial sign not used; fallback */
|
||||
const allowObj = objectionWindowForOwner(claim).pricingEligible;
|
||||
return {
|
||||
phaseKey: "INSURER_REVIEW_NEEDS_REVISION",
|
||||
headline: "Insurer approval — action pending",
|
||||
detail:
|
||||
"If you see priced lines without factors, use sign or objection; otherwise verify workflow data.",
|
||||
nextActions: [
|
||||
act(
|
||||
"SIGN_OR_CHECK",
|
||||
"PUT",
|
||||
`${BASE}/request/${id}/owner-insurer-approval/sign`,
|
||||
"Sign if prompted by app state",
|
||||
),
|
||||
],
|
||||
objectionAllowed: allowObj,
|
||||
objectionHint: objectionHintWhen(allowObj),
|
||||
};
|
||||
}
|
||||
|
||||
if (cs === ClaimStatus.APPROVED && !claim.evaluation?.ownerInsurerApproval?.signedAt) {
|
||||
const allowObj = objectionWindowForOwner(claim).pricingEligible;
|
||||
return {
|
||||
phaseKey: "FINAL_SIGN_OR_REJECT",
|
||||
headline: "Accept or reject final pricing",
|
||||
detail: "Sign to complete the claim, or reject. You may object to priced lines instead of signing.",
|
||||
nextActions: [
|
||||
act(
|
||||
"FINAL_SIGN",
|
||||
"PUT",
|
||||
`${BASE}/request/${id}/owner-insurer-approval/sign`,
|
||||
"Multipart sign + agree + branchId — completes or rejects claim",
|
||||
),
|
||||
act(
|
||||
"OBJECTION",
|
||||
"PUT",
|
||||
`${BASE}/request/${id}/objection`,
|
||||
"Dispute priced lines before signing",
|
||||
),
|
||||
],
|
||||
objectionAllowed: allowObj,
|
||||
objectionHint: objectionHintWhen(allowObj),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phaseKey: "INSURER_APPROVAL_FALLBACK",
|
||||
headline: "Awaiting next step",
|
||||
detail:
|
||||
"Inspect `status`, `claimStatus`, `workflow.currentStep`/`nextStep` or contact support.",
|
||||
nextActions: [],
|
||||
objectionAllowed,
|
||||
objectionHint,
|
||||
};
|
||||
}
|
||||
118
src/helpers/claim-expert-resend.ts
Normal file
118
src/helpers/claim-expert-resend.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||
import { getDamagedPartCaptureBlob } from "./claim-car-angle-media";
|
||||
import { normalizeDamageSelectedParts } from "./outer-damage-parts";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
|
||||
|
||||
export function resendRequestHasPayload(r: {
|
||||
resendDescription?: string;
|
||||
resendDocuments?: unknown[];
|
||||
resendCarParts?: unknown[];
|
||||
}): boolean {
|
||||
if (!r) return false;
|
||||
if (String(r.resendDescription || "").trim() !== "") return true;
|
||||
if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) return true;
|
||||
if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Normalize expert-requested document keys (strings or `{ key }`) to canonical enum strings. */
|
||||
export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] {
|
||||
if (!Array.isArray(docs)) return [];
|
||||
const keys: string[] = [];
|
||||
for (const d of docs) {
|
||||
if (typeof d === "string" && d.trim()) {
|
||||
const c = canonicalizeResendDocumentKey(d.trim());
|
||||
if (c) keys.push(c);
|
||||
} else if (d && typeof d === "object" && "key" in (d as object)) {
|
||||
const k = String((d as { key?: string }).key || "").trim();
|
||||
const c = canonicalizeResendDocumentKey(k);
|
||||
if (c) keys.push(c);
|
||||
}
|
||||
}
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
|
||||
/** Normalize expert-requested damaged part keys (`DamagedPartItem.key`). */
|
||||
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
|
||||
if (!Array.isArray(parts)) return [];
|
||||
const keys: string[] = [];
|
||||
for (const p of parts) {
|
||||
if (typeof p === "string" && p.trim()) keys.push(p.trim());
|
||||
else if (p && typeof p === "object" && "key" in (p as object)) {
|
||||
const k = String((p as { key?: string }).key || "").trim();
|
||||
if (k) keys.push(k);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function getRequiredDocEntry(claim: any, documentKey: string): unknown {
|
||||
const rd = claim?.requiredDocuments;
|
||||
if (!rd) return undefined;
|
||||
return rd instanceof Map ? rd.get(documentKey) : rd[documentKey];
|
||||
}
|
||||
|
||||
export function isRequiredDocumentUploaded(claim: any, documentKey: string): boolean {
|
||||
const doc = getRequiredDocEntry(claim, documentKey) as { uploaded?: boolean } | undefined;
|
||||
return !!doc?.uploaded;
|
||||
}
|
||||
|
||||
export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
|
||||
const data = claim?.media?.damagedParts;
|
||||
if (!data) return false;
|
||||
const norm = normalizeDamageSelectedParts(
|
||||
claim?.damage?.selectedParts,
|
||||
claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
||||
claim?.damage?.selectedOuterParts,
|
||||
);
|
||||
return getDamagedPartCaptureBlob(data, partKey, norm) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user has satisfied every document and part the damage expert asked for,
|
||||
* or when the expert only left instructions (no doc/part list) and there is a description.
|
||||
*/
|
||||
export function canFinalizeExpertResend(claim: any): boolean {
|
||||
if (!claim) return false;
|
||||
if (claim.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) return false;
|
||||
if (claim.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND) return false;
|
||||
|
||||
const r = claim.evaluation?.damageExpertResend;
|
||||
if (!r || r.fulfilledAt) return false;
|
||||
|
||||
const docKeys = normalizeResendDocumentKeys(r.resendDocuments);
|
||||
const partKeys = normalizeResendPartKeys(r.resendCarParts);
|
||||
|
||||
if (docKeys.length === 0 && partKeys.length === 0) {
|
||||
return String(r.resendDescription || "").trim() !== "";
|
||||
}
|
||||
|
||||
for (const k of docKeys) {
|
||||
if (!isRequiredDocumentUploaded(claim, k)) return false;
|
||||
}
|
||||
for (const k of partKeys) {
|
||||
if (!hasDamagedPartCapture(claim, k)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function documentKeyAllowedForExpertResend(
|
||||
claim: any,
|
||||
documentKey: string,
|
||||
): boolean {
|
||||
const r = claim?.evaluation?.damageExpertResend;
|
||||
if (!r || r.fulfilledAt) return false;
|
||||
const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments));
|
||||
const normalized =
|
||||
canonicalizeResendDocumentKey(documentKey) ?? documentKey.trim();
|
||||
return allowed.has(normalized);
|
||||
}
|
||||
|
||||
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
|
||||
const r = claim?.evaluation?.damageExpertResend;
|
||||
if (!r || r.fulfilledAt) return false;
|
||||
const allowed = new Set(normalizeResendPartKeys(r.resendCarParts));
|
||||
return allowed.has(partKey);
|
||||
}
|
||||
83
src/helpers/claim-price-drop.spec.ts
Normal file
83
src/helpers/claim-price-drop.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
buildCoefficientsFromPartSeverities,
|
||||
calculateClaimPriceDrop,
|
||||
resolvePriceDropPartKeyFromDamagePart,
|
||||
resolveVehicleModelYearFromBlame,
|
||||
} from "./claim-price-drop";
|
||||
|
||||
describe("claim-price-drop", () => {
|
||||
it("maps catalog segment to price drop key", () => {
|
||||
expect(
|
||||
resolvePriceDropPartKeyFromDamagePart({
|
||||
name: "backfender",
|
||||
catalogKey: "left_backfender",
|
||||
}),
|
||||
).toBe("backFender");
|
||||
expect(
|
||||
resolvePriceDropPartKeyFromDamagePart({
|
||||
name: "hood",
|
||||
catalogKey: "front_carHood",
|
||||
}),
|
||||
).toBe("Hood");
|
||||
});
|
||||
|
||||
it("calculates total from formula", () => {
|
||||
const r = calculateClaimPriceDrop(400_000_000, 1394, [2, 3]);
|
||||
expect(r.sumOfSeverity).toBe(5);
|
||||
expect(r.coefficientYear).toBe(2.1);
|
||||
expect(r.total).toBe((400_000_000 * 2.1 * 5) / 400);
|
||||
});
|
||||
|
||||
it("reads model year from blame inquiry", () => {
|
||||
const year = resolveVehicleModelYearFromBlame(
|
||||
{ owner: { userId: "u1" } },
|
||||
{
|
||||
parties: [
|
||||
{
|
||||
role: "FIRST",
|
||||
person: { userId: "u1" },
|
||||
vehicle: {
|
||||
inquiry: { mapped: { ModelField: "1395" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(year).toBe(1395);
|
||||
});
|
||||
|
||||
it("builds coefficients from expert severities", () => {
|
||||
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
name: "backfender",
|
||||
side: "left",
|
||||
label_fa: "گلگیر",
|
||||
catalogKey: "left_backfender",
|
||||
},
|
||||
],
|
||||
[{ partId: 1, severity: "Moderate" }],
|
||||
);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(coefficients).toEqual([3]);
|
||||
});
|
||||
|
||||
it("accepts numeric catalog id (e.g. 201)", () => {
|
||||
const { coefficients, errors, lines } = buildCoefficientsFromPartSeverities(
|
||||
[
|
||||
{
|
||||
id: 201,
|
||||
name: "backfender",
|
||||
side: "left",
|
||||
label_fa: "گلگیر عقب (چپ)",
|
||||
catalogKey: "left_backfender",
|
||||
},
|
||||
],
|
||||
[{ partId: 201, severity: "Moderate" }],
|
||||
);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(coefficients).toEqual([3]);
|
||||
expect(lines[0]?.partId).toBe(201);
|
||||
});
|
||||
});
|
||||
349
src/helpers/claim-price-drop.ts
Normal file
349
src/helpers/claim-price-drop.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
ClaimVehicleTypeV2,
|
||||
} from "src/static/outer-car-parts-catalog";
|
||||
import {
|
||||
catalogPartIdFromSelectedPart,
|
||||
parseCatalogPartIdInput,
|
||||
resolvePartForExpertReply,
|
||||
type DamageSelectedPartV2,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
|
||||
/** Canonical severity values for expert input (API). */
|
||||
export type PriceDropSeverity = "Minor" | "Moderate" | "Severe";
|
||||
|
||||
export const PRICE_DROP_SEVERITY_FA: Record<PriceDropSeverity, string> = {
|
||||
Minor: "جزیی",
|
||||
Moderate: "متوسط",
|
||||
Severe: "شدید",
|
||||
};
|
||||
|
||||
export const PRICE_DROP_SEVERITY_OPTIONS = (
|
||||
Object.entries(PRICE_DROP_SEVERITY_FA) as [PriceDropSeverity, string][]
|
||||
).map(([value, label_fa]) => ({ value, label_fa }));
|
||||
|
||||
/** Part → severity → coefficient (same table as V1 expert-claim). */
|
||||
export const PRICE_DROP_PART_TABLE: Record<
|
||||
string,
|
||||
Partial<Record<string, number>>
|
||||
> = {
|
||||
backFender: { Minor: 2, Moderate: 3, Severe: 5 },
|
||||
backDoor: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
frontDoor: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
frontFender: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
frontBumper: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
Hood: { Minor: 2, moderate: 3, Severe: 4 },
|
||||
Trunk: { minor: 1, moderate: 3, Severe: 5 },
|
||||
Roof: { Minor: 3, Moderate: 5, Severe: 7 },
|
||||
coil: { Minor: 2, Moderate: 3, Severe: 4 },
|
||||
column: { Minor: 2, Moderate: 3, Severe: 4 },
|
||||
frontTray: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
backTray: { Minor: 2, moderate: 4, Severe: 5 },
|
||||
frontChassis: { Minor: 3, Moderate: 5, Severe: 7 },
|
||||
backChassis: { Minor: 2, Moderate: 4, Severe: 6 },
|
||||
carFootrest: { Minor: 1, Moderate: 2, Severe: 3 },
|
||||
carFloor: { Minor: 4, moderate: 6, Severe: 8 },
|
||||
};
|
||||
|
||||
/** Jalali model year → year coefficient (V1). */
|
||||
export const PRICE_DROP_YEAR_COEFFICIENT: Record<number, number> = {
|
||||
1393: 2.05,
|
||||
1394: 2.1,
|
||||
1395: 2.2,
|
||||
1396: 2.3,
|
||||
1397: 2.4,
|
||||
1398: 2.5,
|
||||
1399: 2.6,
|
||||
1400: 2.7,
|
||||
1401: 2.8,
|
||||
1402: 2.9,
|
||||
1403: 3,
|
||||
1404: 3,
|
||||
};
|
||||
|
||||
const PRICE_DROP_PART_LABEL_FA: Record<string, string> = {
|
||||
backFender: "گلگیر عقب",
|
||||
backDoor: "در عقب",
|
||||
frontDoor: "در جلو",
|
||||
frontFender: "گلگیر جلو",
|
||||
frontBumper: "سپر جلو",
|
||||
Hood: "کاپوت",
|
||||
Trunk: "صندوق عقب",
|
||||
Roof: "سقف",
|
||||
coil: "لوله",
|
||||
column: "ستون",
|
||||
frontTray: "سینی جلو",
|
||||
backTray: "سینی عقب",
|
||||
frontChassis: "شاسی جلو",
|
||||
backChassis: "شاسی عقب",
|
||||
carFootrest: "پا رکاب",
|
||||
carFloor: "کف خودرو",
|
||||
};
|
||||
|
||||
/** Catalog / damage `name` segments → price-drop part key. */
|
||||
const SEGMENT_ALIASES: Record<string, string> = {
|
||||
backfender: "backFender",
|
||||
backdoor: "backDoor",
|
||||
frontdoor: "frontDoor",
|
||||
frontfender: "frontFender",
|
||||
frontbumper: "frontBumper",
|
||||
backbumper: "frontBumper",
|
||||
carhood: "Hood",
|
||||
hood: "Hood",
|
||||
cartrunk: "Trunk",
|
||||
trunk: "Trunk",
|
||||
roof: "Roof",
|
||||
coil: "coil",
|
||||
column: "column",
|
||||
fronttray: "frontTray",
|
||||
backtray: "backTray",
|
||||
frontchassis: "frontChassis",
|
||||
backchassis: "backChassis",
|
||||
carfootrest: "carFootrest",
|
||||
carfloor: "carFloor",
|
||||
};
|
||||
|
||||
const NORM_TO_PART_KEY = new Map<string, string>();
|
||||
for (const key of Object.keys(PRICE_DROP_PART_TABLE)) {
|
||||
NORM_TO_PART_KEY.set(normalizePriceDropKey(key), key);
|
||||
}
|
||||
for (const [seg, key] of Object.entries(SEGMENT_ALIASES)) {
|
||||
NORM_TO_PART_KEY.set(seg, key);
|
||||
}
|
||||
|
||||
export function normalizePriceDropKey(str: string): string {
|
||||
return String(str ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
export function parsePriceDropNumber(input: number | string): number {
|
||||
if (typeof input === "number" && Number.isFinite(input)) return input;
|
||||
return Number(
|
||||
String(input)
|
||||
.replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
|
||||
.replace(/,/g, "")
|
||||
.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSeverityCoefficient(
|
||||
partKey: string,
|
||||
severity: string,
|
||||
): number | undefined {
|
||||
const row = PRICE_DROP_PART_TABLE[partKey];
|
||||
if (!row) return undefined;
|
||||
const sev = String(severity ?? "").trim();
|
||||
if (row[sev] !== undefined) return row[sev];
|
||||
const cap = sev.charAt(0).toUpperCase() + sev.slice(1).toLowerCase();
|
||||
if (row[cap] !== undefined) return row[cap];
|
||||
const low = sev.toLowerCase();
|
||||
if (row[low] !== undefined) return row[low];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function calculateClaimPriceDrop(
|
||||
carPrice: number | string,
|
||||
carModelYear: number,
|
||||
coefficients: number[],
|
||||
): {
|
||||
carPrice: number;
|
||||
carModel: number;
|
||||
carValue: number[];
|
||||
sumOfSeverity: number;
|
||||
coefficientYear: number;
|
||||
total: number;
|
||||
} {
|
||||
const normalizePrice = parsePriceDropNumber(carPrice);
|
||||
const coefficient = PRICE_DROP_YEAR_COEFFICIENT[carModelYear] ?? 1;
|
||||
const carValue = coefficients.filter((n) => Number.isFinite(n));
|
||||
const sumOfSeverity = carValue.reduce((a, c) => a + c, 0);
|
||||
const total = (normalizePrice * coefficient * sumOfSeverity) / 400;
|
||||
|
||||
return {
|
||||
carPrice: normalizePrice,
|
||||
carModel: carModelYear,
|
||||
carValue,
|
||||
sumOfSeverity,
|
||||
coefficientYear: coefficient,
|
||||
total: total || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPriceDropCatalogForApi(): Array<{
|
||||
key: string;
|
||||
label_fa: string;
|
||||
severities: Record<PriceDropSeverity, number | undefined>;
|
||||
}> {
|
||||
return Object.keys(PRICE_DROP_PART_TABLE).map((key) => {
|
||||
const row = PRICE_DROP_PART_TABLE[key];
|
||||
return {
|
||||
key,
|
||||
label_fa: PRICE_DROP_PART_LABEL_FA[key] ?? key,
|
||||
severities: {
|
||||
Minor: resolveSeverityCoefficient(key, "Minor"),
|
||||
Moderate: resolveSeverityCoefficient(key, "Moderate"),
|
||||
Severe: resolveSeverityCoefficient(key, "Severe"),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePriceDropPartKeyFromDamagePart(part: {
|
||||
name?: string;
|
||||
catalogKey?: string;
|
||||
}): string | null {
|
||||
const candidates: string[] = [];
|
||||
if (part.catalogKey) {
|
||||
for (const seg of String(part.catalogKey).split("_")) {
|
||||
if (seg) candidates.push(seg);
|
||||
}
|
||||
}
|
||||
if (part.name) candidates.push(String(part.name));
|
||||
|
||||
for (const raw of candidates) {
|
||||
const norm = normalizePriceDropKey(raw);
|
||||
const hit = NORM_TO_PART_KEY.get(norm);
|
||||
if (hit && PRICE_DROP_PART_TABLE[hit]) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildDamagedPartsPriceDropLines(
|
||||
selectedParts: DamageSelectedPartV2[],
|
||||
): Array<{
|
||||
partId: number | null;
|
||||
name: string;
|
||||
side: string;
|
||||
label_fa: string;
|
||||
priceDropPartKey: string | null;
|
||||
mappable: boolean;
|
||||
}> {
|
||||
return selectedParts.map((sp) => {
|
||||
const priceDropPartKey = resolvePriceDropPartKeyFromDamagePart(sp);
|
||||
return {
|
||||
partId: catalogPartIdFromSelectedPart(sp),
|
||||
name: sp.name,
|
||||
side: sp.side,
|
||||
label_fa: sp.label_fa,
|
||||
priceDropPartKey,
|
||||
mappable: priceDropPartKey != null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve Jalali year from linked blame party inquiry (owner party preferred). */
|
||||
export function resolveVehicleModelYearFromBlame(
|
||||
claim: { owner?: { userId?: unknown } },
|
||||
blame: {
|
||||
parties?: Array<{
|
||||
role?: string;
|
||||
person?: { userId?: unknown };
|
||||
vehicle?: {
|
||||
inquiry?: {
|
||||
mapped?: { ModelField?: unknown; ModelCii?: unknown };
|
||||
ModelField?: unknown;
|
||||
ModelCii?: unknown;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
} | null,
|
||||
): number | null {
|
||||
if (!blame?.parties?.length) return null;
|
||||
const ownerId =
|
||||
claim.owner?.userId != null ? String(claim.owner.userId) : "";
|
||||
let party =
|
||||
blame.parties.find(
|
||||
(p) =>
|
||||
ownerId &&
|
||||
p.person?.userId != null &&
|
||||
String(p.person.userId) === ownerId,
|
||||
) ?? blame.parties.find((p) => p.role === "FIRST");
|
||||
if (!party) party = blame.parties[0];
|
||||
|
||||
const inquiry = party?.vehicle?.inquiry;
|
||||
const mapped = inquiry?.mapped ?? inquiry;
|
||||
const raw =
|
||||
(mapped as { ModelField?: unknown })?.ModelField ??
|
||||
(mapped as { ModelCii?: unknown })?.ModelCii;
|
||||
if (raw == null || raw === "") return null;
|
||||
const n =
|
||||
typeof raw === "number" ? raw : parseInt(String(raw).trim(), 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export interface PriceDropPartSeverityInput {
|
||||
partId: number | string;
|
||||
severity: PriceDropSeverity;
|
||||
}
|
||||
|
||||
export function buildCoefficientsFromPartSeverities(
|
||||
selectedParts: DamageSelectedPartV2[],
|
||||
partSeverities: PriceDropPartSeverityInput[],
|
||||
carType?: ClaimVehicleTypeV2,
|
||||
): {
|
||||
coefficients: number[];
|
||||
lines: Array<{
|
||||
partId: number;
|
||||
priceDropPartKey: string;
|
||||
label_fa: string;
|
||||
severity: PriceDropSeverity;
|
||||
coefficient: number;
|
||||
}>;
|
||||
errors: string[];
|
||||
} {
|
||||
const coefficients: number[] = [];
|
||||
const lines: Array<{
|
||||
partId: number;
|
||||
priceDropPartKey: string;
|
||||
label_fa: string;
|
||||
severity: PriceDropSeverity;
|
||||
coefficient: number;
|
||||
}> = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const row of partSeverities) {
|
||||
const catalogId = parseCatalogPartIdInput(row.partId);
|
||||
if (catalogId == null) {
|
||||
errors.push(`Unknown partId "${row.partId}".`);
|
||||
continue;
|
||||
}
|
||||
const sp = resolvePartForExpertReply(
|
||||
catalogId,
|
||||
selectedParts,
|
||||
carType,
|
||||
);
|
||||
if (!sp) {
|
||||
errors.push(`Unknown partId "${row.partId}".`);
|
||||
continue;
|
||||
}
|
||||
const partKey = resolvePriceDropPartKeyFromDamagePart(sp);
|
||||
if (!partKey) {
|
||||
errors.push(
|
||||
`Part "${row.partId}" (${sp.label_fa}) has no price-drop mapping.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const coef = resolveSeverityCoefficient(partKey, row.severity);
|
||||
if (coef === undefined) {
|
||||
errors.push(
|
||||
`Invalid severity "${row.severity}" for part key "${partKey}".`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
coefficients.push(coef);
|
||||
const linePartId = catalogPartIdFromSelectedPart(sp);
|
||||
if (linePartId == null) {
|
||||
errors.push(`Part "${row.partId}" has no catalog id.`);
|
||||
continue;
|
||||
}
|
||||
lines.push({
|
||||
partId: linePartId,
|
||||
priceDropPartKey: partKey,
|
||||
label_fa: sp.label_fa,
|
||||
severity: row.severity,
|
||||
coefficient: coef,
|
||||
});
|
||||
}
|
||||
|
||||
return { coefficients, lines, errors };
|
||||
}
|
||||
35
src/helpers/claim-resend-document-keys.ts
Normal file
35
src/helpers/claim-resend-document-keys.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||
|
||||
const REQUIRED_VALUES = new Set<string>(
|
||||
Object.values(ClaimRequiredDocumentType),
|
||||
);
|
||||
|
||||
/**
|
||||
* Non-canonical document keys that may still appear on stored
|
||||
* `evaluation.damageExpertResend.resendDocuments` → {@link ClaimRequiredDocumentType}.
|
||||
*/
|
||||
const RESEND_DOCUMENT_KEY_ALIASES: Readonly<
|
||||
Record<string, ClaimRequiredDocumentType>
|
||||
> = {
|
||||
nationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
|
||||
NationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
|
||||
drivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
|
||||
DrivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
|
||||
carGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||||
CarGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||||
carCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
|
||||
CarCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
|
||||
plate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
|
||||
carPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
|
||||
CarPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
|
||||
};
|
||||
|
||||
/** Normalize a single key to a {@link ClaimRequiredDocumentType} value, or null if unknown. */
|
||||
export function canonicalizeResendDocumentKey(
|
||||
raw: string,
|
||||
): ClaimRequiredDocumentType | null {
|
||||
const t = String(raw || "").trim();
|
||||
if (!t) return null;
|
||||
if (REQUIRED_VALUES.has(t)) return t as ClaimRequiredDocumentType;
|
||||
return RESEND_DOCUMENT_KEY_ALIASES[t] ?? null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user