Added expert field mirror flow

This commit is contained in:
SepehrYahyaee
2026-06-15 11:24:41 +03:30
parent 048398d653
commit 41f81a2f76
39 changed files with 2310 additions and 182 deletions

View File

@@ -18,22 +18,4 @@ export class CreateExpertInitiatedFileDto {
})
@IsEnum(CreationMethod)
creationMethod: CreationMethod;
// Phone numbers for LINK method - users will access files via normal login
@ApiPropertyOptional({
description: "First party phone number. Required for LINK method.",
example: "09123456789",
})
@IsOptional()
@IsString()
firstPartyPhoneNumber?: string;
@ApiPropertyOptional({
description: "Second party phone number. Required for LINK method when type is THIRD_PARTY.",
example: "09187654321",
})
@IsOptional()
@IsString()
secondPartyPhoneNumber?: string;
}

View File

@@ -0,0 +1,45 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator";
/**
* One-at-a-time OTP send for the expert-initiated IN_PERSON flow.
* The expert sends an OTP to a single party's phone, collects the code, then
* verifies. Repeat for the second party (no invite link is used).
*/
export class SendPartyOtpDto {
@ApiProperty({
description: "Phone number of the party to send the OTP to.",
example: "09123456789",
})
@IsString()
@IsNotEmpty()
phoneNumber: string;
}
export class VerifyPartyOtpDto {
@ApiProperty({
description: "Phone number the OTP was sent to.",
example: "09123456789",
})
@IsString()
@IsNotEmpty()
phoneNumber: string;
@ApiProperty({
description: "OTP code collected from the party.",
example: "123456",
})
@IsString()
@IsNotEmpty()
otp: string;
@ApiPropertyOptional({
description:
"Which party this phone belongs to. Optional: when omitted, FIRST is bound if it has no user yet, otherwise SECOND (created if missing).",
enum: ["FIRST", "SECOND"],
example: "FIRST",
})
@IsOptional()
@IsIn(["FIRST", "SECOND"])
partyRole?: "FIRST" | "SECOND";
}

View File

@@ -3,11 +3,10 @@ import { IsNotEmpty, IsString } from "class-validator";
export class SendExpertInitiatedLinkV2Dto {
@ApiProperty({
description:
"Frontend route path (same as add-second-party flow), e.g. requestManagement/firstParty",
example: "requestManagement/firstParty",
description: "First party phone number. The link SMS will be sent to this number.",
example: "09123456789",
})
@IsString()
@IsNotEmpty()
frontendRoute: string;
phoneNumber: string;
}

View File

