From 1b69da38ff7299a7571467785a7126ba44dbce10 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Mon, 14 Sep 2026 11:42:48 +0330 Subject: [PATCH] Fix V4 FileMaker workflow re-entry --- .../file-maker-blame-v4-workflow.spec.ts | 219 ++++++++++++++++++ .../request-management.service.ts | 127 +++++++--- 2 files changed, 314 insertions(+), 32 deletions(-) create mode 100644 src/request-management/file-maker-blame-v4-workflow.spec.ts diff --git a/src/request-management/file-maker-blame-v4-workflow.spec.ts b/src/request-management/file-maker-blame-v4-workflow.spec.ts new file mode 100644 index 0000000..503e506 --- /dev/null +++ b/src/request-management/file-maker-blame-v4-workflow.spec.ts @@ -0,0 +1,219 @@ +import { Types } from "mongoose"; +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { CreationMethod } from "./entities/schema/request-management.schema"; +import { PartyRole } from "./entities/schema/partyRole.enum"; +import { RequestManagementService } from "./request-management.service"; + +describe("RequestManagementService V4 FileMaker workflow", () => { + it("persists FIRST_INITIAL_FORM after the first party OTP is verified", async () => { + const fileMakerId = new Types.ObjectId(); + const userId = new Types.ObjectId(); + const request = { + _id: new Types.ObjectId(), + publicId: "BL-V4-001", + type: BlameRequestType.THIRD_PARTY, + expertInitiated: true, + isMadeByFileMaker: true, + requiresFileMakerApproval: false, + initiatedByFieldExpertId: fileMakerId, + creationMethod: CreationMethod.IN_PERSON, + parties: [{ role: PartyRole.FIRST, person: {} }], + workflow: { + currentStep: WorkflowStep.CREATED, + nextStep: WorkflowStep.FIRST_BLAME_CONFESSION, + completedSteps: [WorkflowStep.CREATED], + }, + history: [], + save: jest.fn().mockResolvedValue(undefined), + }; + const service = + new (RequestManagementService as any)() as RequestManagementService; + (service as any).blameRequestDbService = { + findById: jest.fn().mockResolvedValue(request), + }; + (service as any).userDbService = { + findOne: jest.fn().mockResolvedValue({ + _id: userId, + otp: "hashed-otp", + otpExpire: Date.now() + 60_000, + }), + }; + (service as any).hashService = { + compare: jest.fn().mockResolvedValue(true), + }; + (service as any).claimCaseDbService = { + findOne: jest.fn().mockResolvedValue(null), + }; + (service as any).workflowStepDbService = { + findByStepNumber: jest.fn().mockImplementation((stepNumber: number) => { + if (stepNumber === 2) { + return { + stepNumber: 2, + stepKey: WorkflowStep.FIRST_BLAME_CONFESSION, + nextPossibleSteps: [WorkflowStep.FIRST_VIDEO], + }; + } + if (stepNumber === 3) { + return { + stepNumber: 3, + stepKey: WorkflowStep.FIRST_VIDEO, + nextPossibleSteps: [WorkflowStep.FIRST_INITIAL_FORM], + }; + } + return null; + }), + findByStepKey: jest.fn().mockResolvedValue(null), + }; + + await service.verifyPartyOtpV2( + { + sub: String(fileMakerId), + role: RoleEnum.FILE_MAKER, + firstName: "File", + lastName: "Maker", + }, + String(request._id), + { + phoneNumber: "09121234567", + otp: "12345", + partyRole: "FIRST", + }, + ); + + expect(request.workflow.currentStep).toBe(WorkflowStep.FIRST_INITIAL_FORM); + expect(request.workflow.currentStep).not.toBe(WorkflowStep.FIRST_VIDEO); + expect(request.workflow.nextStep).toBe(WorkflowStep.FIRST_VOICE); + expect(request.workflow.completedSteps).not.toContain( + WorkflowStep.FIRST_VIDEO, + ); + expect(request.save).toHaveBeenCalled(); + + const reopened = await service.getMyFileMakerFileDetail( + { sub: String(fileMakerId), role: RoleEnum.FILE_MAKER }, + String(request._id), + ); + expect(reopened.workflow.currentStep).toBe(WorkflowStep.FIRST_INITIAL_FORM); + expect(reopened.workflow.currentStep).not.toBe(WorkflowStep.FIRST_VIDEO); + }); + + it("keeps FIRST_VIDEO out of the FileMaker car-body workflow", async () => { + const fileMakerId = new Types.ObjectId(); + const request = { + _id: new Types.ObjectId(), + publicId: "BL-V4-002", + type: BlameRequestType.CAR_BODY, + expertInitiated: true, + isMadeByFileMaker: true, + requiresFileMakerApproval: false, + initiatedByFieldExpertId: fileMakerId, + creationMethod: CreationMethod.IN_PERSON, + parties: [{ role: PartyRole.FIRST, person: {} }], + workflow: { + currentStep: WorkflowStep.CREATED, + nextStep: WorkflowStep.CAR_BODY_ACCIDENT_TYPE, + completedSteps: [WorkflowStep.CREATED], + }, + history: [], + save: jest.fn().mockResolvedValue(undefined), + }; + const service = + new (RequestManagementService as any)() as RequestManagementService; + (service as any).blameRequestDbService = { + findById: jest.fn().mockResolvedValue(request), + }; + (service as any).userDbService = { + findOne: jest.fn().mockResolvedValue({ + _id: new Types.ObjectId(), + otp: "hashed-otp", + otpExpire: Date.now() + 60_000, + }), + }; + (service as any).hashService = { + compare: jest.fn().mockResolvedValue(true), + }; + (service as any).workflowStepDbService = { + findByStepNumber: jest.fn().mockResolvedValue(null), + findByStepKey: jest.fn().mockResolvedValue({ + stepNumber: 2, + stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE, + nextPossibleSteps: [WorkflowStep.FIRST_VIDEO], + }), + }; + const actor = { + sub: String(fileMakerId), + role: RoleEnum.FILE_MAKER, + firstName: "File", + lastName: "Maker", + }; + + await service.verifyPartyOtpV2(actor, String(request._id), { + phoneNumber: "09121234567", + otp: "12345", + partyRole: "FIRST", + }); + + expect(request.workflow.currentStep).toBe( + WorkflowStep.CAR_BODY_ACCIDENT_TYPE, + ); + expect(request.workflow.nextStep).toBe(WorkflowStep.FIRST_INITIAL_FORM); + + await service.carBodyAccidentTypeFormV3( + String(request._id), + { car: true, object: false }, + actor, + ); + + expect(request.workflow.currentStep).toBe(WorkflowStep.FIRST_INITIAL_FORM); + expect(request.workflow.nextStep).toBe(WorkflowStep.FIRST_VOICE); + expect(request.workflow.completedSteps).not.toContain( + WorkflowStep.FIRST_VIDEO, + ); + }); + + it("repairs legacy stuck workflow values when a V4 file is reopened", async () => { + const fileMakerId = new Types.ObjectId(); + const request = { + _id: new Types.ObjectId(), + publicId: "BL-V4-LEGACY", + type: BlameRequestType.THIRD_PARTY, + isMadeByFileMaker: true, + initiatedByFieldExpertId: fileMakerId, + workflow: { + currentStep: WorkflowStep.FIRST_VIDEO, + nextStep: WorkflowStep.FIRST_INITIAL_FORM, + completedSteps: [ + WorkflowStep.CREATED, + WorkflowStep.FIRST_BLAME_CONFESSION, + ], + }, + parties: [ + { role: PartyRole.FIRST, person: { userId: new Types.ObjectId() } }, + ], + }; + const service = + new (RequestManagementService as any)() as RequestManagementService; + (service as any).blameRequestDbService = { + findById: jest.fn().mockResolvedValue(request), + }; + (service as any).claimCaseDbService = { + findOne: jest.fn().mockResolvedValue(null), + }; + + const reopened = await service.getMyFileMakerFileDetail( + { sub: String(fileMakerId), role: RoleEnum.FILE_MAKER }, + String(request._id), + ); + + expect(reopened.workflow).toEqual( + expect.objectContaining({ + currentStep: WorkflowStep.FIRST_INITIAL_FORM, + nextStep: WorkflowStep.FIRST_VOICE, + }), + ); + expect(reopened.workflow.completedSteps).not.toContain( + WorkflowStep.FIRST_VIDEO, + ); + }); +}); diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 227cae7..7921b1a 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -6228,8 +6228,9 @@ export class RequestManagementService { // 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. + // THIRD_PARTY: skip FIRST_BLAME_CONFESSION (first party is always guilty). + // V4/V5 FileMaker files have no video step and land directly on the + // initial inquiry form; other in-person flows 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 ( @@ -6247,9 +6248,10 @@ export class RequestManagementService { const carBodyStepDoc = await this.getWorkflowStep({ stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE, }); - const afterCarBody = - (carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ?? - WorkflowStep.FIRST_VIDEO; + const afterCarBody = (req as any).isMadeByFileMaker + ? WorkflowStep.FIRST_INITIAL_FORM + : ((carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ?? + WorkflowStep.FIRST_VIDEO); req.workflow.completedSteps = completed; req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; @@ -6269,16 +6271,24 @@ export class RequestManagementService { // 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; + const isFileMakerFlow = !!(req as any).isMadeByFileMaker; + let advancedTo: WorkflowStep; + + if (isFileMakerFlow) { + advancedTo = WorkflowStep.FIRST_INITIAL_FORM; + req.workflow.currentStep = advancedTo; + req.workflow.nextStep = WorkflowStep.FIRST_VOICE; + } else { + const step3 = await this.getWorkflowStep({ stepNumber: 3 }); // FIRST_VIDEO + advancedTo = step3.stepKey as WorkflowStep; + req.workflow.currentStep = advancedTo; + req.workflow.nextStep = + (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); @@ -6303,7 +6313,7 @@ export class RequestManagementService { reason: "IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved", stepKey: step2Key, - advancedTo: step3Key, + advancedTo, }, } as any); } @@ -11750,8 +11760,13 @@ export class RequestManagementService { firstParty.carBodyFirstForm.object = body.object; this.pushWorkflowSteps(req, [WorkflowStep.CAR_BODY_ACCIDENT_TYPE]); - req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; - req.workflow.nextStep = WorkflowStep.FIRST_INITIAL_FORM; + if ((req as any).isMadeByFileMaker) { + req.workflow.currentStep = WorkflowStep.FIRST_INITIAL_FORM; + req.workflow.nextStep = WorkflowStep.FIRST_VOICE; + } else { + req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; + req.workflow.nextStep = WorkflowStep.FIRST_INITIAL_FORM; + } if (!Array.isArray(req.history)) req.history = []; req.history.push({ @@ -12712,6 +12727,51 @@ export class RequestManagementService { */ // ─── FileMaker file list / detail (V4 + V5) ──────────────────────────────── + /** + * Older V4/V5 records may have FIRST_VIDEO persisted by the shared in-person + * OTP transition. FileMaker flows never exposed that step, so repair the API + * projection until the first inquiry submission naturally persists the next + * valid workflow state. + */ + private fileMakerWorkflowProjection(file: any): any { + const rawWorkflow = file?.workflow ?? {}; + const workflow = + typeof rawWorkflow.toObject === "function" + ? rawWorkflow.toObject({ versionKey: false }) + : { ...rawWorkflow }; + const completedSteps = Array.isArray(workflow.completedSteps) + ? workflow.completedSteps.filter( + (step: WorkflowStep) => step !== WorkflowStep.FIRST_VIDEO, + ) + : workflow.completedSteps; + + if ( + workflow.currentStep === WorkflowStep.FIRST_VIDEO && + !(completedSteps ?? []).includes(WorkflowStep.FIRST_INITIAL_FORM) + ) { + return { + ...workflow, + currentStep: WorkflowStep.FIRST_INITIAL_FORM, + nextStep: WorkflowStep.FIRST_VOICE, + completedSteps, + }; + } + + if ( + file?.type === BlameRequestType.CAR_BODY && + workflow.currentStep === WorkflowStep.CAR_BODY_ACCIDENT_TYPE && + workflow.nextStep === WorkflowStep.FIRST_VIDEO + ) { + return { + ...workflow, + nextStep: WorkflowStep.FIRST_INITIAL_FORM, + completedSteps, + }; + } + + return { ...workflow, completedSteps }; + } + async getMyFileMakerFiles(fileMaker: any): Promise { if (fileMaker?.role !== RoleEnum.FILE_MAKER) { throw new ForbiddenException("Only FileMakers can use this endpoint."); @@ -12721,22 +12781,25 @@ export class RequestManagementService { isMadeByFileMaker: true, initiatedByFieldExpertId: makerId, }); - return (files || []).map((f: any) => ({ - _id: f._id, - publicId: f.publicId, - requestNo: f.requestNo, - type: f.type, - status: f.status, - blameStatus: f.blameStatus, - workflow: { - currentStep: f.workflow?.currentStep, - nextStep: f.workflow?.nextStep, - completedSteps: f.workflow?.completedSteps, - }, - requiresFileMakerApproval: f.requiresFileMakerApproval, - createdAt: f.createdAt, - updatedAt: f.updatedAt, - })); + return (files || []).map((f: any) => { + const workflow = this.fileMakerWorkflowProjection(f); + return { + _id: f._id, + publicId: f.publicId, + requestNo: f.requestNo, + type: f.type, + status: f.status, + blameStatus: f.blameStatus, + workflow: { + currentStep: workflow.currentStep, + nextStep: workflow.nextStep, + completedSteps: workflow.completedSteps, + }, + requiresFileMakerApproval: f.requiresFileMakerApproval, + createdAt: f.createdAt, + updatedAt: f.updatedAt, + }; + }); } async getMyFileMakerFileDetail( @@ -12777,7 +12840,7 @@ export class RequestManagementService { type: plain.type, status: plain.status, blameStatus: plain.blameStatus, - workflow: plain.workflow, + workflow: this.fileMakerWorkflowProjection(plain), requiresFileMakerApproval: plain.requiresFileMakerApproval, parties: (plain.parties ?? []).map((p: any) => ({ role: p.role,