Fixed lock mechanism, and some resendItem types

This commit is contained in:
SepehrYahyaee
2026-04-26 15:58:21 +03:30
parent b5b3b722c6
commit 885678df7d
7 changed files with 188 additions and 193 deletions

View File

@@ -3,4 +3,6 @@ export enum InPersonDocumentsEnum {
CarCertificate = "carCertificate", CarCertificate = "carCertificate",
DrivingLicense = "drivingLicense", DrivingLicense = "drivingLicense",
CarGreenCard = "carGreenCard", CarGreenCard = "carGreenCard",
Plate = "plate",
CarPlate = "carPlate",
} }

View File

@@ -1,6 +1,7 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { import {
ExpertProfileSnapshot, ExpertProfileSnapshot,
ExpertProfileSnapshotSchema, ExpertProfileSnapshotSchema,
@@ -23,6 +24,22 @@ export class ClaimActorLock {
} }
export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock); export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock);
/** Captures queue fields before a damage-expert lock; restored if the lock TTL expires without a reply. */
@Schema({ _id: false })
export class ClaimPreLockQueueSnapshot {
@Prop({ type: String, enum: ClaimStatus, required: true })
claimStatus: ClaimStatus;
@Prop({ type: String, enum: ClaimWorkflowStep, required: true })
currentStep: ClaimWorkflowStep;
@Prop({ type: String, enum: ClaimWorkflowStep, required: false })
nextStep?: ClaimWorkflowStep;
}
export const ClaimPreLockQueueSnapshotSchema = SchemaFactory.createForClass(
ClaimPreLockQueueSnapshot,
);
@Schema({ _id: false }) @Schema({ _id: false })
export class ClaimWorkflow { export class ClaimWorkflow {
@Prop({ type: String, enum: ClaimWorkflowStep }) @Prop({ type: String, enum: ClaimWorkflowStep })
@@ -46,6 +63,9 @@ export class ClaimWorkflow {
@Prop({ type: ClaimActorLockSchema }) @Prop({ type: ClaimActorLockSchema })
lockedBy?: ClaimActorLock; lockedBy?: ClaimActorLock;
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
} }
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow); export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);

View File

@@ -305,15 +305,7 @@ export class ExpertBlameService {
for (const doc of visibleCases) { for (const doc of visibleCases) {
const w = doc.workflow as Record<string, unknown> | undefined; const w = doc.workflow as Record<string, unknown> | undefined;
if (!w?.locked) continue; if (!w?.locked) continue;
const la = w.lockedAt; if (!this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any)) {
if (!la) {
staleIds.add(String(doc._id));
continue;
}
if (
Date.now() >=
new Date(la as string | Date).getTime() + this.blameV2LockTtlMs
) {
staleIds.add(String(doc._id)); staleIds.add(String(doc._id));
} }
} }

View File