@@ -0,0 +1,487 @@
import { extname } from "node:path";
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Put,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
ApiTags,
} from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
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 { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import {
BlameConfessionDtoV2,
CarBodyFormDto,
DescriptionDto,
LocationDto,
} from "./dto/create-request-management.dto";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
import { SendExpertInitiatedLinkV2Dto } from "./dto/send-expert-initiated-link.v2.dto";
import { RequestManagementService } from "./request-management.service";
import { PartyRole } from "./entities/schema/partyRole.enum";
/**
* Expert-initiated IN_PERSON blame flow that mirrors the normal user blame API
* (`v2/blame-request-management`) one-to-one, so the frontend can reuse the same
* pages by only swapping the route prefix:
*
* `v2/blame-request-management/*` -> `v2/expert-initiated/blame-request-management/*`
*
* The field expert is on the accident scene and fills every step on behalf of
* both parties using the exact same endpoints/bodies as a normal user. By
* contract the FIRST party the expert registers is always the guilty party, and
* the blame is finalized (guilt decided + ready for signatures) without any
* waiting-for-expert phase or invite links.
*
* Sequence: create -> send/verify party OTPs -> first party steps
* (blame-confession, initial-form, upload-video, add-detail-location,
* upload-voice, add-detail-description) -> add-second-party (no link) ->
* second party steps -> sign FIRST + sign SECOND -> COMPLETED.
*/
@ApiTags("expert-initiated blame (mirror v2)")
@Controller("v2/expert-initiated/blame-request-management")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FIELD_EXPERT)
export class ExpertInitiatedBlameMirrorController {
constructor(
private readonly requestManagementService: RequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
) {}
@Post()
@ApiOperation({
summary: "[Expert mirror] Create expert-initiated blame file",
description:
"Use creationMethod=IN_PERSON for the on-site flow. Creates a BlameRequest owned by the parties but filled by the expert.",
})
@ApiBody({ type: CreateExpertInitiatedFileDto })
async create(
@CurrentUser() expert: any,
@Body() dto: CreateExpertInitiatedFileDto,
) {
return this.requestManagementService.createExpertInitiatedBlameV2(
expert,
dto,
);
}
@Get()
@ApiOperation({
summary: "[Expert mirror] List my expert-initiated blame files",
})
async list(@CurrentUser() expert: any) {
return this.requestManagementService.getMyExpertInitiatedFilesV2(expert);
}
@Post("send-link/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: SendExpertInitiatedLinkV2Dto })
@ApiOperation({
summary: "[Expert mirror] Send blame link to first party (LINK method)",
description:
"For files created with creationMethod=LINK. Provide the first party phone number; the service stores it, registers the user if needed, and sends the invite link via SMS.",
})
async sendLink(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() dto: SendExpertInitiatedLinkV2Dto,
) {
return this.requestManagementService.sendLinkV2(expert, requestId, dto);
}
@Post("send-party-otp/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: SendPartyOtpDto })
@ApiOperation({
summary: "[Expert mirror] Send OTP to one party (one-at-a-time IN_PERSON)",
description:
"Sends an OTP to a single phone number. Use this for the sequential flow: send to first party -> verify -> fill data -> send to second party -> verify -> continue. No invite link is sent.",
})
async sendPartyOtp(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() dto: SendPartyOtpDto,
) {
return this.requestManagementService.sendPartyOtpV2(expert, requestId, dto);
}
@Post("verify-party-otp/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: VerifyPartyOtpDto })
@ApiOperation({
summary: "[Expert mirror] Verify one party's OTP (one-at-a-time IN_PERSON)",
description:
"Verifies a single party's OTP and binds their account. `partyRole` is optional (inferred as FIRST until the first party is bound, then SECOND).",
})
async verifyPartyOtp(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() dto: VerifyPartyOtpDto,
) {
return this.requestManagementService.verifyPartyOtpV2(
expert,
requestId,
dto,
);
}
// @Post("send-party-otps/:requestId")
// @ApiParam({ name: "requestId" })
// @ApiBody({ type: SendPartyOtpsDto })
// @ApiOperation({
// summary: "[Expert mirror] Send OTP to party/parties (IN_PERSON)",
// description:
// "Bind the parties' user accounts before filling their data. Required so guilt and the claim owner can be resolved.",
// })
// async sendPartyOtps(
// @CurrentUser() expert: any,
// @Param("requestId") requestId: string,
// @Body() dto: SendPartyOtpsDto,
// ) {
// return this.requestManagementService.sendPartyOtpsV2(expert, requestId, dto);
// }
// @Post("verify-party-otps/:requestId")
// @ApiParam({ name: "requestId" })
// @ApiBody({ type: VerifyPartyOtpsDto })
// @ApiOperation({ summary: "[Expert mirror] Verify party OTPs (IN_PERSON)" })
// async verifyPartyOtps(
// @CurrentUser() expert: any,
// @Param("requestId") requestId: string,
// @Body() dto: VerifyPartyOtpsDto,
// ) {
// return this.requestManagementService.verifyPartyOtpsV2(
// expert,
// requestId,
// dto,
// );
// }
// @Post("/blame-confession/:requestId")
// @ApiParam({ name: "requestId" })
// @ApiBody({ type: BlameConfessionDtoV2 })
// @ApiOperation({
// summary: "[Expert mirror] First-party blame confession",
// description:
// "THIRD_PARTY step. The FIRST party is the guilty party, so send imGuilty=true.",
// })
// async blameConfession(
// @Param("requestId") requestId: string,
// @Body() body: BlameConfessionDtoV2,
// @CurrentUser() expert: any,
// @Body("partyRole") partyRole?: string,
// ) {
// return this.requestManagementService.blameConfessionV2(
// requestId,
// body,
// expert,
// partyRole,
// );
// }
@Post("/car-body-form/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: CarBodyFormDto })
@ApiOperation({ summary: "[Expert mirror] CAR_BODY accident type" })
async carBodyForm(
@Param("requestId") requestId: string,
@Body() body: CarBodyFormDto,
@CurrentUser() expert: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.carBodyAccidentTypeFormV2(
requestId,
body,
expert,
partyRole,
);
}
@Post("/initial-form/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: AddPlateDto })
@ApiOperation({
summary: "[Expert mirror] Initial form (plate/insurance) for current party",
description:
"Fills the FIRST or SECOND party plate/insurance/vehicle data depending on the current workflow step.",
})
async initialForm(
@Param("requestId") requestId: string,
@Body() body: AddPlateDto,
@CurrentUser() expert: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.initialFormV2(
requestId,
body,
expert,
partyRole,
);
}
@ApiBody({
schema: {
type: "object",
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/video",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `expert-${file.originalname}-${unique}${ex}`);
},
}),
}),
)
@ApiParam({ name: "requestId" })
@Post("upload-video/:requestId")
@ApiOperation({ summary: "[Expert mirror] Upload first-party video" })
async uploadVideo(
@Param("requestId") requestId: string,
@CurrentUser() expert: any,
@UploadedFile() file?: Express.Multer.File,
) {
await this.mediaPolicyService.assertForBlame(file, requestId, "video");
return this.requestManagementService.uploadFirstPartyVideoV2(
requestId,
file,
expert,
);
}
@Post("/add-detail-location/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: LocationDto })
@ApiOperation({ summary: "[Expert mirror] Add location for current party" })
async addLocation(
@Param("requestId") requestId: string,
@Body() body: LocationDto,
@CurrentUser() expert: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.addDetailLocationV2(
requestId,
body,
expert,
partyRole,
);
}
@ApiBody({
schema: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
partyRole: {
type: "string",
enum: ["FIRST", "SECOND"],
description:
"Optional explicit party selector; must match the party the file is currently collecting (FIRST for these steps).",
},
},
},
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/voice",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const flname = file.originalname.split(".")[0];
callback(null, `expert-${flname}-${unique}${ex}`);
},
}),
}),
)
@ApiParam({ name: "requestId" })
@Post("upload-voice/:requestId")
@ApiOperation({ summary: "[Expert mirror] Upload voice for current party" })
async uploadVoice(
@Param("requestId") requestId: string,
@CurrentUser() expert: any,
@UploadedFile() voice?: Express.Multer.File,
@Body("partyRole") partyRole?: string,
) {
await this.mediaPolicyService.assertForBlame(voice, requestId, "voice");
return this.requestManagementService.uploadVoiceV2(
requestId,
voice,
expert,
partyRole,
);
}
@Post("/add-detail-description/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: DescriptionDto })
@ApiOperation({
summary: "[Expert mirror] Add description for current party",
description:
"For THIRD_PARTY, submitting the SECOND party description finalizes guilt (FIRST party = guilty) and moves the file straight to WAITING_FOR_SIGNATURES.",
})
async addDescription(
@Param("requestId") requestId: string,
@Body() body: DescriptionDto,
@CurrentUser() expert: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.addDescriptionV2(
requestId,
body,
expert,
partyRole,
);
}
@Post("add-second-party/:phoneNumber/:requestId/")
@ApiParam({ name: "phoneNumber" })
@ApiParam({ name: "requestId" })
@ApiOperation({
summary: "[Expert mirror] Advance to second party (no SMS link)",
description:
"IN_PERSON variant of add-second-party: there is no invite link. Advances the workflow so the expert can fill the second party's steps. `frontendRoute` is accepted for route compatibility but ignored.",
})
async addSecondParty(
@Param("phoneNumber") phoneNumber: string,
@Param("requestId") requestId: string,
@CurrentUser() expert: any,
) {
return this.requestManagementService.expertAdvanceToSecondPartyV2(
requestId,
expert,
phoneNumber,
);
}
@Put("sign/:requestId")
@ApiParam({ name: "requestId" })
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "[Expert mirror] Upload a party's on-site signature",
description:
"Collect each party's signature on site. CAR_BODY: partyRole=FIRST once. THIRD_PARTY: upload FIRST then SECOND. When all required parties have signed (accepted), the blame case completes.",
})
@ApiBody({
schema: {
type: "object",
required: ["partyRole", "sign"],
properties: {
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
isAccept: { type: "boolean", default: true },
sign: { type: "string", format: "binary" },
},
},
})
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/signs",
filename: (req, file, callback) => {
const unique = Date.now();
const ext = extname(file.originalname);
callback(null, `expert-party-${unique}${ext}`);
},
}),
}),
)
async sign(
@Param("requestId") requestId: string,
@Body() body: { partyRole?: string; isAccept?: string | boolean },
@CurrentUser() expert: any,
@UploadedFile() sign: Express.Multer.File,
) {
// Guard: accident fields (accidentWay, etc.) must be filled before signing.
// This prevents the expert from bypassing add-accident-fields and avoids the
// double-sign bug where add-accident-fields re-enters WAITING_FOR_SIGNATURES
// after a signature was already recorded.
const requestData = await this.requestManagementService.getBlameRequestV2(
requestId,
expert,
);
if (!(requestData?.expert?.decision as any)?.fields?.accidentWay) {
throw new BadRequestException(
"Accident fields (accidentWay, accidentReason, accidentType) must be submitted via add-accident-fields before signing.",
);
}
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
const partyRole =
body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
const isAccept = !(body?.isAccept === false || body?.isAccept === "false");
return this.requestManagementService.expertUploadPartySignatureV2(
expert,
requestId,
partyRole,
isAccept,
sign,
);
}
@Post("add-accident-fields/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: ExpertAccidentFieldsDto })
@ApiOperation({
summary: "[Expert mirror] Add accident fields and advance to signatures",
description:
"Saves accidentWay, accidentReason, and accidentType for the blame file. " +
"In an IN_PERSON expert-initiated flow the first party is always guilty, so " +
"calling this endpoint both records the accident details and advances the workflow " +
"from WAITING_FOR_GUILT_DECISION straight to WAITING_FOR_SIGNATURES.",
})
async addAccidentFields(
@Param("requestId") requestId: string,
@Body() fields: ExpertAccidentFieldsDto,
@CurrentUser() expert: any,
) {
return this.requestManagementService.expertAddAccidentFieldsForBlameV2(
expert,
requestId,
fields,
);
}
@Get(":requestId")
@ApiParam({ name: "requestId" })
@ApiOperation({ summary: "[Expert mirror] Get one blame request" })
async getOne(
@Param("requestId") requestId: string,
@CurrentUser() expert: any,
) {
return this.requestManagementService.getBlameRequestV2(requestId, expert);
}
}

