From 1d51370b3eac843b0d731bb9e64545662ff0b45d Mon Sep 17 00:00:00 2001 From: Soheil Hajizadeh Date: Wed, 8 Apr 2026 16:08:31 +0330 Subject: [PATCH] vehicle data added to get single request by expert , also submit in person --- .../caseStatus.enum.ts | 34 +- src/expert-blame/expert-blame.service.ts | 298 +++++++++++++++--- .../expert-blame.v2.controller.ts | 20 +- 3 files changed, 298 insertions(+), 54 deletions(-) diff --git a/src/Types&Enums/blame-request-management/caseStatus.enum.ts b/src/Types&Enums/blame-request-management/caseStatus.enum.ts index 317cc6d..6c665f2 100644 --- a/src/Types&Enums/blame-request-management/caseStatus.enum.ts +++ b/src/Types&Enums/blame-request-management/caseStatus.enum.ts @@ -1,19 +1,21 @@ //! NEW export enum CaseStatus { - OPEN = "OPEN", - - WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY", - - WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT", - - WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND", - - WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES", - - COMPLETED = "COMPLETED", - - CANCELLED = "CANCELLED", - - AUTO_CLOSED = "AUTO_CLOSED" - } \ No newline at end of file + OPEN = "OPEN", + + WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY", + + WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT", + + WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND", + + WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES", + + COMPLETED = "COMPLETED", + + CANCELLED = "CANCELLED", + + AUTO_CLOSED = "AUTO_CLOSED", + + STOPPED = "STOPPED" +} \ No newline at end of file diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 4442d07..5a2487d 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -67,7 +67,7 @@ export class ExpertBlameService { private readonly expertDbService: ExpertDbService, private readonly blameDocumentDbService: BlameDocumentDbService, private readonly userSignDbService: UserSignDbService, - ) {} + ) { } async findAll(actor: any): Promise { // 1. Fetch all potentially relevant requests from the database. @@ -186,7 +186,7 @@ export class ExpertBlameService { async findAllV2(actor: any): Promise { try { const expertId = actor.sub; - + // Fetch all DISAGREEMENT cases const allCases = await this.blameRequestDbService.find( { @@ -405,9 +405,9 @@ export class ExpertBlameService { // 2. Initial validation to ensure the expert has access // Check if locked by current expert and lock is still active - const isLockedByCurrentExpert = + const isLockedByCurrentExpert = String(request?.actorLocked?.actorId) === actorId && request.lockFile; - + // Check if lock has expired let isLockExpired = false; if (request.unlockTime) { @@ -415,7 +415,7 @@ export class ExpertBlameService { const now = Date.now(); isLockExpired = now >= unlockTime; } - + if (isLockedByCurrentExpert && !isLockExpired) { // This is the correct expert, and the file is locked to them, which is fine. // They can access it even if they closed the browser and came back. @@ -426,7 +426,7 @@ export class ExpertBlameService { // The file is locked by someone else, or lock expired but status hasn't updated yet // Only block if lock is still active and not by current expert if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) { - throw new BadRequestException("Request is locked by another expert"); + throw new BadRequestException("Request is locked by another expert"); } } @@ -520,12 +520,99 @@ export class ExpertBlameService { * Excludes history. Returns only non–CAR_BODY types. Builds file links for all evidence. * Access control: Only allows viewing fresh requests or requests decided by current expert. */ + // async findOneV2(requestId: string, actorId: string): Promise> { + // try { + // const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId); + // if (!doc) { + // throw new NotFoundException("Request not found"); + // } + // const type = doc.type as string; + // if (type === BlameRequestType.CAR_BODY) { + // throw new ForbiddenException( + // "CAR_BODY type requests are automatically handled and do not require expert review.", + // ); + // } + + // // Access control: Expert-initiated files only visible to the initiating field expert + // const expertInitiated = doc.expertInitiated === true; + // const initiatedByFieldExpertId = doc.initiatedByFieldExpertId; + // if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) { + // throw new ForbiddenException( + // "Only the field expert who created this file can view and review it.", + // ); + // } + + // // Allow if: + // // 1. No decision yet (fresh request) + // // 2. Decision made by current expert + // const decision = (doc.expert as any)?.decision; + // const decidedByExpertId = decision?.decidedByExpertId; + // if (decidedByExpertId && String(decidedByExpertId) !== actorId) { + // throw new ForbiddenException( + // "You do not have permission to view this request. It has been handled by another expert.", + // ); + // } + + // const parties = (doc.parties ?? []) as Array<{ + // evidence?: { videoId?: string; voices?: string[] }; + // [key: string]: unknown; + // }>; + // for (const party of parties) { + // if (!party.evidence) continue; + // const evidence = party.evidence as Record; + // if (evidence.videoId) { + // const videoDoc = await this.blameVideoDbService.findById( + // String(evidence.videoId), + // ); + // if (videoDoc?.path) { + // evidence.videoUrl = buildFileLink(videoDoc.path); + // } + // } + // if (evidence.voices && Array.isArray(evidence.voices)) { + // const voiceUrls: string[] = []; + // for (const voiceId of evidence.voices) { + // const voiceDoc = await this.blameVoiceDbService.findById( + // String(voiceId), + // ); + // if (voiceDoc?.path) { + // voiceUrls.push(buildFileLink(voiceDoc.path)); + // } + // } + // evidence.voiceUrls = voiceUrls; + // } + // } + + // const createdAt = doc.createdAt ? new Date(doc.createdAt as string | Date) : new Date(); + // const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | Date) : new Date(); + // const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); + // const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); + // doc.createdAtFormatted = `${createdDate} ${createdTime}`; + // doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`; + + // // Exclude mapped inquiry vehicle for both parties from response + // for (const party of parties) { + // delete party.vehicle; + // } + + // return doc; + // } catch (error) { + // if (error instanceof HttpException) throw error; + // this.logger.error( + // "findOneV2 failed", + // requestId, + // error instanceof Error ? error.stack : String(error), + // ); + // throw new InternalServerErrorException( + // error instanceof Error ? error.message : "Failed to get blame case details", + // ); + // } + // } + async findOneV2(requestId: string, actorId: string): Promise> { try { const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId); - if (!doc) { - throw new NotFoundException("Request not found"); - } + if (!doc) throw new NotFoundException("Request not found"); + const type = doc.type as string; if (type === BlameRequestType.CAR_BODY) { throw new ForbiddenException( @@ -533,7 +620,7 @@ export class ExpertBlameService { ); } - // Access control: Expert-initiated files only visible to the initiating field expert + // Access control const expertInitiated = doc.expertInitiated === true; const initiatedByFieldExpertId = doc.initiatedByFieldExpertId; if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) { @@ -542,9 +629,6 @@ export class ExpertBlameService { ); } - // Allow if: - // 1. No decision yet (fresh request) - // 2. Decision made by current expert const decision = (doc.expert as any)?.decision; const decidedByExpertId = decision?.decidedByExpertId; if (decidedByExpertId && String(decidedByExpertId) !== actorId) { @@ -553,61 +637,83 @@ export class ExpertBlameService { ); } - const parties = (doc.parties ?? []) as Array<{ - evidence?: { videoId?: string; voices?: string[] }; - [key: string]: unknown; + const parties = (doc.parties ?? []); + + const typedParties = parties as Array<{ + vehicle?: { + inquiry?: { + mapped?: any; // Use 'any' or a more specific type if known + // other inquiry properties + }; + // other vehicle properties + }; + evidence?: { // For the second part of your original error description + videoId?: string | number; + voices?: (string | number)[]; + videoUrl?: string; + voiceUrls?: string[]; + }; + // other party properties }>; - for (const party of parties) { + + // Extract inquiry.mapped for each party if needed, but we will keep the full vehicle object. + for (const party of typedParties) { + const mapped = party?.vehicle?.inquiry?.mapped; + // If you still want this field, you can add it like: + // party.inquiryMapped = mapped ?? null; + // But based on your last request, we are keeping the full vehicle object. + } + + // Evidence (videos + voices) + for (const party of typedParties) { if (!party.evidence) continue; const evidence = party.evidence as Record; + if (evidence.videoId) { - const videoDoc = await this.blameVideoDbService.findById( - String(evidence.videoId), - ); - if (videoDoc?.path) { - evidence.videoUrl = buildFileLink(videoDoc.path); - } + const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId)); + if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path); } + if (evidence.voices && Array.isArray(evidence.voices)) { const voiceUrls: string[] = []; for (const voiceId of evidence.voices) { - const voiceDoc = await this.blameVoiceDbService.findById( - String(voiceId), - ); - if (voiceDoc?.path) { - voiceUrls.push(buildFileLink(voiceDoc.path)); - } + const voiceDoc = await this.blameVoiceDbService.findById(String(voiceId)); + if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path)); } evidence.voiceUrls = voiceUrls; } } - const createdAt = doc.createdAt ? new Date(doc.createdAt as string | Date) : new Date(); - const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | Date) : new Date(); + // Date formatting + const createdAt = doc.createdAt ? new Date(doc.createdAt as string | number) : new Date(); + const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | number) : new Date(); + + const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); + doc.createdAtFormatted = `${createdDate} ${createdTime}`; doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`; - // Exclude mapped inquiry vehicle for both parties from response - for (const party of parties) { - delete party.vehicle; - } + // The line `delete party.vehicle;` has been removed, so vehicle will be included. return doc; } catch (error) { if (error instanceof HttpException) throw error; + this.logger.error( "findOneV2 failed", requestId, error instanceof Error ? error.stack : String(error), ); + throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to get blame case details", ); } } + async lockRequest(requestId: string, actorDetail) { const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); @@ -997,6 +1103,124 @@ export class ExpertBlameService { } } + async submitInPerson( + requestId: string, + actorId: string, + ) { + try { + const request = await this.blameRequestDbService.findById(requestId); + + if (!request) { + throw new NotFoundException("Request not found"); + } + + // Validate no decision exists yet + if (request.expert?.decision) { + throw new ForbiddenException( + "This request already has an expert decision.", + ); + } + + // Check if request is locked + if (!request.workflow?.locked) { + throw new ForbiddenException( + "You must lock the request before submitting a reply.", + ); + } + + const lockedByActorId = String(request.workflow?.lockedBy?.actorId || ""); + const lockedAt = request.workflow?.lockedAt; + const isLockedByCurrentActor = lockedByActorId === actorId; + + // Check if lock has expired (15 minutes) + let isLockExpired = false; + if (lockedAt) { + const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000; + isLockExpired = Date.now() > lockExpiryTime; + } + + // Handle different lock scenarios + if (!isLockedByCurrentActor) { + // Request is locked by another expert + if (isLockExpired) { + throw new ForbiddenException( + "You must lock the request first before submitting a reply.", + ); + } else { + throw new ForbiddenException( + "This request is currently locked by another expert.", + ); + } + } + + // Current actor is the lock owner + if (isLockExpired) { + throw new ForbiddenException( + "Your lock time has expired. Please lock the request again before submitting.", + ); + } + + const now = new Date(); + + // Build expert decision with fields + const decisionPayload: any = { + guiltyPartyId: null, + description: "حضوری مراجعه شود.", + decidedAt: now, + decidedByExpertId: new Types.ObjectId(actorId), + fields: { + accidentWay: null, + accidentReason: null, + accidentType: null, + }, + }; + + const updatePayload = { + $set: { + "workflow.locked": false, + "workflow.currentStep": "COMPLETED", + "workflow.nextStep": null, + "expert.decision": decisionPayload, + status: CaseStatus.STOPPED, + }, + $push: { + "workflow.completedSteps": "WAITING_FOR_GUILT_DECISION", + }, + $unset: { + "workflow.lockedAt": "", + "workflow.lockedBy": "", + }, + }; + + const updateResult = await this.blameRequestDbService.findByIdAndUpdate( + requestId, + updatePayload, + ); + + if (!updateResult) { + throw new InternalServerErrorException( + "Failed to update request with expert reply", + ); + } + + return { + requestId: String(request._id), + status: CaseStatus.WAITING_FOR_SIGNATURES, + }; + + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error( + "submitInPerson failed", + requestId, + error instanceof Error ? error.stack : String(error), + ); + throw new InternalServerErrorException( + error instanceof Error ? error.message : "Failed to submit expert reply", + ); + } + } + private async updateDamageExpertStats( expertId: string, requestId: string, @@ -1069,7 +1293,7 @@ export class ExpertBlameService { "Access denied to this request. You are not the locked expert.", ); } - + // Check if lock has expired (unlockTime has passed) if (request.unlockTime) { const unlockTime = new Date(request.unlockTime).getTime(); @@ -1080,7 +1304,7 @@ export class ExpertBlameService { } else if (request.unlockTime == null) { throw new ForbiddenException("Your lock time has expired."); } - + if (!request.lockFile) { throw new ForbiddenException( "You must lock the request before submitting a reply.", diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index ce42b7f..f7a6d56 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -24,7 +24,7 @@ import { ResendRequestDto } from "./dto/resend.dto"; @UseGuards(LocalActorAuthGuard, RolesGuard) @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) { @@ -101,4 +101,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", + ); + } + } }