@@ -2158,6 +2158,14 @@ export class ExpertClaimService {
actorRole: 'damage_expert', actorRole: 'damage_expert',
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
}, },
'workflow.preLockQueueSnapshot': {
claimStatus: claim.claimStatus,
currentStep:
claim.workflow?.currentStep ?? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
...(claim.workflow?.nextStep != null
? { nextStep: claim.workflow.nextStep }
: {}),
},
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, 'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
$push: { $push: {
history: { history: {
@@ -2264,6 +2272,7 @@ export class ExpertClaimService {
"workflow.lockedAt": "", "workflow.lockedAt": "",
"workflow.expiredAt": "", "workflow.expiredAt": "",
"workflow.lockedBy": "", "workflow.lockedBy": "",
"workflow.preLockQueueSnapshot": "",
}, },
$push: { $push: {
history: { history: {
@@ -2419,6 +2428,7 @@ export class ExpertClaimService {
'workflow.lockedAt': '', 'workflow.lockedAt': '',
'workflow.expiredAt': '', 'workflow.expiredAt': '',
'workflow.lockedBy': '', 'workflow.lockedBy': '',
'workflow.preLockQueueSnapshot': '',
}, },
'workflow.currentStep': nextStep, 'workflow.currentStep': nextStep,
'workflow.nextStep': needsFactorUpload 'workflow.nextStep': needsFactorUpload
@@ -2529,6 +2539,7 @@ export class ExpertClaimService {
'workflow.lockedAt': '', 'workflow.lockedAt': '',
'workflow.expiredAt': '', 'workflow.expiredAt': '',
'workflow.lockedBy': '', 'workflow.lockedBy': '',
'workflow.preLockQueueSnapshot': '',
}, },
...(note ? { 'evaluation.visitLocation': note } : {}), ...(note ? { 'evaluation.visitLocation': note } : {}),
...(visitSnapshot && { ...(visitSnapshot && {
@@ -2584,6 +2595,8 @@ export class ExpertClaimService {
requireActorClientKey(actor); requireActorClientKey(actor);
const actorId = actor.sub; const actorId = actor.sub;
const clientKey = actor.clientKey as string; const clientKey = actor.clientKey as string;
const lockTtlMs = this.claimV2WorkflowLockTtlMs;
const now = new Date();
const claims = await this.claimCaseDbService.find({ const claims = await this.claimCaseDbService.find({
$or: [ $or: [
// Available claims: waiting for expert, not locked // Available claims: waiting for expert, not locked
@@ -2596,6 +2609,27 @@ export class ExpertClaimService {
'workflow.locked': true, 'workflow.locked': true,
'workflow.lockedBy.actorId': new Types.ObjectId(actorId), 'workflow.lockedBy.actorId': new Types.ObjectId(actorId),
}, },
// EXPERT_REVIEWING but lock cleared or TTL elapsed (reconcile on read; fixes invisible rows)
{
status: ClaimCaseStatus.EXPERT_REVIEWING,
$or: [
{ 'workflow.locked': { $ne: true } },
{ 'workflow.expiredAt': { $lte: now } },
{
$expr: {
$and: [
{ $eq: ['$workflow.locked', true] },
{
$lte: [
{ $add: ['$workflow.lockedAt', lockTtlMs] },
now,
],
},
],
},
},
],
},
// User uploaded all factors; expert must approve/reject (unlocked queue) // User uploaded all factors; expert must approve/reject (unlocked queue)
{ {
status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
@@ -2609,12 +2643,32 @@ export class ExpertClaimService {
claimCaseTouchesClient(c, clientKey), claimCaseTouchesClient(c, clientKey),
); );
for (const c of filtered) { const staleLockToReconcile = filtered.filter(
if ( (c) =>
c.workflow?.locked && c.workflow?.locked &&
!this.isClaimV2WorkflowLockCurrentlyEnforced(c) !this.isClaimV2WorkflowLockCurrentlyEnforced(c),
) { );
await this.expireClaimWorkflowLockV2IfStale(String(c._id)); const touchedIds = (
await Promise.all(
staleLockToReconcile.map(async (c) => {
const updated = await this.expireClaimWorkflowLockV2IfStale(
String(c._id),
);
return updated ? String(c._id) : null;
}),
)
).filter((id): id is string => id != null);
if (touchedIds.length > 0) {
const refreshed = await this.claimCaseDbService.find({
_id: { $in: touchedIds.map((id) => new Types.ObjectId(id)) },
});
const byId = new Map(
refreshed.map((r) => [String(r._id), r] as const),
);
for (const c of filtered) {
const r = byId.get(String(c._id));
if (r) Object.assign(c, r);
} }
} }
@@ -2792,6 +2846,18 @@ export class ExpertClaimService {
}; };
} }
private claimHasDamageExpertReply(claim: {
evaluation?: {
damageExpertReply?: unknown;
damageExpertReplyFinal?: unknown;
};
}): boolean {
return !!(
claim.evaluation?.damageExpertReply ||
claim.evaluation?.damageExpertReplyFinal
);
}
/** /**
* True while a workflow lock is still within its window (`expiredAt` when set, else lockedAt + TTL). * True while a workflow lock is still within its window (`expiredAt` when set, else lockedAt + TTL).
* Missing both timestamps on old documents: treat lock as enforced until cleared. * Missing both timestamps on old documents: treat lock as enforced until cleared.
@@ -2810,27 +2876,31 @@ export class ExpertClaimService {
); );
} }
/** Persist unlock when claim V2 workflow lock TTL has passed (matches blame V2 behaviour). */ /**
* Persist unlock when claim V2 workflow lock TTL has passed.
* If the expert never submitted a damage reply, restores queue fields from
* `workflow.preLockQueueSnapshot` (or defaults) so the case shows as WAITING_FOR_DAMAGE_EXPERT again.
*/
private async expireClaimWorkflowLockV2IfStale( private async expireClaimWorkflowLockV2IfStale(
claimRequestId: string, claimRequestId: string,
): Promise<void> { ): Promise<boolean> {
const claim = await this.claimCaseDbService.findById(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim?.workflow?.locked) return; if (!claim?.workflow?.locked) return false;
const expiredAt = claim.workflow?.expiredAt; const expiredAt = claim.workflow?.expiredAt;
const lockedAt = claim.workflow?.lockedAt; const lockedAt = claim.workflow?.lockedAt;
const now = Date.now(); const now = Date.now();
const ttl = this.claimV2WorkflowLockTtlMs; const ttl = this.claimV2WorkflowLockTtlMs;
if (expiredAt && now < new Date(expiredAt as Date).getTime()) return; if (expiredAt && now < new Date(expiredAt as Date).getTime()) return false;
if ( if (
!expiredAt && !expiredAt &&
lockedAt && lockedAt &&
now < new Date(lockedAt as Date).getTime() + ttl now < new Date(lockedAt as Date).getTime() + ttl
) { ) {
return; return false;
} }
if (!expiredAt && !lockedAt) return; if (!expiredAt && !lockedAt) return false;
const lockedById = claim.workflow.lockedBy?.actorId; const lockedById = claim.workflow.lockedBy?.actorId;
const filter: Record<string, unknown> = { const filter: Record<string, unknown> = {
@@ -2845,18 +2915,55 @@ export class ExpertClaimService {
filter["workflow.lockedBy.actorId"] = lockedById; filter["workflow.lockedBy.actorId"] = lockedById;
} }
await this.claimCaseDbService.findOneAndUpdate( const needsQueueRestore =
claim.status === ClaimCaseStatus.EXPERT_REVIEWING &&
!this.claimHasDamageExpertReply(claim);
const snap = claim.workflow?.preLockQueueSnapshot as
| {
claimStatus?: ClaimStatus;
currentStep?: ClaimWorkflowStep;
nextStep?: ClaimWorkflowStep;
}
| undefined;
const restoreClaimStatus = snap?.claimStatus ?? ClaimStatus.PENDING;
const restoreCurrent =
snap?.currentStep ?? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
const restoreNext =
snap?.nextStep != null
? snap.nextStep
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
const lockFieldsUnset = {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
"workflow.preLockQueueSnapshot": "",
};
const update = needsQueueRestore
? {
$set: {
"workflow.locked": false,
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: restoreClaimStatus,
"workflow.currentStep": restoreCurrent,
"workflow.nextStep": restoreNext,
},
$unset: lockFieldsUnset,
}
: {
$set: { "workflow.locked": false },
$unset: lockFieldsUnset,
};
const cleared = await this.claimCaseDbService.findOneAndUpdate(
filter as any, filter as any,
{ update as any,
$set: { "workflow.locked": false },
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
},
},
{ new: false }, { new: false },
); );
return !!cleared;
} }
/** /**

View File

@@ -6446,6 +6446,29 @@ export class RequestManagementService {
"workflow.completedSteps": "WAITING_FOR_SIGNATURES", "workflow.completedSteps": "WAITING_FOR_SIGNATURES",
}, },
}); });
if (updatedRequest.type === BlameRequestType.THIRD_PARTY) {
const guiltyPartyId = updatedRequest.expert?.decision?.guiltyPartyId
? String(updatedRequest.expert.decision.guiltyPartyId)
: null;
if (guiltyPartyId) {
const damagedParty = updatedRequest.parties?.find(
(p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId,
);
const phone = damagedParty?.person?.phoneNumber?.trim();
if (phone) {
const publicId = String(updatedRequest.publicId);
const link = this.smsOrchestrationService.buildClaimLink(requestId);
await this.smsOrchestrationService.sendThirdPartyDamagedPartyClaimLinkNotice(
{
receptor: phone,
publicId,
link,
},
);
}
}
}
} else { } else {
finalStatus = CaseStatus.STOPPED; finalStatus = CaseStatus.STOPPED;
message = message =

View File

@@ -117,6 +117,20 @@ export class SmsOrchestrationService implements OnModuleInit {
}); });
} }
/** THIRD_PARTY only: notify the damaged (non-guilty) party that blame is completed and they can open the claim flow. */
async sendThirdPartyDamagedPartyClaimLinkNotice(params: {
receptor: string;
publicId: string;
link: string;
}): Promise<boolean> {
return this.sendTemplate({
template: "yara-claim-link",
receptor: params.receptor,
token: params.publicId,
token2: params.link,
});
}
async sendThirdPartyExpertStartedReviewNotice( async sendThirdPartyExpertStartedReviewNotice(
params: { params: {
receptor: string; receptor: string;

View File

@@ -1,163 +0,0 @@
/**
* Test script for sending SMS via Kavenegar API
*
* Usage:
* node test-sms.js
*
* Make sure to update the API_KEY and RECEPTOR (your phone number) below
*/
const https = require('https');
const querystring = require('querystring');
// ===== CONFIGURATION =====
// Replace with your actual Kavenegar API key
const API_KEY = '75776C717969412B4B52306A5956462F4A714E6F6C65544D6A2B654B7566786E'; // Replace with your actual API key
// Replace with your phone number (format: 09*********)
const RECEPTOR = '09199503061'; // Replace with your actual phone number
// Optional: Sender number (if not provided, uses default account sender)
// const SENDER = '10004346'; // Optional - can be removed if you want to use default
// Test message
const MESSAGE = 'تست ارسال پیامک از سیستم Yara724';
// ===== END CONFIGURATION =====
function sendSMS(receptor, message, sender = null, options = {}) {
return new Promise((resolve, reject) => {
// Build URL
const baseUrl = `https://api.kavenegar.com/v1/${API_KEY}/sms/send.json`;
// Build query parameters
const params = {
receptor: receptor,
message: encodeURIComponent(message), // Encode message for URL
};
// Only add sender if it's provided (not null/undefined/empty)
if (sender && sender.trim() !== '') {
params.sender = sender;
}
// Add optional parameters
if (options.date) params.date = options.date;
if (options.type) params.type = options.type;
if (options.localid) params.localid = options.localid;
if (options.hide) params.hide = options.hide;
if (options.tag) params.tag = options.tag;
if (options.policy) params.policy = options.policy;
const queryString = querystring.stringify(params);
const url = `${baseUrl}?${queryString}`;
console.log('\n📤 Sending SMS...');
console.log('URL:', url.replace(API_KEY, '***API_KEY***'));
console.log('Receptor:', receptor);
console.log('Message:', message);
console.log('Sender:', sender || '(Using default account sender)');
// Make HTTPS request
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const response = JSON.parse(data);
if (response.return && response.return.status === 200) {
console.log('\n✅ SMS sent successfully!');
console.log('Response:', JSON.stringify(response, null, 2));
if (response.entries && response.entries.length > 0) {
console.log('\n📊 SMS Details:');
response.entries.forEach((entry, index) => {
console.log(`\n Message ${index + 1}:`);
console.log(` Message ID: ${entry.messageid}`);
console.log(` Status: ${entry.status} (${entry.statustext})`);
console.log(` Receptor: ${entry.receptor}`);
console.log(` Sender: ${entry.sender}`);
console.log(` Cost: ${entry.cost} Rials`);
console.log(` Date: ${new Date(entry.date * 1000).toLocaleString('fa-IR')}`);
});
}
resolve(response);
} else {
console.error('\n❌ Error sending SMS');
console.error('Response:', JSON.stringify(response, null, 2));
reject(new Error(`API Error: ${response.return?.message || 'Unknown error'}`));
}
} catch (error) {
console.error('\n❌ Error parsing response:', error.message);
console.error('Raw response:', data);
reject(error);
}
});
}).on('error', (error) => {
console.error('\n❌ Network error:', error.message);
reject(error);
});
});
}
/**
* Test function
*/
async function testSMS() {
console.log('='.repeat(50));
console.log('🧪 Kavenegar SMS Test Script');
console.log('='.repeat(50));
// Validate configuration
if (API_KEY === '613472435563797A37677331D' || API_KEY.length < 10) {
console.error('\n⚠ WARNING: Please update API_KEY with your actual Kavenegar API key!');
console.error(' You can find it in your Kavenegar panel: https://panel.kavenegar.com/client/membership/api');
}
if (RECEPTOR === '09123456789' || !RECEPTOR.startsWith('09')) {
console.error('\n⚠ WARNING: Please update RECEPTOR with your actual phone number!');
console.error(' Format: 09********* (11 digits starting with 09)');
}
try {
// Test 1: Simple SMS (without sender - uses default account sender)
console.log('\n\n📝 Test 1: Simple SMS (No sender - using default)');
await sendSMS(RECEPTOR, MESSAGE);
// Wait a bit before next test
await new Promise(resolve => setTimeout(resolve, 2000));
// Test 2: SMS with tag (optional - uncomment if you have tags set up)
// console.log('\n\n📝 Test 2: SMS with Tag');
// await sendSMS(RECEPTOR, 'تست پیامک با تگ', SENDER, { tag: 'test' });
// Test 3: Multiple recipients (uncomment to test)
// console.log('\n\n📝 Test 3: Multiple Recipients');
// const multipleReceptors = `${RECEPTOR},09123456789`; // Add more numbers separated by comma
// await sendSMS(multipleReceptors, 'تست پیامک به چندین گیرنده', SENDER);
console.log('\n\n' + '='.repeat(50));
console.log('✅ All tests completed!');
console.log('='.repeat(50));
} catch (error) {
console.error('\n\n❌ Test failed:', error.message);
process.exit(1);
}
}
// Run the test
if (require.main === module) {
testSMS().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
}
module.exports = { sendSMS };