View File

@@ -36,8 +36,8 @@ import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto
import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto";
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
import { ExpertCompleteLocationV2Dto } from "./dto/expert-complete-location.v2.dto";
import { SendExpertInitiatedLinkV2Dto } from "./dto/send-expert-initiated-link.v2.dto";
import { ExpertCompleteLocationV2Dto } from "./dto/expert-complete-location.v2.dto";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import { PartyRole } from "./entities/schema/partyRole.enum";
@@ -158,34 +158,12 @@ export class ExpertInitiatedV2Controller {
@Post("send-link/:requestId")
@ApiOperation({
summary: "[V2] Send blame link to party/parties (LINK)",
summary: "[V2] Send blame link to first party (LINK)",
description:
"For expert-initiated LINK files only. Sends SMS with template `yara-field-expert-link` to first party (and second party for THIRD_PARTY). Tokens: token=#1(بدنه/ثالث), token2=#2(expert name), token3=#3(invite link built exactly like add-second-party flow: `${URL}/{frontendRoute}?token={requestId}`).",
"For expert-initiated LINK files only. Provide the first party phone number; the service stores it, registers the user if needed, and sends the invite link via SMS.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({ type: SendExpertInitiatedLinkV2Dto })
@ApiResponse({
status: 200,
description: "Link sent; recipients can open and fill via normal user flow",
schema: {
type: "object",
properties: {
sent: { type: "boolean" },
linkUrl: { type: "string" },
sentTo: {
type: "array",
items: {
type: "object",
properties: {
role: { type: "string", enum: ["FIRST", "SECOND"] },
phoneNumber: { type: "string" },
smsSent: { type: "boolean" },
},
},
},
},
},
})
async sendLinkV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,

View File

@@ -39,6 +39,7 @@ import { RequestManagementController } from "./request-management.controller";
import { RequestManagementV2Controller } from "./request-management.v2.controller";
import { ExpertInitiatedController } from "./expert-initiated.controller";
import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.mirror.controller";
import { RegistrarInitiatedController } from "./registrar-initiated.controller";
@Module({
@@ -72,6 +73,7 @@ import { RegistrarInitiatedController } from "./registrar-initiated.controller";
RequestManagementV2Controller,
ExpertInitiatedController,
ExpertInitiatedV2Controller,
ExpertInitiatedBlameMirrorController,
RegistrarInitiatedController,
],
providers: [

View File

@@ -181,7 +181,34 @@ export class RequestManagementService {
);
}
private assertPartyOwner(party: any, user: any, errMsg: string) {
/**
* True when `user` is the field-expert/registrar who created this IN_PERSON
* file and is therefore allowed to act on behalf of its parties (fill the
* normal granular steps for both parties). Normal USER callers never match.
*/
private isBlameOnBehalfActor(req: any, user: any): boolean {
if (!req || !user) return false;
if (req.creationMethod !== CreationMethod.IN_PERSON) return false;
if (user.role === RoleEnum.FIELD_EXPERT) {
return (
!!req.expertInitiated &&
!!req.initiatedByFieldExpertId &&
String(req.initiatedByFieldExpertId) === String(user.sub)
);
}
if (user.role === RoleEnum.REGISTRAR) {
return (
!!req.registrarInitiated &&
!!req.initiatedByRegistrarId &&
String(req.initiatedByRegistrarId) === String(user.sub)
);
}
return false;
}
private assertPartyOwner(party: any, user: any, errMsg: string, req?: any) {
// The initiating field-expert/registrar fills steps on behalf of parties.
if (req && this.isBlameOnBehalfActor(req, user)) return;
const partyUserId = party?.person?.userId
? String(party.person.userId)
: null;
@@ -192,6 +219,30 @@ export class RequestManagementService {
if (!ok) throw new ForbiddenException(errMsg);
}
/**
* For the on-behalf (expert/registrar) flow only: when the caller explicitly
* labels which party a submission belongs to (`partyRole`), make sure it
* matches the party the file is currently collecting data for. Keeps the
* sequential workflow intact while guaranteeing the expert never attributes a
* submission to the wrong party. No-op for normal users (who never pass it).
*/
private assertExpectedPartyRole(
expectedRole: string | undefined,
actualRole: PartyRole,
onBehalf: boolean,
) {
if (!onBehalf || expectedRole == null || expectedRole === "") return;
const want =
String(expectedRole).toUpperCase() === "SECOND"
? PartyRole.SECOND
: PartyRole.FIRST;
if (want !== actualRole) {
throw new BadRequestException(
`partyRole mismatch: this file is currently collecting the ${actualRole} party's data, but partyRole=${want} was sent. Submit ${actualRole} party data (advance the file to the other party first if needed).`,
);
}
}
private async advanceWorkflowToNext(
req: any,
submittedStepKey: WorkflowStep,
@@ -610,6 +661,7 @@ export class RequestManagementService {
requestId: string,
body: { imGuilty?: boolean; imDamaged?: boolean; expertOpinion?: boolean },
user: any,
expectedRole?: string,
) {
const step2 = await this.getWorkflowStep({ stepNumber: 2 });
const step2Key = step2.stepKey as WorkflowStep;
@@ -653,13 +705,23 @@ export class RequestManagementService {
(firstParty?.person?.phoneNumber &&
firstParty.person.phoneNumber === user?.username);
if (!isOwner) {
if (!isOwner && !this.isBlameOnBehalfActor(req, user)) {
throw new ForbiddenException("Only first party can submit this step");
}
this.assertExpectedPartyRole(
expectedRole,
PartyRole.FIRST,
this.isBlameOnBehalfActor(req, user),
);
// Set userId for first party if not already set
if (!firstParty.person) firstParty.person = {} as any;
if (!firstParty.person.userId && user?.sub) {
if (
!firstParty.person.userId &&
user?.sub &&
!this.isBlameOnBehalfActor(req, user)
) {
firstParty.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
@@ -755,6 +817,7 @@ export class RequestManagementService {
requestId: string,
body: { car?: boolean; object?: boolean },
user: any,
expectedRole?: string,
) {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
@@ -763,12 +826,17 @@ export class RequestManagementService {
"This endpoint is only for CAR_BODY type requests",
);
}
if (!req.workflow?.nextStep) {
if (!req.workflow?.currentStep) {
throw new BadRequestException("Request workflow is not initialized");
}
if (req.workflow.nextStep !== WorkflowStep.CAR_BODY_ACCIDENT_TYPE) {
// Normal user flow: currentStep=CREATED, nextStep=CAR_BODY_ACCIDENT_TYPE
// Expert IN_PERSON: currentStep=CAR_BODY_ACCIDENT_TYPE, nextStep=FIRST_VIDEO
const isCorrectStep =
req.workflow.currentStep === WorkflowStep.CAR_BODY_ACCIDENT_TYPE ||
req.workflow.nextStep === WorkflowStep.CAR_BODY_ACCIDENT_TYPE;
if (!isCorrectStep) {
throw new BadRequestException(
`Invalid step. Expected nextStep=CAR_BODY_ACCIDENT_TYPE but got ${req.workflow.nextStep}`,
`Invalid step. Expected currentStep or nextStep to be CAR_BODY_ACCIDENT_TYPE but got currentStep=${req.workflow.currentStep}, nextStep=${req.workflow.nextStep}`,
);
}
@@ -780,10 +848,21 @@ export class RequestManagementService {
firstParty,
user,
"Only first party can submit this step",
req,
);
this.assertExpectedPartyRole(
expectedRole,
PartyRole.FIRST,
this.isBlameOnBehalfActor(req, user),
);
if (!firstParty.person) firstParty.person = {} as any;
if (!firstParty.person.userId && user?.sub) {
if (
!firstParty.person.userId &&
user?.sub &&
!this.isBlameOnBehalfActor(req, user)
) {
firstParty.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
@@ -869,7 +948,7 @@ export class RequestManagementService {
(firstParty?.person?.phoneNumber &&
firstParty.person.phoneNumber === user?.username);
if (!isOwner) {
if (!isOwner && !this.isBlameOnBehalfActor(req, user)) {
throw new ForbiddenException("Only first party can upload this video");
}
@@ -944,7 +1023,12 @@ export class RequestManagementService {
};
}
async initialFormV2(requestId: string, body: AddPlateDto, user: any) {
async initialFormV2(
requestId: string,
body: AddPlateDto,
user: any,
expectedRole?: string,
) {
try {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) {
@@ -977,11 +1061,22 @@ export class RequestManagementService {
party,
user,
`Only ${role} party can submit this step`,
req,
);
this.assertExpectedPartyRole(
expectedRole,
role,
this.isBlameOnBehalfActor(req, user),
);
// Set userId for party if not already set (important for second party)
if (!party.person) party.person = {} as any;
if (!party.person.userId && user?.sub) {
if (
!party.person.userId &&
user?.sub &&
!this.isBlameOnBehalfActor(req, user)
) {
party.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
@@ -1333,7 +1428,12 @@ export class RequestManagementService {
}
}
async addDetailLocationV2(requestId: string, body: LocationDto, user: any) {
async addDetailLocationV2(
requestId: string,
body: LocationDto,
user: any,
expectedRole?: string,
) {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.workflow?.currentStep) {
@@ -1359,11 +1459,22 @@ export class RequestManagementService {
party,
user,
"Only the related party can submit location",
req,
);
this.assertExpectedPartyRole(
expectedRole,
role,
this.isBlameOnBehalfActor(req, user),
);
// Set userId for party if not already set (important for second party)
if (!party.person) party.person = {} as any;
if (!party.person.userId && user?.sub) {
if (
!party.person.userId &&
user?.sub &&
!this.isBlameOnBehalfActor(req, user)
) {
party.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
@@ -1399,6 +1510,7 @@ export class RequestManagementService {
requestId: string,
voice: Express.Multer.File,
user: any,
expectedRole?: string,
) {
if (!voice) throw new BadRequestException("File is required");
@@ -1427,11 +1539,22 @@ export class RequestManagementService {
party,
user,
"Only the related party can upload voice",
req,
);
this.assertExpectedPartyRole(
expectedRole,
role,
this.isBlameOnBehalfActor(req, user),
);
// Set userId for party if not already set (important for second party)
if (!party.person) party.person = {} as any;
if (!party.person.userId && user?.sub) {
if (
!party.person.userId &&
user?.sub &&
!this.isBlameOnBehalfActor(req, user)
) {
party.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
@@ -1477,7 +1600,12 @@ export class RequestManagementService {
};
}
async addDescriptionV2(requestId: string, body: DescriptionDto, user: any) {
async addDescriptionV2(
requestId: string,
body: DescriptionDto,
user: any,
expectedRole?: string,
) {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.workflow?.currentStep) {
@@ -1503,11 +1631,22 @@ export class RequestManagementService {
party,
user,
"Only the related party can submit description",
req,
);
this.assertExpectedPartyRole(
expectedRole,
role,
this.isBlameOnBehalfActor(req, user),
);
// Set userId for party if not already set (important for second party)
if (!party.person) party.person = {} as any;
if (!party.person.userId && user?.sub) {
if (
!party.person.userId &&
user?.sub &&
!this.isBlameOnBehalfActor(req, user)
) {
party.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
@@ -1587,7 +1726,8 @@ export class RequestManagementService {
isSecondThirdParty &&
closedByMutualAgreement &&
req.status === CaseStatus.WAITING_FOR_SIGNATURES &&
req.blameStatus === BlameStatus.AGREED
req.blameStatus === BlameStatus.AGREED &&
!this.isBlameOnBehalfActor(req, user)
) {
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
@@ -1628,7 +1768,45 @@ export class RequestManagementService {
}
}
if (!closedByMutualAgreement) {
const expertOnBehalfThirdPartySecond =
!closedByMutualAgreement &&
isSecondThirdParty &&
this.isBlameOnBehalfActor(req, user);
if (expertOnBehalfThirdPartySecond) {
// Expert-initiated IN_PERSON: the field expert is on the accident scene
// and decides guilt directly. By contract the FIRST party (the one the
// expert registers first) is always the guilty party, so we record the
// decision here and move straight to signatures — there is no
// waiting-for-field-expert phase for these files.
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
const guiltyUserId = req.parties?.[firstIdx]?.person?.userId;
if (!guiltyUserId) {
throw new BadRequestException(
"First party must be verified (OTP) before finishing the blame file.",
);
}
const guiltyPhone = (req.parties?.[firstIdx]?.person as any)?.phoneNumber;
const damagedPhone = (req.parties?.[secondIdx]?.person as any)
?.phoneNumber;
if (!(req as any).expert) (req as any).expert = {};
(req as any).expert.decision = {
guiltyPartyId: new Types.ObjectId(String(guiltyUserId)),
description: `مقصر توسط کارشناس در محل حادثه مشخص شد. کاربر ${guiltyPhone ?? ""} مقصر و کاربر ${damagedPhone ?? ""} زیان دیده می‌باشد.`,
} as any;
const completed = Array.isArray(req.workflow.completedSteps)
? req.workflow.completedSteps
: [];
if (!completed.includes(stepKey)) completed.push(stepKey);
if (!completed.includes(WorkflowStep.WAITING_FOR_GUILT_DECISION)) {
completed.push(WorkflowStep.WAITING_FOR_GUILT_DECISION);
}
req.workflow.completedSteps = completed;
req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES;
req.workflow.nextStep = undefined;
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
} else if (!closedByMutualAgreement) {
if (stepKey === WorkflowStep.SECOND_DESCRIPTION) {
req.status = CaseStatus.WAITING_FOR_EXPERT;
}
@@ -1711,6 +1889,7 @@ export class RequestManagementService {
firstParty,
user,
"Only first party can invite second party",
req,
);
// Validate second party phone number is different
@@ -1823,6 +2002,113 @@ export class RequestManagementService {
}
}
/**
* Expert-initiated IN_PERSON variant of `addSecondPartyV2`.
*
* The expert is physically with both parties, so there is no invite link/SMS:
* we simply ensure the SECOND party exists and advance the workflow from
* FIRST_INVITE_SECOND to SECOND_INITIAL_FORM so the expert can fill the
* second party's steps using the same granular endpoints.
*/
async expertAdvanceToSecondPartyV2(
requestId: string,
user: any,
phoneNumber?: string,
) {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!this.isBlameOnBehalfActor(req, user)) {
throw new ForbiddenException(
"Only the initiating expert/registrar can advance this in-person file.",
);
}
if (req.type !== BlameRequestType.THIRD_PARTY) {
throw new BadRequestException(
"Only THIRD_PARTY files have a second party.",
);
}
if (!req.workflow?.currentStep) {
throw new BadRequestException("Request workflow is not initialized");
}
const stepKey = req.workflow.currentStep as WorkflowStep;
if (stepKey !== WorkflowStep.FIRST_INVITE_SECOND) {
throw new BadRequestException(
`Invalid step. Expected FIRST_INVITE_SECOND but got ${stepKey}`,
);
}
if (!phoneNumber) {
throw new BadRequestException(
"phoneNumber is required to register the second party",
);
}
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (
firstIdx !== -1 &&
(req.parties[firstIdx]?.person as any)?.phoneNumber === phoneNumber
) {
throw new BadRequestException(
"Second party phone number cannot be the same as first party",
);
}
// Create second party placeholder (no userId yet — will be bound after OTP verify)
let secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
if (secondIdx === -1) {
if (!Array.isArray(req.parties)) req.parties = [];
req.parties.push({
role: PartyRole.SECOND,
person: { phoneNumber } as any,
} as any);
} else {
if (!req.parties[secondIdx].person)
req.parties[secondIdx].person = {} as any;
(req.parties[secondIdx].person as any).phoneNumber = phoneNumber;
}
// Send OTP to second party — expert collects it from them in person
try {
await this.userAuthService.sendOtpRequest(phoneNumber);
} catch (e: any) {
if (e?.message?.includes("Wait for expiry")) {
throw new BadRequestException(
`(${phoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`,
);
}
throw e;
}
// Stay at FIRST_INVITE_SECOND — workflow will advance only after verifyPartyOtp
req.status = CaseStatus.WAITING_FOR_SECOND_PARTY;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "SECOND_PARTY_OTP_SENT",
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: {
inPersonExpert: true,
phoneNumber,
note: "OTP sent; call verify-party-otp to bind and advance to SECOND_INITIAL_FORM",
},
} as any);
await (req as any).save();
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
message: `OTP sent to ${phoneNumber}. Collect the code and call verify-party-otp.`,
};
}
// async createRequest(user, type) {
// const reqNumber = new ShortUniqueId({ counter: 1 });
// const publicId = await this.publicIdService.generateRequestPublicId();
@@ -4036,30 +4322,6 @@ export class RequestManagementService {
? BlameRequestType.CAR_BODY
: BlameRequestType.THIRD_PARTY;
if (dto.creationMethod === CreationMethod.LINK) {
if (!dto.firstPartyPhoneNumber) {
throw new BadRequestException(
"First party phone number is required for LINK method",
);
}
if (
type === BlameRequestType.THIRD_PARTY &&
!dto.secondPartyPhoneNumber
) {
throw new BadRequestException(
"Second party phone number is required for LINK method with THIRD_PARTY type",
);
}
if (
type === BlameRequestType.THIRD_PARTY &&
dto.firstPartyPhoneNumber === dto.secondPartyPhoneNumber
) {
throw new BadRequestException(
"First and second party phone numbers must be different",
);
}
}
const firstStep = await this.getWorkflowStep({ stepNumber: 1 });
if (!firstStep?.stepKey) {
throw new InternalServerErrorException(
@@ -4079,37 +4341,10 @@ export class RequestManagementService {
const publicId = await this.publicIdService.generateRequestPublicId();
const parties: any[] = [];
if (dto.creationMethod === CreationMethod.LINK) {
const firstUserId = await this.getOrCreateUserByPhoneNumber(
dto.firstPartyPhoneNumber,
);
parties.push({
role: PartyRole.FIRST,
person: {
userId: firstUserId,
phoneNumber: dto.firstPartyPhoneNumber,
},
});
if (type === BlameRequestType.THIRD_PARTY && dto.secondPartyPhoneNumber) {
const secondUserId = await this.getOrCreateUserByPhoneNumber(
dto.secondPartyPhoneNumber,
);
parties.push({
role: PartyRole.SECOND,
person: {
userId: secondUserId,
phoneNumber: dto.secondPartyPhoneNumber,
},
});
}
} else {
parties.push({
role: PartyRole.FIRST,
person: {},
});
}
// Always start with an empty first-party placeholder.
// For LINK files the phone + userId are stored when send-link is called.
// For IN_PERSON files the phone + userId are stored when verify-party-otp is called.
const parties: any[] = [{ role: PartyRole.FIRST, person: {} }];
const created = await this.blameRequestDbService.create({
publicId,
@@ -4170,63 +4405,49 @@ export class RequestManagementService {
async sendLinkV2(
expert: any,
requestId: string,
dto: { frontendRoute: string },
dto: { phoneNumber: string },
): Promise<{
sent: boolean;
linkUrl: string;
sentTo: { role: string; phoneNumber: string; smsSent: boolean }[];
sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[];
}> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.expertInitiated || req.creationMethod !== CreationMethod.LINK) {
throw new BadRequestException(
"This endpoint is only for expert-initiated LINK blame files. Create the file with creationMethod LINK first.",
"This endpoint is only for expert-initiated LINK blame files.",
);
}
this.verifyExpertAccessForBlameV2(req, expert);
if (!dto?.frontendRoute) {
throw new BadRequestException("frontendRoute is required");
}
const phone = (dto?.phoneNumber || "").trim();
if (!phone) throw new BadRequestException("phoneNumber is required");
if (!process.env.URL) {
throw new InternalServerErrorException(
"URL environment variable is not configured",
);
}
const linkUrl = this.smsOrchestrationService.buildInviteLink(
dto.frontendRoute,
requestId,
);
const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس";
const sentTo: { role: string; phoneNumber: string; smsSent: boolean }[] =
[];
const sendSms = async (
phone: string,
role: PartyRole,
): Promise<boolean> => {
return this.smsOrchestrationService.sendFieldExpertLink({
receptor: phone,
type: req.type,
expertLastName: expertName,
link: linkUrl,
});
};
// Store / update the first party's phone and bind a user account
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx !== -1 && req.parties[firstIdx]?.person?.phoneNumber) {
const phone = req.parties[firstIdx].person.phoneNumber;
const smsSent = await sendSms(phone, PartyRole.FIRST);
sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone, smsSent });
}
if (req.type === BlameRequestType.THIRD_PARTY) {
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
if (secondIdx !== -1 && req.parties[secondIdx]?.person?.phoneNumber) {
const phone = req.parties[secondIdx].person.phoneNumber;
const smsSent = await sendSms(phone, PartyRole.SECOND);
sentTo.push({ role: PartyRole.SECOND, phoneNumber: phone, smsSent });
}
}
if (firstIdx === -1)
throw new BadRequestException("First party not found on request");
const userId = await this.getOrCreateUserByPhoneNumber(phone);
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
req.parties[firstIdx].person.phoneNumber = phone;
req.parties[firstIdx].person.userId = userId;
const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس";
const sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[] = [];
const firstLink = this.smsOrchestrationService.buildBlamePartyLink(requestId, "FIRST");
const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({
receptor: phone,
type: req.type,
expertLastName: expertName,
link: firstLink,
});
sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone, smsSent, linkUrl: firstLink });
if (!Array.isArray((req as any).history)) (req as any).history = [];
(req as any).history.push({
@@ -4236,18 +4457,12 @@ export class RequestManagementService {
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: {
linkUrl,
sentTo,
template: "yara-field-expert-link",
frontendRoute: dto.frontendRoute,
},
metadata: { sentTo, template: "yara-field-expert-link" },
});
await (req as any).save();
return {
sent: sentTo.length > 0 && sentTo.every((x) => x.smsSent),
linkUrl,
sentTo,
};
}
@@ -4440,6 +4655,279 @@ export class RequestManagementService {
};
}
/**
* Send an OTP to a single party phone (one-at-a-time IN_PERSON flow).
* The expert sends to the first party, collects the code and verifies, fills
* their data, then enters the second party's phone and repeats — instead of
* sending an invite link, the OTP registers/authenticates the second party.
*/
async sendPartyOtpV2(
actor: any,
requestId: string,
dto: { phoneNumber: string },
): Promise<{ sent: boolean; message: string }> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (
(!req.expertInitiated && !req.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON blame files.",
);
}
this.verifyExpertAccessForBlameV2(req, actor);
const phone = (dto?.phoneNumber || "").trim();
if (!phone) throw new BadRequestException("phoneNumber is required");
try {
await this.userAuthService.sendOtpRequest(phone);
} catch (e: any) {
if (e?.message?.includes("Wait for expiry")) {
throw new BadRequestException(
`(${phone}): OTP was recently sent. Wait for the expiry time before requesting again.`,
);
}
throw e;
}
return {
sent: true,
message: `OTP sent to ${phone}. Collect the code from the party, then call verify-party-otp.`,
};
}
/**
* Verify a single party OTP and bind that party's user account.
* `partyRole` is optional: when omitted, FIRST is bound if it has no user yet,
* otherwise SECOND (created if missing). For THIRD_PARTY the second party is
* registered here instead of receiving an invite link.
*/
async verifyPartyOtpV2(
actor: any,
requestId: string,
dto: { phoneNumber: string; otp: string; partyRole?: string },
): Promise<{ verified: boolean; partyRole: PartyRole; message: string }> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (
(!req.expertInitiated && !req.registrarInitiated) ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON blame files.",
);
}
this.verifyExpertAccessForBlameV2(req, actor);
const phone = (dto?.phoneNumber || "").trim();
const otp = (dto?.otp || "").trim();
if (!phone || !otp) {
throw new BadRequestException("phoneNumber and otp are required");
}
if (!Array.isArray(req.parties)) req.parties = [];
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
// Resolve which party this phone belongs to.
let role: PartyRole;
if (dto.partyRole === "SECOND" || dto.partyRole === "FIRST") {
role = dto.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
} else {
const firstHasUser = !!(
firstIdx !== -1 && req.parties[firstIdx]?.person?.userId
);
role = firstHasUser ? PartyRole.SECOND : PartyRole.FIRST;
}
if (
role === PartyRole.SECOND &&
req.type !== BlameRequestType.THIRD_PARTY
) {
throw new BadRequestException("CAR_BODY has only one (first) party.");
}
// Verify the OTP and resolve the user id.
const now = Date.now();
const user = await this.userDbService.findOne({
$or: [{ username: phone }, { mobile: phone }],
});
if (!user)
throw new BadRequestException(`User not found for phone: ${phone}`);
const u = user as any;
if (u.otp == null)
throw new BadRequestException(
`No OTP requested for ${phone}. Send an OTP first.`,
);
if (u.otpExpire < now)
throw new BadRequestException(
`OTP expired for ${phone}. Request a new OTP.`,
);
const valid = await this.hashService.compare(otp, u.otp);
if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`);
const userId = u._id as Types.ObjectId;
if (role === PartyRole.FIRST) {
if (firstIdx === -1)
throw new BadRequestException("First party not found on request");
if (!req.parties[firstIdx].person)
req.parties[firstIdx].person = {} as any;
req.parties[firstIdx].person.userId = userId;
req.parties[firstIdx].person.phoneNumber = phone;
} else {
const firstPhone =
firstIdx !== -1
? (req.parties[firstIdx]?.person as any)?.phoneNumber
: undefined;
if (firstPhone && firstPhone === phone) {
throw new BadRequestException(
"Second party phone number cannot be the same as first party.",
);
}
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
if (secondIdx === -1) {
req.parties.push({
role: PartyRole.SECOND,
person: { userId, phoneNumber: phone },
} as any);
} else {
if (!req.parties[secondIdx].person)
req.parties[secondIdx].person = {} as any;
req.parties[secondIdx].person.userId = userId;
req.parties[secondIdx].person.phoneNumber = phone;
}
}
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "PARTY_OTP_VERIFIED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType:
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
},
metadata: { partyRole: role, phoneNumber: phone },
} as any);
// For IN_PERSON expert/registrar flow: after first party OTP is verified and
// the workflow is still at CREATED, advance past any intro step to the first
// real data-entry step.
//
// THIRD_PARTY: skip FIRST_BLAME_CONFESSION (first party is always guilty) and
// land on FIRST_VIDEO.
// CAR_BODY: no confession exists; advance from CREATED to CAR_BODY_ACCIDENT_TYPE
// so the expert can fill the car-body form before video.
if (
role === PartyRole.FIRST &&
req.workflow?.currentStep === WorkflowStep.CREATED
) {
const completed = Array.isArray(req.workflow.completedSteps)
? req.workflow.completedSteps
: [];
if (req.type === BlameRequestType.CAR_BODY) {
// Advance CREATED → CAR_BODY_ACCIDENT_TYPE
// nextStep was already set to CAR_BODY_ACCIDENT_TYPE at creation time;
// look up its own nextPossibleSteps so we can set nextStep correctly.
const carBodyStepDoc = await this.getWorkflowStep({
stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
});
const afterCarBody =
(carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ??
WorkflowStep.FIRST_VIDEO;
req.workflow.completedSteps = completed;
req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE;
req.workflow.nextStep = afterCarBody;
req.history.push({
type: "AUTO_ADVANCED_TO_CAR_BODY_FORM",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName:
`${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType:
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
},
metadata: { advancedTo: WorkflowStep.CAR_BODY_ACCIDENT_TYPE },
} as any);
} else {
// THIRD_PARTY: skip confession — first party is always guilty in IN_PERSON
const step2 = await this.getWorkflowStep({ stepNumber: 2 }); // FIRST_BLAME_CONFESSION
const step2Key = step2.stepKey as WorkflowStep;
const step3 = await this.getWorkflowStep({ stepNumber: 3 }); // FIRST_VIDEO
const step3Key = step3.stepKey as WorkflowStep;
const nextAfterVideo =
(step3.nextPossibleSteps?.[0] as WorkflowStep) ??
WorkflowStep.FIRST_INITIAL_FORM;
if (!completed.includes(step2Key)) completed.push(step2Key);
req.workflow.completedSteps = completed;
req.workflow.currentStep = step3Key;
req.workflow.nextStep = nextAfterVideo;
// Auto-guilt: first party is always guilty in expert-initiated IN_PERSON
const fIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (fIdx !== -1) {
if (!req.parties[fIdx].statement)
req.parties[fIdx].statement = {} as any;
req.parties[fIdx].statement.admitsGuilt = true;
req.parties[fIdx].statement.claimsDamage = false;
req.parties[fIdx].statement.acceptsExpertOpinion = false;
}
req.blameStatus = BlameStatus.AGREED;
req.history.push({
type: "AUTO_CONFESSION_SKIPPED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName:
`${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType:
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
},
metadata: {
reason:
"IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved",
stepKey: step2Key,
advancedTo: step3Key,
},
} as any);
}
}
// For IN_PERSON expert/registrar flow: after second party OTP is verified and
// the workflow is at FIRST_INVITE_SECOND, advance to SECOND_INITIAL_FORM.
if (
role === PartyRole.SECOND &&
req.workflow?.currentStep === WorkflowStep.FIRST_INVITE_SECOND
) {
await this.advanceWorkflowToNext(req, WorkflowStep.FIRST_INVITE_SECOND);
req.status = CaseStatus.WAITING_FOR_SECOND_PARTY;
req.history.push({
type: "SECOND_PARTY_OTP_VERIFIED_ADVANCED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType:
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
},
metadata: {
phoneNumber: phone,
advancedTo: WorkflowStep.SECOND_INITIAL_FORM,
},
} as any);
}
await (req as any).save();
return {
verified: true,
partyRole: role,
message: `${role} party verified and bound to phone ${phone}.`,
};
}
/**
* List all expert-initiated blame files for the current field expert (their panel).
* Only files where initiatedBy === current user are returned.
@@ -5659,17 +6147,72 @@ export class RequestManagementService {
if (!req.expert) req.expert = {} as any;
if (!req.expert.decision) req.expert.decision = {} as any;
// Ensure guilty party is recorded (first party is always guilty in IN_PERSON flow)
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
const guiltyUserId = req.parties?.[firstIdx]?.person?.userId;
const guiltyPhone = (req.parties?.[firstIdx]?.person as any)?.phoneNumber;
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
const damagedPhone = (req.parties?.[secondIdx]?.person as any)?.phoneNumber;
(req.expert as any).decision = {
...(req.expert as any).decision,
guiltyPartyId: guiltyUserId
? new Types.ObjectId(String(guiltyUserId))
: (req.expert as any).decision?.guiltyPartyId,
description:
(req.expert as any).decision?.description ||
`مقصر توسط کارشناس در محل حادثه مشخص شد. کاربر ${guiltyPhone ?? ""} مقصر و کاربر ${damagedPhone ?? ""} زیان دیده می‌باشد.`,
decidedAt: (req.expert as any).decision?.decidedAt || new Date(),
decidedByExpertId: new Types.ObjectId(String(expert.sub)),
fields: {
accidentWay: fields.accidentWay,
accidentReason: fields.accidentReason,
accidentType: fields.accidentType,
},
};
// For IN_PERSON expert files: after accident fields are filled, guilt is
// decided and we can go straight to signatures (no separate review needed).
if (req.creationMethod === CreationMethod.IN_PERSON) {
const completed = Array.isArray(req.workflow?.completedSteps)
? req.workflow.completedSteps
: [];
const currentStep = req.workflow?.currentStep as WorkflowStep | undefined;
if (
currentStep &&
!completed.includes(currentStep)
) {
completed.push(currentStep);
}
if (!completed.includes(WorkflowStep.WAITING_FOR_GUILT_DECISION)) {
completed.push(WorkflowStep.WAITING_FOR_GUILT_DECISION);
}
if (req.workflow) {
req.workflow.completedSteps = completed;
req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES;
req.workflow.nextStep = undefined;
}
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "ACCIDENT_FIELDS_SAVED_ADVANCED_TO_SIGNATURES",
actor: {
actorId: new Types.ObjectId(String(expert.sub)),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
metadata: {
accidentWay: fields.accidentWay,
advancedTo: WorkflowStep.WAITING_FOR_SIGNATURES,
},
} as any);
}
await (req as any).save();
return { requestId: req._id };
return { requestId: req._id, workflow: req.workflow, status: req.status };
}
/**