From 885678df7dc02755406fea700e7288a1b0a35288 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 26 Apr 2026 15:58:21 +0330 Subject: [PATCH 1/2] Fixed lock mechanism, and some resendItem types --- .../in-person-dcouments-enum.ts | 4 +- .../schema/claim-case.workflow.schema.ts | 20 +++ src/expert-blame/expert-blame.service.ts | 10 +- src/expert-claim/expert-claim.service.ts | 147 +++++++++++++--- .../request-management.service.ts | 23 +++ .../sms-orchestration.service.ts | 14 ++ test-sms.js | 163 ------------------ 7 files changed, 188 insertions(+), 193 deletions(-) delete mode 100644 test-sms.js diff --git a/src/Types&Enums/claim-request-management/in-person-dcouments-enum.ts b/src/Types&Enums/claim-request-management/in-person-dcouments-enum.ts index bc8b48e..6cd2081 100644 --- a/src/Types&Enums/claim-request-management/in-person-dcouments-enum.ts +++ b/src/Types&Enums/claim-request-management/in-person-dcouments-enum.ts @@ -3,4 +3,6 @@ export enum InPersonDocumentsEnum { CarCertificate = "carCertificate", DrivingLicense = "drivingLicense", CarGreenCard = "carGreenCard", -} + Plate = "plate", + CarPlate = "carPlate", +} \ No newline at end of file diff --git a/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts b/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts index 1eb09ba..fc16851 100644 --- a/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts @@ -1,6 +1,7 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Types } from "mongoose"; 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 { ExpertProfileSnapshot, ExpertProfileSnapshotSchema, @@ -23,6 +24,22 @@ export class 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 }) export class ClaimWorkflow { @Prop({ type: String, enum: ClaimWorkflowStep }) @@ -46,6 +63,9 @@ export class ClaimWorkflow { @Prop({ type: ClaimActorLockSchema }) lockedBy?: ClaimActorLock; + + @Prop({ type: ClaimPreLockQueueSnapshotSchema }) + preLockQueueSnapshot?: ClaimPreLockQueueSnapshot; } export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow); diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 03f6c1b..a80f034 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -305,15 +305,7 @@ export class ExpertBlameService { for (const doc of visibleCases) { const w = doc.workflow as Record | undefined; if (!w?.locked) continue; - const la = w.lockedAt; - if (!la) { - staleIds.add(String(doc._id)); - continue; - } - if ( - Date.now() >= - new Date(la as string | Date).getTime() + this.blameV2LockTtlMs - ) { + if (!this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any)) { staleIds.add(String(doc._id)); } } diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 011c456..73d841f 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -2158,6 +2158,14 @@ export class ExpertClaimService { actorRole: 'damage_expert', ...(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, $push: { history: { @@ -2264,6 +2272,7 @@ export class ExpertClaimService { "workflow.lockedAt": "", "workflow.expiredAt": "", "workflow.lockedBy": "", + "workflow.preLockQueueSnapshot": "", }, $push: { history: { @@ -2419,6 +2428,7 @@ export class ExpertClaimService { 'workflow.lockedAt': '', 'workflow.expiredAt': '', 'workflow.lockedBy': '', + 'workflow.preLockQueueSnapshot': '', }, 'workflow.currentStep': nextStep, 'workflow.nextStep': needsFactorUpload @@ -2529,6 +2539,7 @@ export class ExpertClaimService { 'workflow.lockedAt': '', 'workflow.expiredAt': '', 'workflow.lockedBy': '', + 'workflow.preLockQueueSnapshot': '', }, ...(note ? { 'evaluation.visitLocation': note } : {}), ...(visitSnapshot && { @@ -2584,6 +2595,8 @@ export class ExpertClaimService { requireActorClientKey(actor); const actorId = actor.sub; const clientKey = actor.clientKey as string; + const lockTtlMs = this.claimV2WorkflowLockTtlMs; + const now = new Date(); const claims = await this.claimCaseDbService.find({ $or: [ // Available claims: waiting for expert, not locked @@ -2596,6 +2609,27 @@ export class ExpertClaimService { 'workflow.locked': true, '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) { status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, @@ -2609,12 +2643,32 @@ export class ExpertClaimService { claimCaseTouchesClient(c, clientKey), ); - for (const c of filtered) { - if ( + const staleLockToReconcile = filtered.filter( + (c) => c.workflow?.locked && - !this.isClaimV2WorkflowLockCurrentlyEnforced(c) - ) { - await this.expireClaimWorkflowLockV2IfStale(String(c._id)); + !this.isClaimV2WorkflowLockCurrentlyEnforced(c), + ); + 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). * 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( claimRequestId: string, - ): Promise { + ): Promise { const claim = await this.claimCaseDbService.findById(claimRequestId); - if (!claim?.workflow?.locked) return; + if (!claim?.workflow?.locked) return false; 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 && now < new Date(expiredAt as Date).getTime()) return false; if ( !expiredAt && lockedAt && 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 filter: Record = { @@ -2845,18 +2915,55 @@ export class ExpertClaimService { 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, - { - $set: { "workflow.locked": false }, - $unset: { - "workflow.lockedAt": "", - "workflow.expiredAt": "", - "workflow.lockedBy": "", - }, - }, + update as any, { new: false }, ); + return !!cleared; } /** diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 7a96cef..5f6c635 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -6446,6 +6446,29 @@ export class RequestManagementService { "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 { finalStatus = CaseStatus.STOPPED; message = diff --git a/src/sms-orchestration/sms-orchestration.service.ts b/src/sms-orchestration/sms-orchestration.service.ts index ef13ca1..d2258f7 100644 --- a/src/sms-orchestration/sms-orchestration.service.ts +++ b/src/sms-orchestration/sms-orchestration.service.ts @@ -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 { + return this.sendTemplate({ + template: "yara-claim-link", + receptor: params.receptor, + token: params.publicId, + token2: params.link, + }); + } + async sendThirdPartyExpertStartedReviewNotice( params: { receptor: string; diff --git a/test-sms.js b/test-sms.js deleted file mode 100644 index e71ca6d..0000000 --- a/test-sms.js +++ /dev/null @@ -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 }; - From 4b0e8a3547003b741726c01300b19a967c0dfb13 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 26 Apr 2026 16:08:14 +0330 Subject: [PATCH 2/2] FIX LOCKS --- src/expert-claim/expert-claim.service.ts | 84 +++++++++++++++++------- 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 73d841f..79e3f49 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -2595,8 +2595,9 @@ export class ExpertClaimService { requireActorClientKey(actor); const actorId = actor.sub; const clientKey = actor.clientKey as string; - const lockTtlMs = this.claimV2WorkflowLockTtlMs; - const now = new Date(); + + await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor); + const claims = await this.claimCaseDbService.find({ $or: [ // Available claims: waiting for expert, not locked @@ -2609,27 +2610,6 @@ export class ExpertClaimService { 'workflow.locked': true, '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) { status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, @@ -2702,10 +2682,14 @@ export class ExpertClaimService { const fileCtx = blame ? this.blameFileContextForExpert(blame) : {}; const lockActive = !!(c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)); + const statusForList = + c.status === ClaimCaseStatus.EXPERT_REVIEWING + ? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT + : c.status; return { claimRequestId: c._id.toString(), publicId: c.publicId, - status: c.status, + status: statusForList, currentStep: c.workflow?.currentStep || "", locked: lockActive, lockedBy: @@ -2876,6 +2860,51 @@ export class ExpertClaimService { ); } + /** + * EXPERT_REVIEWING + expired lock is not listed until status is reverted. Run expire for this tenant + * so the next query returns those cases as WAITING_FOR_DAMAGE_EXPERT. + */ + private async reconcileStaleExpertReviewingClaimLocksForTenant(actor: { + sub: string; + clientKey?: string; + }): Promise { + const clientKey = requireActorClientKey(actor); + const lockTtlMs = this.claimV2WorkflowLockTtlMs; + const now = new Date(); + const candidates = (await this.claimCaseDbService.find( + { + status: ClaimCaseStatus.EXPERT_REVIEWING, + "workflow.locked": true, + $or: [ + { "workflow.expiredAt": { $lte: now } }, + { + $expr: { + $and: [ + { $eq: ["$workflow.locked", true] }, + { + $lte: [ + { $add: ["$workflow.lockedAt", lockTtlMs] }, + now, + ], + }, + ], + }, + }, + ], + }, + { lean: true, select: "_id owner" }, + )) as Array<{ _id: unknown; owner?: unknown }>; + + const relevant = candidates.filter((c) => + claimCaseTouchesClient(c, clientKey), + ); + await Promise.all( + relevant.map((c) => + this.expireClaimWorkflowLockV2IfStale(String(c._id)), + ), + ); + } + /** * Persist unlock when claim V2 workflow lock TTL has passed. * If the expert never submitted a damage reply, restores queue fields from @@ -3158,10 +3187,15 @@ export class ExpertClaimService { } : undefined; + const statusForExpertApi = + claim.status === ClaimCaseStatus.EXPERT_REVIEWING + ? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT + : claim.status; + return { claimRequestId: claim._id.toString(), publicId: claim.publicId, - status: claim.status, + status: statusForExpertApi, claimStatus: claim.claimStatus || 'PENDING', currentStep: claim.workflow?.currentStep || '', nextStep: claim.workflow?.nextStep,