forked from Yara724/api
Merge pull request 'YARA-725, YARA-830, YARA-832' (#40) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#40
This commit is contained in:
@@ -40,6 +40,10 @@ export class ClaimWorkflow {
|
|||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
lockedAt?: Date;
|
lockedAt?: Date;
|
||||||
|
|
||||||
|
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||||
|
@Prop({ type: Date })
|
||||||
|
expiredAt?: Date;
|
||||||
|
|
||||||
@Prop({ type: ClaimActorLockSchema })
|
@Prop({ type: ClaimActorLockSchema })
|
||||||
lockedBy?: ClaimActorLock;
|
lockedBy?: ClaimActorLock;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,8 +117,7 @@ export class AllRequestDtoV2 {
|
|||||||
type: string;
|
type: string;
|
||||||
blameStatus: string;
|
blameStatus: string;
|
||||||
partiesInitialForms: { firstParty: string; secondParty: string };
|
partiesInitialForms: { firstParty: string; secondParty: string };
|
||||||
firstPartyVehicle: string;
|
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string };
|
||||||
secondPartyVehicle?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AllRequestDtoRsV2 {
|
export class AllRequestDtoRsV2 {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { RequestManagementService } from "src/request-management/request-managem
|
|||||||
import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot";
|
import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot";
|
||||||
import { ExpertModel } from "src/users/entities/schema/expert.schema";
|
import { ExpertModel } from "src/users/entities/schema/expert.schema";
|
||||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||||
|
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||||
|
|
||||||
interface CheckedRequestEntry {
|
interface CheckedRequestEntry {
|
||||||
CheckedRequest?: {
|
CheckedRequest?: {
|
||||||
@@ -325,6 +326,7 @@ export class ExpertBlameService {
|
|||||||
if (w) {
|
if (w) {
|
||||||
w.locked = false;
|
w.locked = false;
|
||||||
delete w.lockedAt;
|
delete w.lockedAt;
|
||||||
|
delete w.expiredAt;
|
||||||
delete w.lockedBy;
|
delete w.lockedBy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,6 +354,15 @@ export class ExpertBlameService {
|
|||||||
const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
|
const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
|
||||||
const parties = (doc.parties ?? []) as Array<{
|
const parties = (doc.parties ?? []) as Array<{
|
||||||
role?: string;
|
role?: string;
|
||||||
|
vehicle?: {
|
||||||
|
name?: string;
|
||||||
|
inquiry?: {
|
||||||
|
mapped?: {
|
||||||
|
MapTypNam?: string;
|
||||||
|
CarName?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
statement?: {
|
statement?: {
|
||||||
admitsGuilt?: boolean;
|
admitsGuilt?: boolean;
|
||||||
claimsDamage?: boolean;
|
claimsDamage?: boolean;
|
||||||
@@ -381,11 +392,43 @@ export class ExpertBlameService {
|
|||||||
firstParty: statementToFormKey(firstParty?.statement) ?? "",
|
firstParty: statementToFormKey(firstParty?.statement) ?? "",
|
||||||
secondParty: statementToFormKey(secondParty?.statement) ?? "",
|
secondParty: statementToFormKey(secondParty?.statement) ?? "",
|
||||||
},
|
},
|
||||||
firstPartyVehicle: doc.parties[0]?.person?.vehicle?.name,
|
partiesVehicles: {
|
||||||
secondPartyVehicle: doc.parties[1]?.person?.vehicle?.name,
|
firstPartyVehicle:
|
||||||
|
firstParty?.vehicle?.name ||
|
||||||
|
firstParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
|
||||||
|
firstParty?.vehicle?.inquiry?.mapped?.CarName ||
|
||||||
|
"",
|
||||||
|
secondPartyVehicle:
|
||||||
|
secondParty?.vehicle?.name ||
|
||||||
|
secondParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
|
||||||
|
secondParty?.vehicle?.inquiry?.mapped?.CarName ||
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while another expert holds an active workflow lock (uses `expiredAt` when set). */
|
||||||
|
private isBlameV2WorkflowLockCurrentlyEnforced(doc: {
|
||||||
|
workflow?: {
|
||||||
|
locked?: boolean;
|
||||||
|
lockedAt?: Date;
|
||||||
|
expiredAt?: Date;
|
||||||
|
lockedBy?: { actorId?: unknown };
|
||||||
|
};
|
||||||
|
}): boolean {
|
||||||
|
if (!doc?.workflow?.locked) return false;
|
||||||
|
const exp = doc.workflow.expiredAt;
|
||||||
|
if (exp) {
|
||||||
|
return Date.now() < new Date(exp as Date).getTime();
|
||||||
|
}
|
||||||
|
const la = doc.workflow.lockedAt;
|
||||||
|
if (!la) return true;
|
||||||
|
return (
|
||||||
|
Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}.
|
* Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}.
|
||||||
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
|
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
|
||||||
@@ -399,6 +442,13 @@ export class ExpertBlameService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lockedAt = request.workflow.lockedAt;
|
const lockedAt = request.workflow.lockedAt;
|
||||||
|
const expiredAt = request.workflow.expiredAt;
|
||||||
|
if (
|
||||||
|
expiredAt &&
|
||||||
|
Date.now() < new Date(expiredAt as Date).getTime()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
lockedAt &&
|
lockedAt &&
|
||||||
Date.now() <
|
Date.now() <
|
||||||
@@ -412,7 +462,9 @@ export class ExpertBlameService {
|
|||||||
_id: new Types.ObjectId(requestId),
|
_id: new Types.ObjectId(requestId),
|
||||||
"workflow.locked": true,
|
"workflow.locked": true,
|
||||||
};
|
};
|
||||||
if (lockedAt) {
|
if (expiredAt) {
|
||||||
|
filter["workflow.expiredAt"] = expiredAt;
|
||||||
|
} else if (lockedAt) {
|
||||||
filter["workflow.lockedAt"] = lockedAt;
|
filter["workflow.lockedAt"] = lockedAt;
|
||||||
} else if (lockedById) {
|
} else if (lockedById) {
|
||||||
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
|
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
|
||||||
@@ -424,7 +476,11 @@ export class ExpertBlameService {
|
|||||||
filter as any,
|
filter as any,
|
||||||
{
|
{
|
||||||
$set: { "workflow.locked": false },
|
$set: { "workflow.locked": false },
|
||||||
$unset: { "workflow.lockedAt": "", "workflow.lockedBy": "" },
|
$unset: {
|
||||||
|
"workflow.lockedAt": "",
|
||||||
|
"workflow.expiredAt": "",
|
||||||
|
"workflow.lockedBy": "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ new: false },
|
{ new: false },
|
||||||
);
|
);
|
||||||
@@ -690,6 +746,7 @@ export class ExpertBlameService {
|
|||||||
try {
|
try {
|
||||||
requireActorClientKey(actor);
|
requireActorClientKey(actor);
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new NotFoundException("Request not found");
|
throw new NotFoundException("Request not found");
|
||||||
@@ -711,6 +768,16 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) {
|
||||||
|
const w = doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined;
|
||||||
|
const lockerId = String(w?.lockedBy?.actorId ?? "");
|
||||||
|
if (lockerId && lockerId !== actorId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"This request is locked by another expert.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const decision = (doc.expert as any)?.decision;
|
const decision = (doc.expert as any)?.decision;
|
||||||
const decidedByExpertId = decision?.decidedByExpertId;
|
const decidedByExpertId = decision?.decidedByExpertId;
|
||||||
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
||||||
@@ -899,14 +966,11 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if locked and not expired
|
// Check if locked and not expired (`expiredAt` or lockedAt + TTL)
|
||||||
if (request.workflow?.locked) {
|
if (
|
||||||
const lockedAt = request.workflow.lockedAt;
|
request.workflow?.locked &&
|
||||||
if (lockedAt) {
|
this.isBlameV2WorkflowLockCurrentlyEnforced(request)
|
||||||
const lockExpiryTime =
|
) {
|
||||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
|
||||||
if (Date.now() < lockExpiryTime) {
|
|
||||||
// Lock is still valid
|
|
||||||
const lockedByActorId = String(
|
const lockedByActorId = String(
|
||||||
request.workflow.lockedBy?.actorId ?? "",
|
request.workflow.lockedBy?.actorId ?? "",
|
||||||
);
|
);
|
||||||
@@ -914,15 +978,11 @@ export class ExpertBlameService {
|
|||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"You have already locked this request",
|
"You have already locked this request",
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Request is currently locked by another expert",
|
"Request is currently locked by another expert",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Lock expired, allow re-locking (continue below)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
|
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
|
||||||
|
|
||||||
@@ -933,6 +993,9 @@ export class ExpertBlameService {
|
|||||||
$set: {
|
$set: {
|
||||||
"workflow.locked": true,
|
"workflow.locked": true,
|
||||||
"workflow.lockedAt": now,
|
"workflow.lockedAt": now,
|
||||||
|
"workflow.expiredAt": new Date(
|
||||||
|
now.getTime() + this.blameV2LockTtlMs,
|
||||||
|
),
|
||||||
"workflow.lockedBy": {
|
"workflow.lockedBy": {
|
||||||
actorId: new Types.ObjectId(actorDetail.sub),
|
actorId: new Types.ObjectId(actorDetail.sub),
|
||||||
actorName: actorDetail.fullName || "Unknown Expert",
|
actorName: actorDetail.fullName || "Unknown Expert",
|
||||||
@@ -960,6 +1023,7 @@ export class ExpertBlameService {
|
|||||||
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
||||||
{
|
{
|
||||||
receptor: phone,
|
receptor: phone,
|
||||||
|
fileKind: "blame",
|
||||||
publicId: request.publicId,
|
publicId: request.publicId,
|
||||||
expertLastName,
|
expertLastName,
|
||||||
},
|
},
|
||||||
@@ -1105,6 +1169,7 @@ export class ExpertBlameService {
|
|||||||
},
|
},
|
||||||
$unset: {
|
$unset: {
|
||||||
"workflow.lockedAt": "",
|
"workflow.lockedAt": "",
|
||||||
|
"workflow.expiredAt": "",
|
||||||
"workflow.lockedBy": "",
|
"workflow.lockedBy": "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1124,6 +1189,7 @@ export class ExpertBlameService {
|
|||||||
requestId,
|
requestId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (request.type === BlameRequestType.THIRD_PARTY) {
|
||||||
const requestIdToken = String(request._id);
|
const requestIdToken = String(request._id);
|
||||||
const partyPhoneByUserId = new Map<string, string>();
|
const partyPhoneByUserId = new Map<string, string>();
|
||||||
for (const p of request.parties || []) {
|
for (const p of request.parties || []) {
|
||||||
@@ -1133,22 +1199,39 @@ export class ExpertBlameService {
|
|||||||
partyPhoneByUserId.set(uid, phone);
|
partyPhoneByUserId.set(uid, phone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const partyHasResendPayload = (row: {
|
||||||
|
requestedItems?: ResendItemType[];
|
||||||
|
description?: string;
|
||||||
|
}) => {
|
||||||
|
const n = Array.isArray(row.requestedItems)
|
||||||
|
? row.requestedItems.length
|
||||||
|
: 0;
|
||||||
|
return n > 0 || String(row.description ?? "").trim().length > 0;
|
||||||
|
};
|
||||||
for (const p of partyResendRequests) {
|
for (const p of partyResendRequests) {
|
||||||
|
if (!partyHasResendPayload(p)) continue;
|
||||||
const uid = String((p as any).partyId);
|
const uid = String((p as any).partyId);
|
||||||
const phone = partyPhoneByUserId.get(uid);
|
const phone = partyPhoneByUserId.get(uid);
|
||||||
if (!phone) continue;
|
if (!phone) continue;
|
||||||
const role =
|
const role =
|
||||||
String(request.parties?.find((x: any) => String(x?.person?.userId) === uid)?.role) ===
|
String(
|
||||||
"SECOND"
|
request.parties?.find(
|
||||||
|
(x: any) => String(x?.person?.userId) === uid,
|
||||||
|
)?.role,
|
||||||
|
) === "SECOND"
|
||||||
? "SECOND"
|
? "SECOND"
|
||||||
: "FIRST";
|
: "FIRST";
|
||||||
await this.smsOrchestrationService.sendResendDocumentsNotice({
|
await this.smsOrchestrationService.sendResendDocumentsNotice({
|
||||||
receptor: phone,
|
receptor: phone,
|
||||||
fileKind: "blame",
|
fileKind: "blame",
|
||||||
publicId: request.publicId,
|
publicId: request.publicId,
|
||||||
link: this.smsOrchestrationService.buildBlamePartyLink(requestIdToken, role),
|
link: this.smsOrchestrationService.buildBlamePartyLink(
|
||||||
|
requestIdToken,
|
||||||
|
role,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestId: String(request._id),
|
requestId: String(request._id),
|
||||||
@@ -1288,6 +1371,7 @@ export class ExpertBlameService {
|
|||||||
},
|
},
|
||||||
$unset: {
|
$unset: {
|
||||||
"workflow.lockedAt": "",
|
"workflow.lockedAt": "",
|
||||||
|
"workflow.expiredAt": "",
|
||||||
"workflow.lockedBy": "",
|
"workflow.lockedBy": "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1303,14 +1387,17 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// THIRD_PARTY: both parties must sign; send each their deep link (mutual-agreement uses yara-blame-agreement elsewhere).
|
||||||
if (request.type === BlameRequestType.THIRD_PARTY) {
|
if (request.type === BlameRequestType.THIRD_PARTY) {
|
||||||
const requestIdToken = String(request._id);
|
const requestIdToken = String(request._id);
|
||||||
const expertLastName =
|
const expertLastName =
|
||||||
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||||
for (const p of request.parties || []) {
|
const parties = request.parties || [];
|
||||||
|
for (const partyRole of [PartyRole.FIRST, PartyRole.SECOND] as const) {
|
||||||
|
const p = parties.find((x: any) => x?.role === partyRole);
|
||||||
const phone = p?.person?.phoneNumber;
|
const phone = p?.person?.phoneNumber;
|
||||||
if (!phone || typeof phone !== "string") continue;
|
if (!phone || typeof phone !== "string") continue;
|
||||||
const role = String(p?.role) === "SECOND" ? "SECOND" : "FIRST";
|
const linkRole = partyRole === PartyRole.SECOND ? "SECOND" : "FIRST";
|
||||||
await this.smsOrchestrationService.sendSignatureReviewNotice({
|
await this.smsOrchestrationService.sendSignatureReviewNotice({
|
||||||
receptor: phone,
|
receptor: phone,
|
||||||
fileKind: "blame",
|
fileKind: "blame",
|
||||||
@@ -1318,7 +1405,7 @@ export class ExpertBlameService {
|
|||||||
expertLastName,
|
expertLastName,
|
||||||
link: this.smsOrchestrationService.buildBlamePartyLink(
|
link: this.smsOrchestrationService.buildBlamePartyLink(
|
||||||
requestIdToken,
|
requestIdToken,
|
||||||
role,
|
linkRole,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1430,6 +1517,7 @@ export class ExpertBlameService {
|
|||||||
},
|
},
|
||||||
$unset: {
|
$unset: {
|
||||||
"workflow.lockedAt": "",
|
"workflow.lockedAt": "",
|
||||||
|
"workflow.expiredAt": "",
|
||||||
"workflow.lockedBy": "",
|
"workflow.lockedBy": "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
actorId: string;
|
actorId: string;
|
||||||
actorName: string;
|
actorName: string;
|
||||||
lockedAt: string;
|
lockedAt: string;
|
||||||
|
expiredAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ export class ClaimListItemV2Dto {
|
|||||||
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
|
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Actor who locked this claim' })
|
@ApiPropertyOptional({ description: 'Actor who locked this claim (only while lock is active)' })
|
||||||
lockedBy?: {
|
lockedBy?: {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
actorName: string;
|
actorName: string;
|
||||||
|
lockedAt?: string;
|
||||||
|
expiredAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
|
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.
|
|||||||
import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum";
|
import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum";
|
||||||
import { UserType } from "src/Types&Enums/userType.enum";
|
import { UserType } from "src/Types&Enums/userType.enum";
|
||||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||||
|
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||||
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
|
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
|
||||||
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
||||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
@@ -189,6 +190,7 @@ export class ExpertClaimService {
|
|||||||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
||||||
private readonly branchDbService: BranchDbService,
|
private readonly branchDbService: BranchDbService,
|
||||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||||
|
private readonly userDbService: UserDbService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/** Load immutable profile fields from `damage-expert` for the acting expert. */
|
/** Load immutable profile fields from `damage-expert` for the acting expert. */
|
||||||
@@ -197,6 +199,29 @@ export class ExpertClaimService {
|
|||||||
return snapshotFromDamageExpert(doc as DamageExpertModel);
|
return snapshotFromDamageExpert(doc as DamageExpertModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Owner mobile: linked blame party phone when available, else `users.mobile`. */
|
||||||
|
private async resolveClaimOwnerPhone(claim: any): Promise<string | undefined> {
|
||||||
|
if (!claim?.owner?.userId) return undefined;
|
||||||
|
const ownerUserId = String(claim.owner.userId);
|
||||||
|
if (claim.blameRequestId) {
|
||||||
|
const blame = await this.blameRequestDbService.findById(
|
||||||
|
claim.blameRequestId.toString(),
|
||||||
|
);
|
||||||
|
const ownerParty = (blame?.parties || []).find(
|
||||||
|
(p: any) =>
|
||||||
|
p?.person?.userId && String(p.person.userId) === ownerUserId,
|
||||||
|
);
|
||||||
|
const fromParty = ownerParty?.person?.phoneNumber;
|
||||||
|
if (fromParty && typeof fromParty === "string") return fromParty;
|
||||||
|
}
|
||||||
|
const user = await this.userDbService.findOne({
|
||||||
|
_id: new Types.ObjectId(ownerUserId),
|
||||||
|
});
|
||||||
|
const m = user?.mobile;
|
||||||
|
if (m && typeof m === "string") return m;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Map blame party `Vehicle` (name/model/type) to expert panel shape. */
|
/** Map blame party `Vehicle` (name/model/type) to expert panel shape. */
|
||||||
private expertVehicleFromPartyVehicle(v: any): {
|
private expertVehicleFromPartyVehicle(v: any): {
|
||||||
carName?: string;
|
carName?: string;
|
||||||
@@ -2009,6 +2034,19 @@ export class ExpertClaimService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ownerPhoneFactors = await this.resolveClaimOwnerPhone(claim);
|
||||||
|
if (ownerPhoneFactors) {
|
||||||
|
const expertLastName =
|
||||||
|
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||||
|
await this.smsOrchestrationService.sendSignatureReviewNotice({
|
||||||
|
receptor: ownerPhoneFactors,
|
||||||
|
fileKind: "claim",
|
||||||
|
publicId: claim.publicId,
|
||||||
|
expertLastName,
|
||||||
|
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message:
|
message:
|
||||||
"All factors approved. The claim proceeds to insurer review (same as non-factor expert reply).",
|
"All factors approved. The claim proceeds to insurer review (same as non-factor expert reply).",
|
||||||
@@ -2072,6 +2110,7 @@ export class ExpertClaimService {
|
|||||||
*/
|
*/
|
||||||
async lockClaimRequestV2(claimRequestId: string, actor: any) {
|
async lockClaimRequestV2(claimRequestId: string, actor: any) {
|
||||||
requireActorClientKey(actor);
|
requireActorClientKey(actor);
|
||||||
|
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
@@ -2104,12 +2143,15 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||||||
|
const lockAt = new Date();
|
||||||
|
const expiredAt = new Date(lockAt.getTime() + this.claimV2WorkflowLockTtlMs);
|
||||||
|
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
claimStatus: ClaimStatus.UNDER_REVIEW,
|
claimStatus: ClaimStatus.UNDER_REVIEW,
|
||||||
'workflow.locked': true,
|
'workflow.locked': true,
|
||||||
'workflow.lockedAt': new Date(),
|
'workflow.lockedAt': lockAt,
|
||||||
|
'workflow.expiredAt': expiredAt,
|
||||||
'workflow.lockedBy': {
|
'workflow.lockedBy': {
|
||||||
actorId: new Types.ObjectId(actor.sub),
|
actorId: new Types.ObjectId(actor.sub),
|
||||||
actorName: actor.fullName,
|
actorName: actor.fullName,
|
||||||
@@ -2128,6 +2170,20 @@ export class ExpertClaimService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ownerPhone = await this.resolveClaimOwnerPhone(claim);
|
||||||
|
if (ownerPhone) {
|
||||||
|
const expertLastName =
|
||||||
|
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||||
|
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
||||||
|
{
|
||||||
|
receptor: ownerPhone,
|
||||||
|
fileKind: "claim",
|
||||||
|
publicId: claim.publicId,
|
||||||
|
expertLastName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
locked: true,
|
locked: true,
|
||||||
@@ -2206,6 +2262,7 @@ export class ExpertClaimService {
|
|||||||
},
|
},
|
||||||
$unset: {
|
$unset: {
|
||||||
"workflow.lockedAt": "",
|
"workflow.lockedAt": "",
|
||||||
|
"workflow.expiredAt": "",
|
||||||
"workflow.lockedBy": "",
|
"workflow.lockedBy": "",
|
||||||
},
|
},
|
||||||
$push: {
|
$push: {
|
||||||
@@ -2225,29 +2282,15 @@ export class ExpertClaimService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (claim.blameRequestId) {
|
const ownerPhoneResend = await this.resolveClaimOwnerPhone(claim);
|
||||||
const blame = await this.blameRequestDbService.findById(
|
if (ownerPhoneResend) {
|
||||||
claim.blameRequestId.toString(),
|
|
||||||
);
|
|
||||||
if (blame?.type === BlameRequestType.THIRD_PARTY) {
|
|
||||||
const ownerUserId = claim.owner?.userId ? String(claim.owner.userId) : "";
|
|
||||||
const ownerParty = (blame.parties || []).find(
|
|
||||||
(p: any) =>
|
|
||||||
p?.person?.userId && String(p.person.userId) === ownerUserId,
|
|
||||||
);
|
|
||||||
const ownerPhone = ownerParty?.person?.phoneNumber;
|
|
||||||
if (ownerPhone && typeof ownerPhone === "string") {
|
|
||||||
await this.smsOrchestrationService.sendResendDocumentsNotice({
|
await this.smsOrchestrationService.sendResendDocumentsNotice({
|
||||||
receptor: ownerPhone,
|
receptor: ownerPhoneResend,
|
||||||
fileKind: "claim",
|
fileKind: "claim",
|
||||||
publicId: claim.publicId,
|
publicId: claim.publicId,
|
||||||
link: this.smsOrchestrationService.buildClaimLink(
|
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
|
||||||
String(claim._id),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
@@ -2372,6 +2415,11 @@ export class ExpertClaimService {
|
|||||||
status: nextCaseStatus,
|
status: nextCaseStatus,
|
||||||
claimStatus: nextClaimStatus,
|
claimStatus: nextClaimStatus,
|
||||||
'workflow.locked': false,
|
'workflow.locked': false,
|
||||||
|
$unset: {
|
||||||
|
'workflow.lockedAt': '',
|
||||||
|
'workflow.expiredAt': '',
|
||||||
|
'workflow.lockedBy': '',
|
||||||
|
},
|
||||||
'workflow.currentStep': nextStep,
|
'workflow.currentStep': nextStep,
|
||||||
'workflow.nextStep': needsFactorUpload
|
'workflow.nextStep': needsFactorUpload
|
||||||
? ClaimWorkflowStep.INSURER_REVIEW
|
? ClaimWorkflowStep.INSURER_REVIEW
|
||||||
@@ -2401,18 +2449,10 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updatePayload);
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updatePayload);
|
||||||
|
|
||||||
if (!needsFactorUpload && claim.blameRequestId) {
|
// Same effective step as WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW: owner reviews/signs (yara-signature).
|
||||||
const blame = await this.blameRequestDbService.findById(
|
if (!needsFactorUpload) {
|
||||||
claim.blameRequestId.toString(),
|
const ownerPhone = await this.resolveClaimOwnerPhone(claim);
|
||||||
);
|
if (ownerPhone) {
|
||||||
if (blame?.type === BlameRequestType.THIRD_PARTY) {
|
|
||||||
const ownerUserId = claim.owner?.userId ? String(claim.owner.userId) : "";
|
|
||||||
const ownerParty = (blame.parties || []).find(
|
|
||||||
(p: any) =>
|
|
||||||
p?.person?.userId && String(p.person.userId) === ownerUserId,
|
|
||||||
);
|
|
||||||
const ownerPhone = ownerParty?.person?.phoneNumber;
|
|
||||||
if (ownerPhone && typeof ownerPhone === "string") {
|
|
||||||
const expertLastName =
|
const expertLastName =
|
||||||
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||||
await this.smsOrchestrationService.sendSignatureReviewNotice({
|
await this.smsOrchestrationService.sendSignatureReviewNotice({
|
||||||
@@ -2426,7 +2466,6 @@ export class ExpertClaimService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
@@ -2486,6 +2525,11 @@ export class ExpertClaimService {
|
|||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||||||
'workflow.locked': false,
|
'workflow.locked': false,
|
||||||
|
$unset: {
|
||||||
|
'workflow.lockedAt': '',
|
||||||
|
'workflow.expiredAt': '',
|
||||||
|
'workflow.lockedBy': '',
|
||||||
|
},
|
||||||
...(note ? { 'evaluation.visitLocation': note } : {}),
|
...(note ? { 'evaluation.visitLocation': note } : {}),
|
||||||
...(visitSnapshot && {
|
...(visitSnapshot && {
|
||||||
'evaluation.inPersonVisitExpertProfileSnapshot': visitSnapshot,
|
'evaluation.inPersonVisitExpertProfileSnapshot': visitSnapshot,
|
||||||
@@ -2565,6 +2609,15 @@ export class ExpertClaimService {
|
|||||||
claimCaseTouchesClient(c, clientKey),
|
claimCaseTouchesClient(c, clientKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const c of filtered) {
|
||||||
|
if (
|
||||||
|
c.workflow?.locked &&
|
||||||
|
!this.isClaimV2WorkflowLockCurrentlyEnforced(c)
|
||||||
|
) {
|
||||||
|
await this.expireClaimWorkflowLockV2IfStale(String(c._id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const blameIds = [
|
const blameIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
filtered
|
filtered
|
||||||
@@ -2593,16 +2646,21 @@ export class ExpertClaimService {
|
|||||||
? blameById.get(c.blameRequestId.toString())
|
? blameById.get(c.blameRequestId.toString())
|
||||||
: undefined;
|
: undefined;
|
||||||
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
|
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
|
||||||
|
const lockActive =
|
||||||
|
!!(c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c));
|
||||||
return {
|
return {
|
||||||
claimRequestId: c._id.toString(),
|
claimRequestId: c._id.toString(),
|
||||||
publicId: c.publicId,
|
publicId: c.publicId,
|
||||||
status: c.status,
|
status: c.status,
|
||||||
currentStep: c.workflow?.currentStep || "",
|
currentStep: c.workflow?.currentStep || "",
|
||||||
locked: c.workflow?.locked || false,
|
locked: lockActive,
|
||||||
lockedBy: c.workflow?.lockedBy
|
lockedBy:
|
||||||
|
lockActive && c.workflow?.lockedBy
|
||||||
? {
|
? {
|
||||||
actorId: c.workflow.lockedBy.actorId?.toString(),
|
actorId: c.workflow.lockedBy.actorId?.toString(),
|
||||||
actorName: c.workflow.lockedBy.actorName,
|
actorName: c.workflow.lockedBy.actorName,
|
||||||
|
lockedAt: (c.workflow as any).lockedAt?.toISOString?.(),
|
||||||
|
expiredAt: (c.workflow as any).expiredAt?.toISOString?.(),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
vehicle: v
|
vehicle: v
|
||||||
@@ -2735,11 +2793,15 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when another expert still holds an active (non-expired) workflow lock.
|
* True while a workflow lock is still within its window (`expiredAt` when set, else lockedAt + TTL).
|
||||||
* Missing `lockedAt` on old documents: treat lock as still enforced until cleared.
|
* Missing both timestamps on old documents: treat lock as enforced until cleared.
|
||||||
*/
|
*/
|
||||||
private isClaimV2WorkflowLockCurrentlyEnforced(claim: any): boolean {
|
private isClaimV2WorkflowLockCurrentlyEnforced(claim: any): boolean {
|
||||||
if (!claim?.workflow?.locked) return false;
|
if (!claim?.workflow?.locked) return false;
|
||||||
|
const exp = claim.workflow?.expiredAt;
|
||||||
|
if (exp) {
|
||||||
|
return Date.now() < new Date(exp as Date).getTime();
|
||||||
|
}
|
||||||
const la = claim.workflow?.lockedAt as Date | string | undefined;
|
const la = claim.workflow?.lockedAt as Date | string | undefined;
|
||||||
if (!la) return true;
|
if (!la) return true;
|
||||||
return (
|
return (
|
||||||
@@ -2748,6 +2810,55 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist unlock when claim V2 workflow lock TTL has passed (matches blame V2 behaviour). */
|
||||||
|
private async expireClaimWorkflowLockV2IfStale(
|
||||||
|
claimRequestId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (!claim?.workflow?.locked) return;
|
||||||
|
|
||||||
|
const expiredAt = claim.workflow?.expiredAt;
|
||||||
|
const lockedAt = claim.workflow?.lockedAt;
|
||||||
|
const now = Date.now();
|
||||||
|
const ttl = this.claimV2WorkflowLockTtlMs;
|
||||||
|
|
||||||
|
if (expiredAt && now < new Date(expiredAt as Date).getTime()) return;
|
||||||
|
if (
|
||||||
|
!expiredAt &&
|
||||||
|
lockedAt &&
|
||||||
|
now < new Date(lockedAt as Date).getTime() + ttl
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!expiredAt && !lockedAt) return;
|
||||||
|
|
||||||
|
const lockedById = claim.workflow.lockedBy?.actorId;
|
||||||
|
const filter: Record<string, unknown> = {
|
||||||
|
_id: new Types.ObjectId(claimRequestId),
|
||||||
|
"workflow.locked": true,
|
||||||
|
};
|
||||||
|
if (expiredAt) {
|
||||||
|
filter["workflow.expiredAt"] = expiredAt;
|
||||||
|
} else if (lockedAt) {
|
||||||
|
filter["workflow.lockedAt"] = lockedAt;
|
||||||
|
} else if (lockedById) {
|
||||||
|
filter["workflow.lockedBy.actorId"] = lockedById;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findOneAndUpdate(
|
||||||
|
filter as any,
|
||||||
|
{
|
||||||
|
$set: { "workflow.locked": false },
|
||||||
|
$unset: {
|
||||||
|
"workflow.lockedAt": "",
|
||||||
|
"workflow.expiredAt": "",
|
||||||
|
"workflow.lockedBy": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ new: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Get claim detail for damage expert
|
* V2: Get claim detail for damage expert
|
||||||
*
|
*
|
||||||
@@ -2762,6 +2873,7 @@ export class ExpertClaimService {
|
|||||||
actor: any,
|
actor: any,
|
||||||
): Promise<ClaimDetailV2ResponseDto> {
|
): Promise<ClaimDetailV2ResponseDto> {
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
|
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
@@ -2952,6 +3064,7 @@ export class ExpertClaimService {
|
|||||||
actorId: claim.workflow.lockedBy.actorId?.toString(),
|
actorId: claim.workflow.lockedBy.actorId?.toString(),
|
||||||
actorName: claim.workflow.lockedBy.actorName,
|
actorName: claim.workflow.lockedBy.actorName,
|
||||||
lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
|
lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
|
||||||
|
expiredAt: (claim.workflow as any).expiredAt?.toISOString(),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
owner: claim.owner
|
owner: claim.owner
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export class Workflow {
|
|||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
lockedAt?: Date;
|
lockedAt?: Date;
|
||||||
|
|
||||||
|
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||||
|
@Prop({ type: Date })
|
||||||
|
expiredAt?: Date;
|
||||||
|
|
||||||
@Prop({ type: ActorLockSchema })
|
@Prop({ type: ActorLockSchema })
|
||||||
lockedBy?: ActorLock;
|
lockedBy?: ActorLock;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
|
||||||
import { RequestManagementController } from "./request-management.controller";
|
|
||||||
import { RequestManagementService } from "./request-management.service";
|
|
||||||
|
|
||||||
describe("RequestManagementController", () => {
|
|
||||||
let controller: RequestManagementController;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [RequestManagementController],
|
|
||||||
providers: [RequestManagementService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<RequestManagementController>(
|
|
||||||
RequestManagementController,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be defined", () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
|
||||||
import { RequestManagementService } from "./request-management.service";
|
|
||||||
|
|
||||||
describe("RequestManagementService", () => {
|
|
||||||
let service: RequestManagementService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [RequestManagementService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<RequestManagementService>(RequestManagementService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be defined", () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -120,17 +120,17 @@ export class SmsOrchestrationService implements OnModuleInit {
|
|||||||
async sendThirdPartyExpertStartedReviewNotice(
|
async sendThirdPartyExpertStartedReviewNotice(
|
||||||
params: {
|
params: {
|
||||||
receptor: string;
|
receptor: string;
|
||||||
|
fileKind: "blame" | "claim";
|
||||||
publicId: string;
|
publicId: string;
|
||||||
expertLastName: string;
|
expertLastName: string;
|
||||||
},
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return this.sendTemplate({
|
return this.sendTemplate({
|
||||||
template:
|
template: "yara-expert-lock",
|
||||||
process.env.SMS_TEMPLATE_THIRD_PARTY_EXPERT_LOCK ||
|
|
||||||
"yara-blame-expert-lock",
|
|
||||||
receptor: params.receptor,
|
receptor: params.receptor,
|
||||||
token: params.publicId,
|
token: params.fileKind === "blame" ? "تصادف" : "خسارت",
|
||||||
token2: params.expertLastName || "کارشناس",
|
token2: params.publicId,
|
||||||
|
token3: params.expertLastName || "کارشناس",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user