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:
2026-04-26 11:44:54 +03:30
10 changed files with 327 additions and 156 deletions

View File

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

View File

@@ -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 {

View File

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

View File

@@ -28,6 +28,7 @@ export class ClaimDetailV2ResponseDto {
actorId: string; actorId: string;
actorName: string; actorName: string;
lockedAt: string; lockedAt: string;
expiredAt?: string;
}; };
@ApiPropertyOptional() @ApiPropertyOptional()

View File

@@ -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' })

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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 || "کارشناس",
}); });
} }