Compare commits

..

7 Commits

4 changed files with 378 additions and 6 deletions

View File

@@ -3716,14 +3716,19 @@ export class ExpertClaimService {
let claims: any[] = []; let claims: any[] = [];
if (actor.role === RoleEnum.FIELD_EXPERT) { if (actor.role === RoleEnum.FIELD_EXPERT) {
const expertOid = new Types.ObjectId(actor.sub); const expertOid = new Types.ObjectId(actor.sub);
// Exclude V4/V5 blame files — same rule as getFieldExpertClaimListV2.
const expertBlameIds = await this.blameRequestDbService const expertBlameIds = await this.blameRequestDbService
.find( .find(
{ expertInitiated: true, initiatedByFieldExpertId: expertOid }, {
expertInitiated: true,
initiatedByFieldExpertId: expertOid,
isMadeByFileMaker: { $ne: true },
},
{ select: "_id", lean: true }, { select: "_id", lean: true },
) )
.then((docs) => docs.map((d) => (d as { _id: unknown })._id)); .then((docs) => docs.map((d) => (d as { _id: unknown })._id));
const claimOr: Record<string, unknown>[] = [ const claimOr: Record<string, unknown>[] = [
{ initiatedByFieldExpertId: expertOid }, { initiatedByFieldExpertId: expertOid, requiresFileMakerApproval: { $ne: true } },
]; ];
if (expertBlameIds.length > 0) { if (expertBlameIds.length > 0) {
claimOr.push({ blameRequestId: { $in: expertBlameIds } }); claimOr.push({ blameRequestId: { $in: expertBlameIds } });
@@ -4052,15 +4057,22 @@ export class ExpertClaimService {
query: ListQueryV2Dto = {}, query: ListQueryV2Dto = {},
): Promise<GetClaimListV2ResponseDto> { ): Promise<GetClaimListV2ResponseDto> {
const expertOid = new Types.ObjectId(actor.sub); const expertOid = new Types.ObjectId(actor.sub);
// Exclude V4/V5 blame files (isMadeByFileMaker=true) — those belong to
// the FILE_MAKER/FILE_REVIEWER flows, not the V3 field-expert flow.
const expertBlameIds = await this.blameRequestDbService const expertBlameIds = await this.blameRequestDbService
.find( .find(
{ expertInitiated: true, initiatedByFieldExpertId: expertOid }, {
expertInitiated: true,
initiatedByFieldExpertId: expertOid,
isMadeByFileMaker: { $ne: true },
},
{ select: "_id", lean: true }, { select: "_id", lean: true },
) )
.then((docs) => docs.map((d) => (d as { _id: unknown })._id)); .then((docs) => docs.map((d) => (d as { _id: unknown })._id));
const claimOr: Record<string, unknown>[] = [ const claimOr: Record<string, unknown>[] = [
{ initiatedByFieldExpertId: expertOid }, // Direct claim link: exclude V5 claims that require FileMaker approval
{ initiatedByFieldExpertId: expertOid, requiresFileMakerApproval: { $ne: true } },
]; ];
if (expertBlameIds.length > 0) { if (expertBlameIds.length > 0) {
claimOr.push({ blameRequestId: { $in: expertBlameIds } }); claimOr.push({ blameRequestId: { $in: expertBlameIds } });
@@ -4738,8 +4750,19 @@ export class ExpertClaimService {
"This file has been taken by another reviewer.", "This file has been taken by another reviewer.",
); );
} }
} else if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) { } else {
throw new ForbiddenException("This claim is not accessible to you."); // FIELD_EXPERT: V3 flow only. Reject V4/V5 files (isMadeByFileMaker)
// even if their ID matches initiatedByFieldExpertId on the blame —
// those are FILE_MAKER files, accessible via the FILE_MAKER role only.
const isV4V5Blame = !!(linkedBlame as any)?.isMadeByFileMaker;
if (isV4V5Blame) {
throw new ForbiddenException(
"Field experts can only access V3 expert-initiated files.",
);
}
if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) {
throw new ForbiddenException("This claim is not accessible to you.");
}
} }
// Fall through to the detail build below (skip the damage-expert gate) // Fall through to the detail build below (skip the damage-expert gate)
} else if (actor.role !== RoleEnum.FILE_MAKER) { } else if (actor.role !== RoleEnum.FILE_MAKER) {

View File

@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises";
import { import {
Body, Body,
Controller, Controller,
@@ -117,6 +118,21 @@ export class ExpertClaimV2Controller {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType); return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
} }
@Get("inner-parts-catalog")
@ApiOperation({
summary: "Get inner parts catalog",
description: "Returns the full list of inner car parts from the static catalog.",
})
@ApiResponse({ status: 200, description: "Inner parts catalog (object keyed by part name)" })
async getInnerPartsCatalog() {
const raw = await readFile(`${process.cwd()}/src/static/car-part.json`, "utf-8");
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
@Get("branches") @Get("branches")
@ApiOperation({ @ApiOperation({
summary: "List insurer branches for this damage expert (V2)", summary: "List insurer branches for this damage expert (V2)",

View File

@@ -1,18 +1,25 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Get, Get,
Param,
Patch, Patch,
Post, Post,
Put,
Query,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
ApiOperation, ApiOperation,
ApiParam,
ApiQuery,
ApiResponse, ApiResponse,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { Types } from "mongoose";
import { SuperAdminGuard } from "./guards/super-admin.guard"; import { SuperAdminGuard } from "./guards/super-admin.guard";
import { SuperAdminService } from "./super-admin.service"; import { SuperAdminService } from "./super-admin.service";
import { import {
@@ -32,6 +39,20 @@ import {
UpdateSystemSettingsDto, UpdateSystemSettingsDto,
} from "src/system-settings/dto/system-settings.dto"; } from "src/system-settings/dto/system-settings.dto";
import { SetExternalInquiriesLiveDto } from "src/client/dto/external-inquiries-live.dto"; import { SetExternalInquiriesLiveDto } from "src/client/dto/external-inquiries-live.dto";
import { ExpertInsurerService } from "src/expert-insurer/expert-insurer.service";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
UnifiedFileStatusReportDto,
UnifiedFileStatusReportQueryDto,
} from "src/common/dto/unified-file-status-report.dto";
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
import {
CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto,
CreateFileMakerByInsurerDto,
CreateFileReviewerByInsurerDto,
} from "src/expert-insurer/dto/create-insurer-expert.dto";
@ApiTags("super-admin") @ApiTags("super-admin")
@ApiBearerAuth() @ApiBearerAuth()
@@ -41,6 +62,7 @@ export class SuperAdminController {
constructor( constructor(
private readonly superAdminService: SuperAdminService, private readonly superAdminService: SuperAdminService,
private readonly systemSettingsService: SystemSettingsService, private readonly systemSettingsService: SystemSettingsService,
private readonly expertInsurerService: ExpertInsurerService,
) {} ) {}
// ── Super-admin account management ─────────────────────────────────────── // ── Super-admin account management ───────────────────────────────────────
@@ -159,4 +181,313 @@ export class SuperAdminController {
externalApis: { sandHubUseLiveApi: body?.enabled === true }, externalApis: { sandHubUseLiveApi: body?.enabled === true },
}); });
} }
// ── Insurer panel (super-admin proxy) ────────────────────────────────────
@Get("insurer/branches")
@ApiOperation({ summary: "List branches for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
@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)" })
getInsuranceBranches(
@Query("clientId") clientId: string,
@Query("search") search?: string,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("isActive") isActive?: string,
) {
return this.expertInsurerService.retrieveInsuranceBranches(
clientId,
{ search, from, to, isActive },
);
}
@Post("insurer/branches")
@ApiOperation({ summary: "Add a branch for a given client" })
@ApiBody({
schema: {
allOf: [
{ $ref: "#/components/schemas/CreateBranchDto" },
{
type: "object",
required: ["clientId"],
properties: { clientId: { type: "string", description: "Client ObjectId" } },
},
],
},
})
addBranch(
@Body("clientId") clientId: string,
@Body() createBranchDto: CreateBranchDto,
) {
return this.expertInsurerService.addBranch(clientId, createBranchDto);
}
@Put("insurer/branches/:branchId/status")
@ApiOperation({ summary: "Set branch active/inactive for a given client" })
@ApiParam({ name: "branchId" })
@ApiBody({
schema: {
type: "object",
properties: {
clientId: { type: "string", description: "Client ObjectId" },
isActive: { type: "boolean" },
},
required: ["clientId", "isActive"],
},
})
setBranchStatus(
@Param("branchId") branchId: string,
@Body("clientId") clientId: string,
@Body("isActive") isActive: unknown,
) {
let active: boolean;
if (typeof isActive === "boolean") {
active = isActive;
} else {
const normalized = String(isActive ?? "").trim().toLowerCase();
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) {
throw new BadRequestException("isActive must be a boolean");
}
active = ["true", "1", "yes"].includes(normalized);
}
return this.expertInsurerService.setBranchActive(clientId, branchId, active);
}
@Post("insurer/experts/blame")
@ApiOperation({ summary: "Create a blame expert under a given client" })
@ApiBody({
schema: {
allOf: [
{ $ref: "#/components/schemas/CreateBlameExpertByInsurerDto" },
{
type: "object",
required: ["clientId"],
properties: { clientId: { type: "string", description: "Client ObjectId" } },
},
],
},
})
addBlameExpert(
@Body("clientId") clientId: string,
@Body() body: CreateBlameExpertByInsurerDto,
) {
return this.expertInsurerService.addBlameExpert(clientId, body);
}
@Post("insurer/experts/claim")
@ApiOperation({ summary: "Create a claim expert under a given client" })
@ApiBody({
schema: {
allOf: [
{ $ref: "#/components/schemas/CreateClaimExpertByInsurerDto" },
{
type: "object",
required: ["clientId"],
properties: { clientId: { type: "string", description: "Client ObjectId" } },
},
],
},
})
addClaimExpert(
@Body("clientId") clientId: string,
@Body() body: CreateClaimExpertByInsurerDto,
) {
return this.expertInsurerService.addClaimExpert(clientId, body);
}
@Post("insurer/experts/file-maker")
@ApiOperation({ summary: "Create a FileMaker account under a given client" })
@ApiBody({
schema: {
allOf: [
{ $ref: "#/components/schemas/CreateFileMakerByInsurerDto" },
{
type: "object",
required: ["clientId"],
properties: { clientId: { type: "string", description: "Client ObjectId" } },
},
],
},
})
addFileMaker(
@Body("clientId") clientId: string,
@Body() body: CreateFileMakerByInsurerDto,
) {
return this.expertInsurerService.addFileMaker(clientId, body);
}
@Post("insurer/experts/file-reviewer")
@ApiOperation({ summary: "Create a FileReviewer account under a given client" })
@ApiBody({
schema: {
allOf: [
{ $ref: "#/components/schemas/CreateFileReviewerByInsurerDto" },
{
type: "object",
required: ["clientId"],
properties: { clientId: { type: "string", description: "Client ObjectId" } },
},
],
},
})
addFileReviewer(
@Body("clientId") clientId: string,
@Body() body: CreateFileReviewerByInsurerDto,
) {
return this.expertInsurerService.addFileReviewer(clientId, body);
}
@Get("insurer/experts/list")
@ApiOperation({ summary: "List all experts of a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
@ApiQuery({ name: "page", type: Number })
@ApiQuery({ name: "response_count", type: Number })
getAllExperts(
@Query("clientId") clientId: string,
@Query("page") page: number,
@Query("response_count") count: number,
) {
return this.expertInsurerService.retrieveAllExpertsOfClient(
{ clientKey: clientId },
page,
count,
);
}
@Get("insurer/experts/top")
@ApiOperation({ summary: "Top blame vs claim experts for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
getTopExperts(@Query("clientId") clientId: string) {
return this.expertInsurerService.getTopExpertsForClient({ clientKey: clientId });
}
@Get("insurer/files")
@ApiOperation({ summary: "List files (blame + claim merged) for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
getAllFiles(
@Query("clientId") clientId: string,
@Query() query: ListQueryV2Dto,
) {
return this.expertInsurerService.retrieveAllFilesOfClient(clientId, query);
}
@Get("insurer/report/unified-file-statuses")
@ApiOperation({ summary: "Unified file status catalog + counts for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
@ApiResponse({ status: 200, type: UnifiedFileStatusReportDto })
getUnifiedFileStatusReport(
@Query("clientId") clientId: string,
@Query() query: UnifiedFileStatusReportQueryDto,
): Promise<UnifiedFileStatusReportDto> {
return this.expertInsurerService.getInsurerUnifiedFileStatusReport(
{ clientKey: clientId },
query,
);
}
@Get("insurer/report/status-counts")
@ApiOperation({
summary: "Legacy status counts for a given client",
deprecated: true,
description: "Prefer GET insurer/report/unified-file-statuses.",
})
@ApiQuery({ name: "clientId", required: true, type: String })
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
getInsurerStatusReport(
@Query("clientId") clientId: string,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return this.expertInsurerService.getInsurerFileStatusCounts(
{ clientKey: clientId },
from,
to,
);
}
@Get("insurer/files/:publicId/timeline")
@ApiOperation({ summary: "Activity timeline for a case of a given client" })
@ApiParam({ name: "publicId" })
@ApiQuery({ name: "clientId", required: true, type: String })
getFileTimeline(
@Query("clientId") clientId: string,
@Param("publicId") publicId: string,
) {
return this.expertInsurerService.getFileTimeline(clientId, publicId);
}
@Get("insurer/files/:publicId")
@ApiOperation({ summary: "File details by publicId for a given client" })
@ApiParam({ name: "publicId" })
@ApiQuery({ name: "clientId", required: true, type: String })
getFileDetailsByPublicId(
@Query("clientId") clientId: string,
@Param("publicId") publicId: string,
) {
return this.expertInsurerService.retrieveFileDetailsByPublicId(clientId, publicId);
}
// @Put("insurer/files/:publicId/rating")
// @ApiOperation({ summary: "Rate experts by publicId for a given client" })
// @ApiParam({ name: "publicId" })
// @ApiBody({
// schema: {
// type: "object",
// required: [
// "clientId",
// "collisionMethodAccuracy",
// "evaluationTimeliness",
// "accidentCauseAccuracy",
// "guiltyVehicleIdentification",
// "botRating",
// ],
// properties: {
// clientId: { type: "string", description: "Client ObjectId" },
// collisionMethodAccuracy: { type: "number", minimum: 0, maximum: 5 },
// evaluationTimeliness: { type: "number", minimum: 0, maximum: 5 },
// accidentCauseAccuracy: { type: "number", minimum: 0, maximum: 5 },
// guiltyVehicleIdentification: { type: "number", minimum: 0, maximum: 5 },
// botRating: { type: "number", minimum: 0, maximum: 5 },
// },
// },
// })
// rateExpertsByPublicId(
// @Param("publicId") publicId: string,
// @Body() body: FileRating & { clientId: string },
// ) {
// const { clientId, ...rating } = body;
// return this.expertInsurerService.rateExpertByPublicId(publicId, rating as FileRating, clientId);
// }
@Get("insurer/top-files")
@ApiOperation({ summary: "Top-rated files for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
getTopFiles(@Query("clientId") clientId: string) {
return this.expertInsurerService.getTopFilesForClient(clientId);
}
@Get("insurer/statistics")
@ApiOperation({ summary: "Expert statistics report for a given client" })
@ApiQuery({ name: "clientId", required: true, type: String })
getExpertStatistics(@Query("clientId") clientId: string) {
return this.expertInsurerService.getExpertStatisticsReport({ clientKey: clientId });
}
@Get("insurer/:expertId")
@ApiOperation({ summary: "Files handled by one roster expert for a given client" })
@ApiParam({ name: "expertId" })
@ApiQuery({ name: "clientId", required: true, type: String })
getExpertFiles(
@Query("clientId") clientId: string,
@Param("expertId") expertId: string,
) {
if (!Types.ObjectId.isValid(expertId)) {
throw new BadRequestException("Invalid expert ID");
}
return this.expertInsurerService.getAllFilesForInsurerExpert(expertId, clientId);
}
} }

View File

@@ -3,6 +3,7 @@ import { ClientModule } from "src/client/client.module";
import { SystemSettingsModule } from "src/system-settings/system-settings.module"; import { SystemSettingsModule } from "src/system-settings/system-settings.module";
import { HashModule } from "src/utils/hash/hash.module"; import { HashModule } from "src/utils/hash/hash.module";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { ExpertInsurerModule } from "src/expert-insurer/expert-insurer.module";
import { SuperAdminController } from "./super-admin.controller"; import { SuperAdminController } from "./super-admin.controller";
import { SuperAdminService } from "./super-admin.service"; import { SuperAdminService } from "./super-admin.service";
import { SuperAdminGuard } from "./guards/super-admin.guard"; import { SuperAdminGuard } from "./guards/super-admin.guard";
@@ -26,6 +27,7 @@ import { SuperAdminGuard } from "./guards/super-admin.guard";
SystemSettingsModule, SystemSettingsModule,
HashModule, HashModule,
UsersModule, UsersModule,
ExpertInsurerModule,
], ],
controllers: [SuperAdminController], controllers: [SuperAdminController],
providers: [SuperAdminService, SuperAdminGuard], providers: [SuperAdminService, SuperAdminGuard],