import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UnauthorizedException, UseGuards, } from "@nestjs/common"; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags, } from "@nestjs/swagger"; import { Types } from "mongoose"; 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 { FileRating } from "src/request-management/entities/schema/request-management.schema"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { ExpertInsurerService } from "./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 { CreateBlameExpertByInsurerDto, CreateClaimExpertByInsurerDto, CreateFileMakerByInsurerDto, CreateFileReviewerByInsurerDto, } from "./dto/create-insurer-expert.dto"; import { InsurerStatisticsDto, InsurerTopExpertsDto, InsurerTopFileDto, InsurerWorkLogResponseDto, } from "./dto/insurer-reports.dto"; import { ReportsService } from "src/reports/reports.service"; @Controller("expert-insurer") @ApiTags("expert-insurer-panel") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) @Roles(RoleEnum.COMPANY) export class ExpertInsurerController { constructor( private readonly expertInsurerService: ExpertInsurerService, private readonly reportsService: ReportsService, ) {} // ─── Branch management ──────────────────────────────────────────────────── @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" }) @ApiBody({ schema: { type: "object", properties: { isActive: { type: "boolean" } }, required: ["isActive"], }, }) async setBranchStatus( @CurrentUser() insurer, @Param("branchId") branchId: string, @Body("isActive") isActive: unknown, ) { if (!insurer) throw new UnauthorizedException("Could not identify the current user."); 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(insurer.clientKey, branchId, active); } // ─── Expert management ──────────────────────────────────────────────────── @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); } @Post("experts/file-maker") @ApiBody({ type: CreateFileMakerByInsurerDto }) @ApiOperation({ summary: "Create a FileMaker account under this insurer" }) async addFileMaker(@CurrentUser() insurer, @Body() body: CreateFileMakerByInsurerDto) { if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return this.expertInsurerService.addFileMaker(insurer.clientKey, body); } @Post("experts/file-reviewer") @ApiBody({ type: CreateFileReviewerByInsurerDto }) @ApiOperation({ summary: "Create a FileReviewer account under this insurer" }) async addFileReviewer(@CurrentUser() insurer, @Body() body: CreateFileReviewerByInsurerDto) { if (!insurer) throw new UnauthorizedException("Could not identify the current user."); return this.expertInsurerService.addFileReviewer(insurer.clientKey, body); } @ApiQuery({ name: "page", type: Number }) @ApiQuery({ name: "response_count", type: Number }) @Get("experts/list") async getAllExperts( @Query("page") page: number, @Query("response_count") count: number, @CurrentUser() actor, ) { return await this.expertInsurerService.retrieveAllExpertsOfClient(actor, page, count); } // ─── Reports: statistics ────────────────────────────────────────────────── @Get("statistics") @ApiOperation({ summary: "نمایش کلی — KPI cards for insurer reports page", description: "Returns `totalFilesReviewed` (distinct tenant claim files with ≥1 expert CHECKED activity), " + "`averageUserRatingPercentage` (mean of progressSpeed + registrationEase + overallEvaluation across rated files, normalised to 0–100), " + "`inPersonAccompaniedCount` (blame files where expertInitiated=true AND creationMethod=IN_PERSON), " + "`filesCreatedThisMonth`, `totalFiles`, `objectionPercentage`, `insurerToBotRatingPercentage`. " + "⚠️ `userRatingPercentage` and `filesWithUserRatingPercentage` are the share of files *that have any user rating* — NOT a satisfaction score. Use `averageUserRatingPercentage` for رضایت کاربران.", }) @ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string) — restricts counted claim portfolio" }) @ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" }) @ApiResponse({ status: 200, type: InsurerStatisticsDto }) async getExpertStatistics( @CurrentUser() actor, @Query("from") from?: string, @Query("to") to?: string, ) { return await this.expertInsurerService.getExpertStatisticsReport(actor, { from, to }); } // ─── Reports: top files ─────────────────────────────────────────────────── @Get("top-files") @ApiOperation({ summary: "Top 10 highest-rated claim files for this insurer", description: "Sorted by combined insurer + user rating blend (getCombinedFileScore). " + "Returns slim DTO only: publicId, createdAt, combinedScore, userRating.{comment, overallEvaluation}. " + "Use publicId to build مشاهده پرونده links.", }) @ApiQuery({ name: "from", required: false, description: "Restrict to files created on or after this ISO date" }) @ApiQuery({ name: "to", required: false, description: "Restrict to files created on or before this ISO date" }) @ApiResponse({ status: 200, type: [InsurerTopFileDto] }) async getTopFiles( @CurrentUser() insurer, @Query("from") from?: string, @Query("to") to?: string, ) { return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, { from, to }); } // ─── Reports: top experts (canonical + alias) ───────────────────────────── @Get("experts/top") @ApiOperation({ summary: "Top blame vs claim experts for this insurer", description: "Response: `{ blameExperts: [], claimExperts: [] }`. " + "Each item: `_id`, `fullName`, `expertKind`, `overallAverageRating`, `requestStats`. " + "Sorted by overallAverageRating descending; up to 10 per group. " + "Optional `from` / `to` accepted for forward-compat (currently accepted but roster is full-history).", }) @ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" }) @ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" }) @ApiResponse({ status: 200, type: InsurerTopExpertsDto }) async getTopExperts( @CurrentUser() actor, @Query("from") from?: string, @Query("to") to?: string, ) { return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to }); } /** * Alias: some BFF/frontend paths call /top-experts instead of /experts/top. * Both routes point to the same handler. */ @Get("top-experts") @ApiOperation({ summary: "Alias for GET experts/top — top blame + claim experts", description: "Same response as `GET experts/top`. Provided for front-end compatibility.", }) @ApiQuery({ name: "from", required: false }) @ApiQuery({ name: "to", required: false }) @ApiResponse({ status: 200, type: InsurerTopExpertsDto }) async getTopExpertsAlias( @CurrentUser() actor, @Query("from") from?: string, @Query("to") to?: string, ) { return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to }); } // ─── Reports: work-log (delegates to ReportsService) ───────────────────── @Get("expert-work-log") @ApiOperation({ summary: "نمایش جدولی — per-expert work log (blame panel + damage experts)", description: "Rows for every tenant expert in the `expert` (blame) and `damage-expert` (claim) collections — same scope as GET experts/list, field-expert excluded. " + "Columns: expertId, fullName, expertKind, totalHandled (files in HANDLED state up to `to`/now), " + "currentlyChecking (CHECKED but not yet HANDLED up to `to`/now), " + "distinctFilesCheckedInPeriod (distinct files with ≥1 CHECKED event inside from–to window; all-time when omitted). " + "Optional `expertKind` filter: `expert` = blame panel, `damage_expert` = claim damage experts, `all` (default).", }) @ApiQuery({ name: "expertKind", required: false, enum: ["all", "expert", "damage_expert"], description: "Filter by expert kind" }) @ApiQuery({ name: "from", required: false, description: "Start of date window (ISO string)" }) @ApiQuery({ name: "to", required: false, description: "End of date window (ISO string)" }) @ApiQuery({ name: "page", required: false, type: Number, description: "Page (1-based)" }) @ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page" }) @ApiResponse({ status: 200, type: InsurerWorkLogResponseDto }) async getExpertWorkLog( @CurrentUser() actor, @Query("expertKind") expertKind?: "all" | "expert" | "damage_expert", @Query("from") from?: string, @Query("to") to?: string, @Query("page") page?: string, @Query("limit") limit?: string, ) { return this.reportsService.getInsurerExpertWorkLog(actor, { expertKind, from, to, page: page ? parseInt(page, 10) : undefined, limit: limit ? parseInt(limit, 10) : undefined, }); } // ─── Files ──────────────────────────────────────────────────────────────── @Get("files") @ApiOperation({ summary: "List insurer files (blame + claim merged by publicId)", description: "Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).", }) async getAllFiles(@CurrentUser() insurer, @Query() query: ListQueryV2Dto) { return await this.expertInsurerService.retrieveAllFilesOfClient(insurer.clientKey, query); } @Get("report/unified-file-statuses") @ApiOperation({ summary: "Unified file status catalog + counts (blame + claim)", description: "Calculatable unified statuses and per-status counts for this insurer's files. Filter lists with `unifiedStatus` and `fileType`. Optional `from` / `to` ISO dates narrow the counted portfolio.", }) @ApiResponse({ status: 200, type: UnifiedFileStatusReportDto }) async getUnifiedFileStatusReport( @CurrentUser() actor, @Query() query: UnifiedFileStatusReportQueryDto, ): Promise { return await this.expertInsurerService.getInsurerUnifiedFileStatusReport(actor, query); } @Get("files/:publicId/timeline") @ApiParam({ name: "publicId" }) @ApiOperation({ summary: "Activity timeline for a case", description: "Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event has: source, type, timestamp, actor, metadata.", }) async getFileTimeline(@CurrentUser() insurer, @Param("publicId") publicId: string) { return await this.expertInsurerService.getFileTimeline(insurer.clientKey, publicId); } @Get("files/:publicId") @ApiParam({ name: "publicId" }) async getFileDetailsByPublicId(@CurrentUser() insurer, @Param("publicId") publicId: string) { return await this.expertInsurerService.retrieveFileDetailsByPublicId(insurer.clientKey, publicId); } @ApiBody({ description: "One insurer rating for the shared publicId; persisted on claim and/or blame case documents when both exist.", schema: { type: "object", properties: { collisionMethodAccuracy: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست نحوه برخورد" }, evaluationTimeliness: { type: "number", minimum: 0, maximum: 5, example: 3, description: "زمان ارزیابی" }, accidentCauseAccuracy: { type: "number", minimum: 0, maximum: 5, example: 5, description: "تشخیص درست علت تصادف" }, guiltyVehicleIdentification: { type: "number", minimum: 0, maximum: 5, example: 4, description: "تشخیص درست وسیله نقلیه مقصر" }, botRating: { type: "number", minimum: 0, maximum: 5, example: 4, description: "برای امتیاز دادن به عملکرد بات" }, }, required: ["collisionMethodAccuracy", "evaluationTimeliness", "accidentCauseAccuracy", "guiltyVehicleIdentification", "botRating"], example: { collisionMethodAccuracy: 4, evaluationTimeliness: 3, accidentCauseAccuracy: 5, guiltyVehicleIdentification: 4, botRating: 4 }, }, }) @ApiParam({ name: "publicId" }) @Put("files/:publicId/rating") async rateExpertsByPublicId( @CurrentUser() insurer, @Param("publicId") publicId: string, @Body() rating: FileRating, ) { return await this.expertInsurerService.rateExpertByPublicId(publicId, rating, insurer.clientKey); } @Get("report/status-counts") @ApiOperation({ summary: "Legacy status counts (mapped from unified statuses)", deprecated: true, description: "Prefer GET report/unified-file-statuses for full calculatable blame+claim statuses.", }) @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); } // ─── Expert detail ──────────────────────────────────────────────────────── @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); } }