Merge pull request 'Fix V4 FileMaker workflow re-entry' (#311) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#311
This commit is contained in:
2026-09-14 11:43:41 +03:30
2 changed files with 314 additions and 32 deletions

View File

@@ -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,
);
});
});

View File

@@ -6228,8 +6228,9 @@ export class RequestManagementService {
// the workflow is still at CREATED, advance past any intro step to the first // the workflow is still at CREATED, advance past any intro step to the first
// real data-entry step. // real data-entry step.
// //
// THIRD_PARTY: skip FIRST_BLAME_CONFESSION (first party is always guilty) and // THIRD_PARTY: skip FIRST_BLAME_CONFESSION (first party is always guilty).
// land on FIRST_VIDEO. // 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 // CAR_BODY: no confession exists; advance from CREATED to CAR_BODY_ACCIDENT_TYPE
// so the expert can fill the car-body form before video. // so the expert can fill the car-body form before video.
if ( if (
@@ -6247,9 +6248,10 @@ export class RequestManagementService {
const carBodyStepDoc = await this.getWorkflowStep({ const carBodyStepDoc = await this.getWorkflowStep({
stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE, stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
}); });
const afterCarBody = const afterCarBody = (req as any).isMadeByFileMaker
(carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ?? ? WorkflowStep.FIRST_INITIAL_FORM
WorkflowStep.FIRST_VIDEO; : ((carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ??
WorkflowStep.FIRST_VIDEO);
req.workflow.completedSteps = completed; req.workflow.completedSteps = completed;
req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; 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 // THIRD_PARTY: skip confession — first party is always guilty in IN_PERSON
const step2 = await this.getWorkflowStep({ stepNumber: 2 }); // FIRST_BLAME_CONFESSION const step2 = await this.getWorkflowStep({ stepNumber: 2 }); // FIRST_BLAME_CONFESSION
const step2Key = step2.stepKey as WorkflowStep; const step2Key = step2.stepKey as WorkflowStep;
const step3 = await this.getWorkflowStep({ stepNumber: 3 }); // FIRST_VIDEO const isFileMakerFlow = !!(req as any).isMadeByFileMaker;
const step3Key = step3.stepKey as WorkflowStep; let advancedTo: WorkflowStep;
const nextAfterVideo =
(step3.nextPossibleSteps?.[0] as WorkflowStep) ?? if (isFileMakerFlow) {
WorkflowStep.FIRST_INITIAL_FORM; 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); if (!completed.includes(step2Key)) completed.push(step2Key);
req.workflow.completedSteps = completed; req.workflow.completedSteps = completed;
req.workflow.currentStep = step3Key;
req.workflow.nextStep = nextAfterVideo;
// Auto-guilt: first party is always guilty in expert-initiated IN_PERSON // Auto-guilt: first party is always guilty in expert-initiated IN_PERSON
const fIdx = this.getPartyIndex(req, PartyRole.FIRST); const fIdx = this.getPartyIndex(req, PartyRole.FIRST);
@@ -6303,7 +6313,7 @@ export class RequestManagementService {
reason: reason:
"IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved", "IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved",
stepKey: step2Key, stepKey: step2Key,
advancedTo: step3Key, advancedTo,
}, },
} as any); } as any);
} }
@@ -11750,8 +11760,13 @@ export class RequestManagementService {
firstParty.carBodyFirstForm.object = body.object; firstParty.carBodyFirstForm.object = body.object;
this.pushWorkflowSteps(req, [WorkflowStep.CAR_BODY_ACCIDENT_TYPE]); this.pushWorkflowSteps(req, [WorkflowStep.CAR_BODY_ACCIDENT_TYPE]);
req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; if ((req as any).isMadeByFileMaker) {
req.workflow.nextStep = WorkflowStep.FIRST_INITIAL_FORM; 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 = []; if (!Array.isArray(req.history)) req.history = [];
req.history.push({ req.history.push({
@@ -12712,6 +12727,51 @@ export class RequestManagementService {
*/ */
// ─── FileMaker file list / detail (V4 + V5) ──────────────────────────────── // ─── 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<any[]> { async getMyFileMakerFiles(fileMaker: any): Promise<any[]> {
if (fileMaker?.role !== RoleEnum.FILE_MAKER) { if (fileMaker?.role !== RoleEnum.FILE_MAKER) {
throw new ForbiddenException("Only FileMakers can use this endpoint."); throw new ForbiddenException("Only FileMakers can use this endpoint.");
@@ -12721,22 +12781,25 @@ export class RequestManagementService {
isMadeByFileMaker: true, isMadeByFileMaker: true,
initiatedByFieldExpertId: makerId, initiatedByFieldExpertId: makerId,
}); });
return (files || []).map((f: any) => ({ return (files || []).map((f: any) => {
_id: f._id, const workflow = this.fileMakerWorkflowProjection(f);
publicId: f.publicId, return {
requestNo: f.requestNo, _id: f._id,
type: f.type, publicId: f.publicId,
status: f.status, requestNo: f.requestNo,
blameStatus: f.blameStatus, type: f.type,
workflow: { status: f.status,
currentStep: f.workflow?.currentStep, blameStatus: f.blameStatus,
nextStep: f.workflow?.nextStep, workflow: {
completedSteps: f.workflow?.completedSteps, currentStep: workflow.currentStep,
}, nextStep: workflow.nextStep,
requiresFileMakerApproval: f.requiresFileMakerApproval, completedSteps: workflow.completedSteps,
createdAt: f.createdAt, },
updatedAt: f.updatedAt, requiresFileMakerApproval: f.requiresFileMakerApproval,
})); createdAt: f.createdAt,
updatedAt: f.updatedAt,
};
});
} }
async getMyFileMakerFileDetail( async getMyFileMakerFileDetail(
@@ -12777,7 +12840,7 @@ export class RequestManagementService {
type: plain.type, type: plain.type,
status: plain.status, status: plain.status,
blameStatus: plain.blameStatus, blameStatus: plain.blameStatus,
workflow: plain.workflow, workflow: this.fileMakerWorkflowProjection(plain),
requiresFileMakerApproval: plain.requiresFileMakerApproval, requiresFileMakerApproval: plain.requiresFileMakerApproval,
parties: (plain.parties ?? []).map((p: any) => ({ parties: (plain.parties ?? []).map((p: any) => ({
role: p.role, role: p.role,