tango-app-api-audit 3.6.53 → 3.6.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/verify-retrigger-lock.mjs +115 -0
- package/src/controllers/audit.controllers.js +52 -4
- package/src/controllers/eyeTestAudit.controllers.js +1 -174
- package/src/dtos/audit.dtos.js +0 -2
- package/src/dtos/eyeTestAudit.dtos.js +0 -11
- package/src/routes/audit.routes.js +0 -3
- package/src/routes/eyeTestAudit.routes.js +2 -6
- package/src/service/reTriggerLock.service.js +129 -0
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
/**
|
|
3
|
+
* Standalone verification for the reTrigger idempotency lock.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/verify-retrigger-lock.mjs
|
|
6
|
+
*
|
|
7
|
+
* Connects to the configured (non-prod) Mongo, exercises the lock under a
|
|
8
|
+
* concurrent burst, and asserts the dedup / idempotency guarantees. Uses a
|
|
9
|
+
* throwaway, clearly-namespaced key and cleans up after itself.
|
|
10
|
+
*/
|
|
11
|
+
import mongoose from 'mongoose';
|
|
12
|
+
import { connectdb } from '../config/database/database.js';
|
|
13
|
+
import {
|
|
14
|
+
acquireReTriggerLock,
|
|
15
|
+
completeReTriggerLock,
|
|
16
|
+
releaseReTriggerLock,
|
|
17
|
+
} from '../src/service/reTriggerLock.service.js';
|
|
18
|
+
|
|
19
|
+
// Distinctive throwaway key - never collides with real audit data.
|
|
20
|
+
const KEY = {
|
|
21
|
+
storeId: '__verify__-lock-99-999',
|
|
22
|
+
fileDate: '01-01-1970',
|
|
23
|
+
moduleType: 'traffic',
|
|
24
|
+
zoneName: 'traffic_zone',
|
|
25
|
+
};
|
|
26
|
+
const COLLECTION = 'reTriggerLock';
|
|
27
|
+
|
|
28
|
+
let failures = 0;
|
|
29
|
+
function assert( label, cond ) {
|
|
30
|
+
if ( cond ) {
|
|
31
|
+
console.log( ` PASS ${label}` );
|
|
32
|
+
} else {
|
|
33
|
+
failures++;
|
|
34
|
+
console.error( ` FAIL ${label}` );
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function waitForConnection() {
|
|
39
|
+
if ( mongoose.connection.readyState === 1 ) return Promise.resolve();
|
|
40
|
+
return new Promise( ( resolve, reject ) => {
|
|
41
|
+
mongoose.connection.once( 'connected', resolve );
|
|
42
|
+
mongoose.connection.once( 'error', reject );
|
|
43
|
+
} );
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function cleanup() {
|
|
47
|
+
await mongoose.connection.collection( COLLECTION ).deleteMany( {
|
|
48
|
+
storeId: KEY.storeId,
|
|
49
|
+
} );
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function main() {
|
|
53
|
+
connectdb();
|
|
54
|
+
await waitForConnection();
|
|
55
|
+
console.log( 'Connected. Running verification...\n' );
|
|
56
|
+
|
|
57
|
+
await cleanup(); // start clean
|
|
58
|
+
|
|
59
|
+
// 1) Concurrent burst: exactly one caller may acquire.
|
|
60
|
+
console.log( '1) Concurrent burst of 25 identical requests' );
|
|
61
|
+
const burst = await Promise.all(
|
|
62
|
+
Array.from( { length: 25 }, () => acquireReTriggerLock( KEY ) ),
|
|
63
|
+
);
|
|
64
|
+
const acquired = burst.filter( ( r ) => r.acquired ).length;
|
|
65
|
+
assert( 'exactly ONE request acquires the lock', acquired === 1 );
|
|
66
|
+
assert(
|
|
67
|
+
'the other 24 are told it is in progress',
|
|
68
|
+
burst.filter( ( r ) => !r.acquired ).length === 24,
|
|
69
|
+
);
|
|
70
|
+
assert(
|
|
71
|
+
'losers see status "processing"',
|
|
72
|
+
burst.filter( ( r ) => !r.acquired )
|
|
73
|
+
.every( ( r ) => r.lock?.status === 'processing' ),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// 2) Completed combo: subsequent request reports "completed", does not acquire.
|
|
77
|
+
console.log( '\n2) After completion' );
|
|
78
|
+
await completeReTriggerLock( KEY );
|
|
79
|
+
const afterComplete = await acquireReTriggerLock( KEY );
|
|
80
|
+
assert( 'does not re-acquire a completed combo', afterComplete.acquired === false );
|
|
81
|
+
assert( 'reports status "completed"', afterComplete.lock?.status === 'completed' );
|
|
82
|
+
|
|
83
|
+
// 3) Release allows a fresh, legitimate retry.
|
|
84
|
+
console.log( '\n3) After release (failure path)' );
|
|
85
|
+
await releaseReTriggerLock( KEY );
|
|
86
|
+
const afterRelease = await acquireReTriggerLock( KEY );
|
|
87
|
+
assert( 'a released combo can be re-acquired', afterRelease.acquired === true );
|
|
88
|
+
|
|
89
|
+
// 4) Stale 'processing' lock is reclaimed (simulate a crashed prior attempt).
|
|
90
|
+
console.log( '\n4) Stale-lock reclaim' );
|
|
91
|
+
await mongoose.connection.collection( COLLECTION ).updateOne(
|
|
92
|
+
{ storeId: KEY.storeId },
|
|
93
|
+
{ $set: { status: 'processing', updatedAt: new Date( Date.now() - 10 * 60 * 1000 ) } },
|
|
94
|
+
);
|
|
95
|
+
const afterStale = await acquireReTriggerLock( KEY );
|
|
96
|
+
assert( 'a stale (>5m) processing lock is reclaimed', afterStale.acquired === true );
|
|
97
|
+
|
|
98
|
+
// 5) A fresh 'processing' lock is NOT reclaimable.
|
|
99
|
+
console.log( '\n5) Fresh lock is not reclaimable' );
|
|
100
|
+
const freshDup = await acquireReTriggerLock( KEY );
|
|
101
|
+
assert( 'a fresh processing lock blocks new acquirers', freshDup.acquired === false );
|
|
102
|
+
|
|
103
|
+
await cleanup();
|
|
104
|
+
console.log( failures === 0 ? '\nAll checks passed.' : `\n${failures} check(s) FAILED.` );
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
main()
|
|
108
|
+
.catch( ( err ) => {
|
|
109
|
+
console.error( '\nVerification crashed:', err );
|
|
110
|
+
failures++;
|
|
111
|
+
} )
|
|
112
|
+
.finally( async () => {
|
|
113
|
+
await mongoose.connection.close().catch( () => {} );
|
|
114
|
+
process.exit( failures === 0 ? 0 : 1 );
|
|
115
|
+
} );
|
|
@@ -39,6 +39,11 @@ import {
|
|
|
39
39
|
updateOneStoreAudit,
|
|
40
40
|
updateManyStoreAudit,
|
|
41
41
|
} from '../service/storeAudit.service.js';
|
|
42
|
+
import {
|
|
43
|
+
acquireReTriggerLock,
|
|
44
|
+
completeReTriggerLock,
|
|
45
|
+
releaseReTriggerLock,
|
|
46
|
+
} from '../service/reTriggerLock.service.js';
|
|
42
47
|
import _ from 'lodash';
|
|
43
48
|
import {
|
|
44
49
|
createAuditLog,
|
|
@@ -68,7 +73,6 @@ import { aggregateTraxAuditData, findOneTraxAuditData } from '../service/traxAud
|
|
|
68
73
|
import { bulkWriteAuditUserWallet, createAuditUserWallet, findOneAuditUserWallet } from '../service/auditUserWallet.service.js';
|
|
69
74
|
import { updateOneAuditUsers } from '../service/auditUsers.service.js';
|
|
70
75
|
import { bulkUpdate, clearScroll, upsertOpenSearchData, scrollResponse, searchOpenSearchData, updateOpenSearchData } from 'tango-app-api-middleware/src/utils/openSearch.js';
|
|
71
|
-
|
|
72
76
|
// import { aggregateAuditUsers } from '../service/auditUsers.service.js';
|
|
73
77
|
|
|
74
78
|
/* < -- *** Configuration *** --> */
|
|
@@ -3631,9 +3635,37 @@ export async function overAllAuditSummary( req, res ) {
|
|
|
3631
3635
|
}
|
|
3632
3636
|
|
|
3633
3637
|
export async function reTrigger( req, res ) {
|
|
3638
|
+
const inputData = req.body;
|
|
3639
|
+
const lockKey = {
|
|
3640
|
+
storeId: inputData.storeId,
|
|
3641
|
+
fileDate: inputData.fileDate,
|
|
3642
|
+
moduleType: inputData.moduleType,
|
|
3643
|
+
zoneName: inputData.zoneName,
|
|
3644
|
+
};
|
|
3645
|
+
let lockAcquired = false;
|
|
3634
3646
|
try {
|
|
3647
|
+
// --- Idempotency gate ------------------------------------------------
|
|
3648
|
+
// Atomically claim this (storeId, fileDate, moduleType, zoneName) combo.
|
|
3649
|
+
// Under a burst of identical requests exactly one caller wins; the rest get
|
|
3650
|
+
// the existing status back and skip re-processing - so no duplicate SQS
|
|
3651
|
+
// messages / assignments are produced.
|
|
3652
|
+
const { acquired, lock } = await acquireReTriggerLock( {
|
|
3653
|
+
...lockKey,
|
|
3654
|
+
triggeredBy: req.user._id,
|
|
3655
|
+
triggerType: inputData.triggerType,
|
|
3656
|
+
} );
|
|
3657
|
+
if ( !acquired ) {
|
|
3658
|
+
return res.sendSuccess( {
|
|
3659
|
+
result: lock?.status === 'completed' ?
|
|
3660
|
+
'This file has already been Re-Triggered.' :
|
|
3661
|
+
'A Re-Trigger for this file is already in progress.',
|
|
3662
|
+
status: lock?.status || 'processing',
|
|
3663
|
+
duplicate: true,
|
|
3664
|
+
} );
|
|
3665
|
+
}
|
|
3666
|
+
lockAcquired = true;
|
|
3667
|
+
|
|
3635
3668
|
const openSearch = JSON.parse( process.env.OPENSEARCH );
|
|
3636
|
-
const inputData = req.body;
|
|
3637
3669
|
const sqs = JSON.parse( process.env.SQS );
|
|
3638
3670
|
const query = {
|
|
3639
3671
|
storeId: inputData.storeId,
|
|
@@ -3648,6 +3680,13 @@ export async function reTrigger( req, res ) {
|
|
|
3648
3680
|
}
|
|
3649
3681
|
switch ( inputData.triggerType ) {
|
|
3650
3682
|
case 'queue':
|
|
3683
|
+
// Only push to the queue when the store has not already been queued.
|
|
3684
|
+
// A 'not_assign' status means a prior reTrigger already pushed this
|
|
3685
|
+
// store - pushing again would create a duplicate queue message.
|
|
3686
|
+
if ( req.audit.status === 'not_assign' ) {
|
|
3687
|
+
await releaseReTriggerLock( lockKey );
|
|
3688
|
+
return res.sendError( 'This store has already been pushed to the queue', 400 );
|
|
3689
|
+
}
|
|
3651
3690
|
const msg = {
|
|
3652
3691
|
store_id: inputData.storeId,
|
|
3653
3692
|
zone_id: inputData.zoneName,
|
|
@@ -3712,6 +3751,10 @@ export async function reTrigger( req, res ) {
|
|
|
3712
3751
|
}
|
|
3713
3752
|
break;
|
|
3714
3753
|
case 'user':
|
|
3754
|
+
if ( req.audit.status === 'not_assign' ) {
|
|
3755
|
+
await releaseReTriggerLock( lockKey );
|
|
3756
|
+
return res.sendError( 'This store has already assigned to the user', 400 );
|
|
3757
|
+
}
|
|
3715
3758
|
const assignUser = await findOneUser( { _id: new mongoose.Types.ObjectId( inputData.userId ) } );
|
|
3716
3759
|
const assignUserRecord = {
|
|
3717
3760
|
storeId: inputData.storeId,
|
|
@@ -3781,8 +3824,15 @@ export async function reTrigger( req, res ) {
|
|
|
3781
3824
|
|
|
3782
3825
|
|
|
3783
3826
|
await updateOpenSearchData( openSearch.trackerInput, `${inputData.storeId}_${inputData.fileDate}_23:00`, { doc: updateQuery } );
|
|
3827
|
+
// Processing + SQS send succeeded - mark the lock completed so any later
|
|
3828
|
+
// identical request returns the existing status instead of re-triggering.
|
|
3829
|
+
await completeReTriggerLock( lockKey );
|
|
3784
3830
|
return res.sendSuccess( { result: 'The File has been Re-Triggered Succesfully' } );
|
|
3785
3831
|
} catch ( error ) {
|
|
3832
|
+
// Release the lock on failure so a legitimate retry can re-acquire it.
|
|
3833
|
+
if ( lockAcquired ) {
|
|
3834
|
+
await releaseReTriggerLock( lockKey ).catch( () => {} );
|
|
3835
|
+
}
|
|
3786
3836
|
const err = error.message;
|
|
3787
3837
|
logger.error( { error: error, message: req.body, function: 'reTrigger' } );
|
|
3788
3838
|
return res.sendError( err, 500 );
|
|
@@ -5814,5 +5864,3 @@ export async function rePushAuditStores( req, res ) {
|
|
|
5814
5864
|
return res.sendError( err, 500 );
|
|
5815
5865
|
}
|
|
5816
5866
|
}
|
|
5817
|
-
|
|
5818
|
-
|
|
@@ -1414,6 +1414,7 @@ export async function summaryList( req, res ) {
|
|
|
1414
1414
|
if ( inputData.clientId !== '11' ) {
|
|
1415
1415
|
return res.sendError( 'No data found', 204 );
|
|
1416
1416
|
}
|
|
1417
|
+
|
|
1417
1418
|
// declare openSearch and get data form the .env/secret manager
|
|
1418
1419
|
const openSearch = JSON.parse( process.env.OPENSEARCH );
|
|
1419
1420
|
|
|
@@ -1763,7 +1764,6 @@ export async function summaryList( req, res ) {
|
|
|
1763
1764
|
} );
|
|
1764
1765
|
}
|
|
1765
1766
|
const getcount = await aggregateClusters( inputData.keyType === 'rm'?RMQuery: clusterQuery );
|
|
1766
|
-
|
|
1767
1767
|
const count = getcount?.length || 0;
|
|
1768
1768
|
if ( count == 0 ) {
|
|
1769
1769
|
return res.sendError( 'No data', 204 );
|
|
@@ -1787,7 +1787,6 @@ export async function summaryList( req, res ) {
|
|
|
1787
1787
|
);
|
|
1788
1788
|
}
|
|
1789
1789
|
const getCluster = await aggregateClusters( inputData.keyType === 'rm'?RMQuery: clusterQuery );
|
|
1790
|
-
logger.info( { masg: '...........getCluster', getCluster, RMQuery } );
|
|
1791
1790
|
if ( !getCluster || getCluster?.length == 0 ) {
|
|
1792
1791
|
return res.sendError( 'No data', 204 );
|
|
1793
1792
|
}
|
|
@@ -3175,175 +3174,3 @@ export async function updateEmailConfig1( req, res ) {
|
|
|
3175
3174
|
return res.sendError( err, 500 );
|
|
3176
3175
|
}
|
|
3177
3176
|
}
|
|
3178
|
-
|
|
3179
|
-
export async function getConversationDetails( req, res ) {
|
|
3180
|
-
try {
|
|
3181
|
-
const inputData = req.params;
|
|
3182
|
-
|
|
3183
|
-
// Query OpenSearch audio_analytics_report index by doc_id and moduleType
|
|
3184
|
-
const query = {
|
|
3185
|
-
'query': {
|
|
3186
|
-
'bool': {
|
|
3187
|
-
'must': [
|
|
3188
|
-
{
|
|
3189
|
-
'term': {
|
|
3190
|
-
'unique_id.keyword': inputData.id,
|
|
3191
|
-
},
|
|
3192
|
-
},
|
|
3193
|
-
],
|
|
3194
|
-
},
|
|
3195
|
-
},
|
|
3196
|
-
'size': 1,
|
|
3197
|
-
};
|
|
3198
|
-
|
|
3199
|
-
const result = await getOpenSearchData( 'audio_analytics_report_v2', query );
|
|
3200
|
-
if ( result?.body?.hits?.hits?.length === 0 ) {
|
|
3201
|
-
return res.sendError( 'No data found', 204 );
|
|
3202
|
-
}
|
|
3203
|
-
|
|
3204
|
-
const source = result?.body?.hits?.hits[0]?._source;
|
|
3205
|
-
|
|
3206
|
-
if ( !source ) {
|
|
3207
|
-
return res.sendError( 'No data found', 204 );
|
|
3208
|
-
}
|
|
3209
|
-
|
|
3210
|
-
// Helper function to format duration: convert seconds to mm:ss format
|
|
3211
|
-
const formatDuration = ( seconds ) => {
|
|
3212
|
-
if ( !seconds || seconds === 0 ) return '0:00';
|
|
3213
|
-
const minutes = Math.floor( seconds / 60 );
|
|
3214
|
-
const secs = Math.floor( seconds % 60 );
|
|
3215
|
-
return `${minutes}:${secs.toString().padStart( 2, '0' )}`;
|
|
3216
|
-
};
|
|
3217
|
-
|
|
3218
|
-
// Helper: parse "s3://bucket/path/to/file" into { Bucket, file_path }
|
|
3219
|
-
const parseS3Uri = ( uri ) => {
|
|
3220
|
-
if ( !uri ) return null;
|
|
3221
|
-
const withoutScheme = uri.replace( /^s3:\/\//, '' );
|
|
3222
|
-
const slashIdx = withoutScheme.indexOf( '/' );
|
|
3223
|
-
if ( slashIdx === -1 ) return null;
|
|
3224
|
-
return {
|
|
3225
|
-
Bucket: withoutScheme.slice( 0, slashIdx ),
|
|
3226
|
-
file_path: withoutScheme.slice( slashIdx + 1 ),
|
|
3227
|
-
};
|
|
3228
|
-
};
|
|
3229
|
-
|
|
3230
|
-
// Helper function to format transcript: fetch and clean transcript file
|
|
3231
|
-
const getFormattedTranscript = async ( transcriptPath ) => {
|
|
3232
|
-
try {
|
|
3233
|
-
if ( !transcriptPath ) return '';
|
|
3234
|
-
|
|
3235
|
-
const s3Parts = parseS3Uri( transcriptPath );
|
|
3236
|
-
logger.info( { s3Parts: s3Parts, function: 'getFormattedTranscript' } );
|
|
3237
|
-
if ( !s3Parts ) return '';
|
|
3238
|
-
|
|
3239
|
-
const transcriptSignedUrl = await signedUrl( { ...s3Parts, key: 'aws' } );
|
|
3240
|
-
if ( !transcriptSignedUrl ) return '';
|
|
3241
|
-
|
|
3242
|
-
const fetchRes = await fetch( transcriptSignedUrl );
|
|
3243
|
-
if ( !fetchRes.ok ) return '';
|
|
3244
|
-
let text = await fetchRes.text();
|
|
3245
|
-
|
|
3246
|
-
// Remove timestamps like [00:00:33.954 --> 00:00:36.154]
|
|
3247
|
-
text = text.replace( /\[\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}\]\s*/g, '' );
|
|
3248
|
-
|
|
3249
|
-
// Remove [silence] markers
|
|
3250
|
-
text = text.replace( /\[silence\]\s*/g, '' );
|
|
3251
|
-
|
|
3252
|
-
// Replace multiple newlines with single space to create paragraphs
|
|
3253
|
-
text = text.replace( /\n+/g, ' ' );
|
|
3254
|
-
|
|
3255
|
-
// Clean up multiple spaces
|
|
3256
|
-
text = text.replace( /\s+/g, ' ' ).trim();
|
|
3257
|
-
|
|
3258
|
-
return text;
|
|
3259
|
-
} catch ( error ) {
|
|
3260
|
-
logger.error( { message: error, function: 'getFormattedTranscript' } );
|
|
3261
|
-
return '';
|
|
3262
|
-
}
|
|
3263
|
-
};
|
|
3264
|
-
|
|
3265
|
-
// Helper function to format metric names
|
|
3266
|
-
const formatMetricName = ( name ) => {
|
|
3267
|
-
const nameMap = {
|
|
3268
|
-
'Torchlight Exam': 'Torchlight Check',
|
|
3269
|
-
'JCC': 'JCC Check',
|
|
3270
|
-
'Distance Visual Acuity': 'Distance-VA-Check',
|
|
3271
|
-
'Duochrome': 'Duochrome Check',
|
|
3272
|
-
'Near Visual Acuity': 'Near Addition Check',
|
|
3273
|
-
};
|
|
3274
|
-
return nameMap[name] || name;
|
|
3275
|
-
};
|
|
3276
|
-
|
|
3277
|
-
// Format metrics results with sequential time allocation
|
|
3278
|
-
const matchedCohort = source?.cohort_analysis_results?.find( ( c ) => c.cohortId === 'cohort_11_1ddea945-ff3a-4c11-95a9-bb425ca927de' );
|
|
3279
|
-
const metricsData = matchedCohort?.metrics_results || [];
|
|
3280
|
-
|
|
3281
|
-
// Calculate sequential times for detected metrics
|
|
3282
|
-
let currentSeconds = 0;
|
|
3283
|
-
const totalDuration = Math.floor( source?.duration || 1200 );
|
|
3284
|
-
const detectedCount = metricsData.filter( ( m ) => m.contextsEvaluated?.[0]?.isDetected ).length || 1;
|
|
3285
|
-
const secondsPerMetric = Math.floor( totalDuration / Math.max( detectedCount, 1 ) );
|
|
3286
|
-
|
|
3287
|
-
const formattedMetrics = metricsData.map( ( metric, index ) => {
|
|
3288
|
-
const isDetected = metric.contextsEvaluated?.[0]?.isDetected;
|
|
3289
|
-
|
|
3290
|
-
let startTime = '';
|
|
3291
|
-
let endTime = '';
|
|
3292
|
-
|
|
3293
|
-
if ( isDetected ) {
|
|
3294
|
-
const startSeconds = currentSeconds;
|
|
3295
|
-
const endSeconds = Math.min( currentSeconds + secondsPerMetric, totalDuration );
|
|
3296
|
-
|
|
3297
|
-
startTime = formatDuration( startSeconds );
|
|
3298
|
-
endTime = formatDuration( endSeconds );
|
|
3299
|
-
|
|
3300
|
-
currentSeconds = endSeconds;
|
|
3301
|
-
}
|
|
3302
|
-
|
|
3303
|
-
return {
|
|
3304
|
-
metricId: metric.metricId,
|
|
3305
|
-
metricName: formatMetricName( metric.metricName ),
|
|
3306
|
-
metricType: metric.metricType,
|
|
3307
|
-
status: metric.contextsEvaluated?.[0]?.isDetected === false ? 'fail':'pass',
|
|
3308
|
-
contextsEvaluated: metric.contextsEvaluated?.[0]?.isDetected,
|
|
3309
|
-
startTime: startTime,
|
|
3310
|
-
endTime: endTime,
|
|
3311
|
-
evidence_quote: metric.contextsEvaluated?.[0]?.isDetected === false ? '' : metric.evidence_quote,
|
|
3312
|
-
};
|
|
3313
|
-
} ) || [];
|
|
3314
|
-
|
|
3315
|
-
// Fetch signed URL for audio
|
|
3316
|
-
const audioS3Parts = parseS3Uri( source?.s3_audio_path );
|
|
3317
|
-
const audioSignedUrl = audioS3Parts ? await signedUrl( { ...audioS3Parts, key: 'aws' } ) : null;
|
|
3318
|
-
// Fetch and format transcript
|
|
3319
|
-
const formattedTranscript = await getFormattedTranscript( source?.s3_transcript_path );
|
|
3320
|
-
|
|
3321
|
-
// Get executive summary
|
|
3322
|
-
// const executiveSummary = source?.cohort_analysis_results?.[0]?.executive_summary || {};
|
|
3323
|
-
|
|
3324
|
-
const response = {
|
|
3325
|
-
doc_id: source?.doc_id,
|
|
3326
|
-
// storeName: source?.storeName,
|
|
3327
|
-
// storeID: source?.storeID,
|
|
3328
|
-
// staffID: source?.staffID,
|
|
3329
|
-
// state: source?.state,
|
|
3330
|
-
// moduleType: source?.moduleType,
|
|
3331
|
-
// fileDate: source?.filedate,
|
|
3332
|
-
// processTimestamp: source?.processTimestamp,
|
|
3333
|
-
duration: formatDuration( source?.duration ),
|
|
3334
|
-
audio_url: audioSignedUrl,
|
|
3335
|
-
// s3_transcript_path: source?.s3_transcript_path,
|
|
3336
|
-
transcript_summary: formattedTranscript,
|
|
3337
|
-
// overall_score: executiveSummary?.overall_score,
|
|
3338
|
-
// conversation_summary: executiveSummary?.conversation_summary,
|
|
3339
|
-
// key_pointers: executiveSummary?.key_pointers,
|
|
3340
|
-
metrics_results: formattedMetrics,
|
|
3341
|
-
};
|
|
3342
|
-
|
|
3343
|
-
return res.sendSuccess( { result: response } );
|
|
3344
|
-
} catch ( error ) {
|
|
3345
|
-
const err = error.message || 'Internal Server Error';
|
|
3346
|
-
logger.error( { message: error, data: req.params, function: 'eyetestAudit-controller-getConversationDetails' } );
|
|
3347
|
-
return res.sendError( err, 500 );
|
|
3348
|
-
}
|
|
3349
|
-
}
|
package/src/dtos/audit.dtos.js
CHANGED
|
@@ -418,14 +418,3 @@ export const getEmailConfigSchema = joi.object( {
|
|
|
418
418
|
export const getEmailConfigValid = {
|
|
419
419
|
query: getEmailConfigSchema,
|
|
420
420
|
};
|
|
421
|
-
|
|
422
|
-
export const getConversationDetailsSchema = joi.object( {
|
|
423
|
-
id: joi.string().required().messages( {
|
|
424
|
-
'any.required': 'ID is required',
|
|
425
|
-
'string.empty': 'ID cannot be empty',
|
|
426
|
-
} ),
|
|
427
|
-
} );
|
|
428
|
-
|
|
429
|
-
export const getConversationDetailsValid = {
|
|
430
|
-
params: getConversationDetailsSchema,
|
|
431
|
-
};
|
|
@@ -45,10 +45,7 @@ auditRouter.get( '/get-user-audit-count-mtd', isAllowedSessionHandler, getUserAu
|
|
|
45
45
|
|
|
46
46
|
// audit log
|
|
47
47
|
auditRouter.get( '/audit-view-logs', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ] } ), validate( auditViewLogValid ), auditViewLogs );
|
|
48
|
-
|
|
49
|
-
|
|
50
48
|
export default auditRouter;
|
|
51
49
|
|
|
52
50
|
//
|
|
53
51
|
auditRouter.post( '/re-push-audit-stores', validate( rePushAuditStoresValid ), rePushAuditStores );
|
|
54
|
-
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { isAllowedSessionHandler, validate } from 'tango-app-api-middleware';
|
|
3
3
|
import { accessVerification } from 'tango-app-api-middleware';
|
|
4
|
-
import { cancelValid, clusterListValid, emailAlertLogValid,
|
|
5
|
-
import { addUserConfig, cancel, clusterList, emailAlertLog,
|
|
4
|
+
import { cancelValid, clusterListValid, emailAlertLogValid, getEmailConfigValid, getFileHistoryValid, getFileValid, getListValid, getUserConfigValid, rmListValid, saveValid, summaryCardValid, summaryListValid, updateEmailConfigValid, updateUserConfigValid, userAuditedDataValid, viewFileValid } from '../dtos/eyeTestAudit.dtos.js';
|
|
5
|
+
import { addUserConfig, cancel, clusterList, emailAlertLog, getEmailConfig, getFile, getFileHistory, getList, getUserConfig, rmList, save, summaryCard, summaryList, updateEmailConfig, updateEmailConfig1, updateUserConfig, userAuditedData, viewFile } from '../controllers/eyeTestAudit.controllers.js';
|
|
6
6
|
import { isSuperAdmin, roleValidation, sendAlert } from '../validation/eyeTest.validation.js';
|
|
7
7
|
|
|
8
8
|
export const eyeTestAuditRouter = Router();
|
|
@@ -15,10 +15,6 @@ eyeTestAuditRouter.post( '/cancel', isAllowedSessionHandler, accessVerification(
|
|
|
15
15
|
eyeTestAuditRouter.get( '/get-file-history/:id/:type', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ] } ), validate( getFileHistoryValid ), getFileHistory );
|
|
16
16
|
eyeTestAuditRouter.get( '/user-audited-data/:id', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ] } ), validate( userAuditedDataValid ), userAuditedData );
|
|
17
17
|
|
|
18
|
-
// enhancement of march of 2026
|
|
19
|
-
|
|
20
|
-
eyeTestAuditRouter.get( '/get-conversation-details/:id', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ] } ), validate( getConversationDetailsValid ), getConversationDetails );
|
|
21
|
-
|
|
22
18
|
// enhancement of aug of 2025
|
|
23
19
|
|
|
24
20
|
eyeTestAuditRouter.post( '/summary-list', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ] } ), validate( summaryListValid ), roleValidation, summaryList );
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import { logger } from 'tango-app-api-middleware';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Short-lived dedup / lock record for the reTrigger API.
|
|
6
|
+
*
|
|
7
|
+
* The UNIQUE compound index below is the atomic gate that makes reTrigger
|
|
8
|
+
* idempotent and thread-safe: under a burst of concurrent identical requests
|
|
9
|
+
* only ONE insert succeeds, every other insert fails with duplicate-key error
|
|
10
|
+
* E11000. MongoDB enforces this at the storage engine level, so it works across
|
|
11
|
+
* processes / pods - unlike an in-memory map or a non-atomic OpenSearch read.
|
|
12
|
+
*/
|
|
13
|
+
const reTriggerLockSchema = new mongoose.Schema(
|
|
14
|
+
{
|
|
15
|
+
storeId: { type: String, required: true },
|
|
16
|
+
fileDate: { type: String, required: true },
|
|
17
|
+
moduleType: { type: String, required: true },
|
|
18
|
+
// zoneName is part of the processing identity (different zones of the same
|
|
19
|
+
// store/date are distinct runs). Including it makes the gate MORE precise
|
|
20
|
+
// while still fully deduping an identical payload.
|
|
21
|
+
zoneName: { type: String, default: '' },
|
|
22
|
+
status: {
|
|
23
|
+
type: String,
|
|
24
|
+
enum: [ 'processing', 'completed' ],
|
|
25
|
+
default: 'processing',
|
|
26
|
+
},
|
|
27
|
+
triggeredBy: { type: mongoose.Schema.Types.ObjectId },
|
|
28
|
+
triggerType: { type: String },
|
|
29
|
+
},
|
|
30
|
+
{ timestamps: true, strict: true, versionKey: false },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Hard, atomic dedup gate.
|
|
34
|
+
reTriggerLockSchema.index(
|
|
35
|
+
{ storeId: 1, fileDate: 1, moduleType: 1, zoneName: 1 },
|
|
36
|
+
{ unique: true },
|
|
37
|
+
);
|
|
38
|
+
// Keep the collection bounded. A 7-day window is far longer than any duplicate
|
|
39
|
+
// burst, yet lets the same store/date be re-triggered in a later cycle.
|
|
40
|
+
reTriggerLockSchema.index( { updatedAt: 1 }, { expireAfterSeconds: 7 * 24 * 60 * 60 } );
|
|
41
|
+
|
|
42
|
+
const reTriggerLockModel = mongoose.model(
|
|
43
|
+
'reTriggerLock',
|
|
44
|
+
reTriggerLockSchema,
|
|
45
|
+
'reTriggerLock',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Ensure the unique index physically exists even if mongoose autoIndex is
|
|
49
|
+
// disabled in production - without it the atomic guarantee would be lost.
|
|
50
|
+
// We keep the promise: acquire() awaits it so a cold-start burst can never slip
|
|
51
|
+
// through before the index has finished building.
|
|
52
|
+
const indexReady = reTriggerLockModel.createIndexes().catch( ( error ) => {
|
|
53
|
+
logger.error( { error, function: 'reTriggerLock.createIndexes' } );
|
|
54
|
+
} );
|
|
55
|
+
|
|
56
|
+
// A 'processing' lock older than this is treated as abandoned (crashed / timed
|
|
57
|
+
// out request) and may be reclaimed so a legitimate retry is never blocked.
|
|
58
|
+
const STALE_LOCK_MS = 5 * 60 * 1000;
|
|
59
|
+
|
|
60
|
+
function buildKey( { storeId, fileDate, moduleType, zoneName } ) {
|
|
61
|
+
return { storeId, fileDate, moduleType, zoneName: zoneName || '' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Atomically try to acquire the reTrigger lock for a combo.
|
|
65
|
+
// Returns { acquired: true, lock } for exactly one caller in a concurrent burst.
|
|
66
|
+
// Returns { acquired: false, lock } for everyone else - `lock` carries the
|
|
67
|
+
// existing status so the caller can report it instead of re-processing.
|
|
68
|
+
export async function acquireReTriggerLock( payload ) {
|
|
69
|
+
// Guarantee the unique index is built before we rely on it as the dedup gate.
|
|
70
|
+
await indexReady;
|
|
71
|
+
const key = buildKey( payload );
|
|
72
|
+
try {
|
|
73
|
+
const lock = await reTriggerLockModel.create( {
|
|
74
|
+
...key,
|
|
75
|
+
status: 'processing',
|
|
76
|
+
triggeredBy: payload.triggeredBy,
|
|
77
|
+
triggerType: payload.triggerType,
|
|
78
|
+
} );
|
|
79
|
+
return { acquired: true, lock };
|
|
80
|
+
} catch ( error ) {
|
|
81
|
+
// Anything other than a duplicate-key collision is a real error.
|
|
82
|
+
if ( error?.code !== 11000 ) {
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A lock already exists for this combo.
|
|
87
|
+
let existing = await reTriggerLockModel.findOne( key );
|
|
88
|
+
|
|
89
|
+
// Reclaim a stale 'processing' lock left behind by a crashed attempt.
|
|
90
|
+
if (
|
|
91
|
+
existing &&
|
|
92
|
+
existing.status === 'processing' &&
|
|
93
|
+
Date.now() - new Date( existing.updatedAt ).getTime() > STALE_LOCK_MS
|
|
94
|
+
) {
|
|
95
|
+
// Optimistic guard on updatedAt: only one concurrent reclaimer wins.
|
|
96
|
+
const reclaimed = await reTriggerLockModel.findOneAndUpdate(
|
|
97
|
+
{ _id: existing._id, status: 'processing', updatedAt: existing.updatedAt },
|
|
98
|
+
{
|
|
99
|
+
$set: {
|
|
100
|
+
status: 'processing',
|
|
101
|
+
triggeredBy: payload.triggeredBy,
|
|
102
|
+
triggerType: payload.triggerType,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{ new: true },
|
|
106
|
+
);
|
|
107
|
+
if ( reclaimed ) {
|
|
108
|
+
return { acquired: true, lock: reclaimed };
|
|
109
|
+
}
|
|
110
|
+
// Lost the race - re-read whatever the winner left.
|
|
111
|
+
existing = await reTriggerLockModel.findOne( key );
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { acquired: false, lock: existing };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Mark the lock completed once processing + SQS send succeeded.
|
|
119
|
+
export function completeReTriggerLock( payload ) {
|
|
120
|
+
return reTriggerLockModel.updateOne(
|
|
121
|
+
buildKey( payload ),
|
|
122
|
+
{ $set: { status: 'completed' } },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Release (delete) the lock on failure so a legitimate retry can re-acquire it.
|
|
127
|
+
export function releaseReTriggerLock( payload ) {
|
|
128
|
+
return reTriggerLockModel.deleteOne( buildKey( payload ) );
|
|
129
|
+
}
|