tango-app-api-audit 3.6.54 → 3.6.56

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-audit",
3
- "version": "3.6.54",
3
+ "version": "3.6.56",
4
4
  "description": "audit & audit metrics apis",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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 *** --> */
@@ -933,8 +937,7 @@ export async function getFilterData( msg, user ) {
933
937
  ( item ) =>
934
938
  item.temp_ids < 20000 &&
935
939
  item.temp_ids > 0 &&
936
- item.person_status === '' &&
937
- item.query_status !== '',
940
+ item.person_status === '',
938
941
  );
939
942
  const finalResult = await filterData.map( ( i ) => {
940
943
  return { ...i, img: i.index.concat( '_', i.temp_ids ) };
@@ -1990,77 +1993,112 @@ export async function save( req, res ) {
1990
1993
  inputData.auditType == 'Audit' &&
1991
1994
  inputData.moduleType == 'traffic'
1992
1995
  ) {
1993
- logger.info(
1994
- `Hit in ReAudit pushing queue Store Id=${inputData.storeId},File Date=${inputData.fileDate}, /n \nQueue Name = ${clientData[0].auditConfigs.queueName}`,
1995
- 'ReAudit',
1996
- );
1997
- const queueName = inputData.moduleType == 'zone' ? clientData[0].auditConfigs.zoneQueueName : clientData[0].auditConfigs.trafficQueueName;
1998
- if ( !queueName ) {
1999
- logger.error( { error: 'queueName is undefined', queueName: queueName, moduleType: inputData.moduleType, function: 'save' } );
2000
- }
2001
- const sqsProduceQueue = {
2002
- QueueUrl: `${sqs.url}${queueName}`,
2003
- MessageBody: JSON.stringify( {
2004
- store_id: inputData.storeId,
2005
- zone_id: inputData.zoneName,
2006
- audit_type: 'ReAudit',
2007
- curr_date: inputData.fileDate,
2008
- total_count: inputData.customerCount,
2009
- before_count: storeAuditData.beforeCount,
2010
- } ),
2011
- };
2012
- const sqsQueue = await sendMessageToQueue(
2013
- sqsProduceQueue.QueueUrl,
2014
- sqsProduceQueue.MessageBody,
2015
- );
2016
-
2017
- if ( sqsQueue.statusCode ) {
2018
- logger.error( {
2019
- error: `${sqsQueue}`,
2020
- type: 'UPLOAD_ERROR',
2021
- } );
2022
- return res.sendError( mappingUpload, 500 );
2023
- }
2024
-
2025
- await updateOneStoreAudit(
2026
- {
2027
- storeId: inputData.storeId,
2028
- fileDate: inputData.fileDate,
2029
- moduleType: inputData.moduleType,
2030
- zoneName: inputData.zoneName,
2031
- },
2032
- {
2033
- status: 'not_assign',
2034
- afterCount: inputData.customerCount,
2035
- auditType: 'ReAudit',
2036
- timeSpent: inputData.timeSpent,
1996
+ // Skip re-pushing the reAudit queue if a reAuditPush log with the
1997
+ // same audit identifiers already exists (prevents duplicate triggers).
1998
+ const reAuditPushQuery = {
1999
+ size: 1,
2000
+ query: {
2001
+ bool: {
2002
+ must: [
2003
+ { term: { 'logSubType.keyword': 'reAuditPush' } },
2004
+ { term: { 'logData.fileDate.keyword': req.userAudit?.fileDate } },
2005
+ { term: { 'logData.auditType.keyword': req.userAudit?.auditType } },
2006
+ { term: { 'logData.storeId.keyword': req.userAudit?.storeId } },
2007
+ { term: { 'logData.moduleType.keyword': req.userAudit?.moduleType } },
2008
+ { term: { 'logData.zoneName.keyword': req.userAudit?.zoneName } },
2009
+ { term: { 'logData.startTime': req.userAudit?.startTime } },
2010
+ { term: { 'logData.auditId.keyword': inputData.auditId } },
2011
+ ],
2037
2012
  },
2038
- );
2013
+ },
2014
+ };
2015
+ const existingReAuditPush = await getOpenSearchData( openSearch.auditLog, reAuditPushQuery );
2016
+ const reAuditPushExists = existingReAuditPush?.body?.hits?.total?.value || 0;
2039
2017
 
2040
- const logData = {
2041
- userId: req.user._id,
2042
- userName: req.user.userName,
2043
- logType: 'audit',
2044
- logSubType: 'reAuditPush',
2045
- logData: {
2018
+ if ( reAuditPushExists ) {
2019
+ logger.info( {
2020
+ message: 'reAuditPush already exists, skipping queue push',
2021
+ storeId: req.userAudit?.storeId,
2046
2022
  fileDate: req.userAudit?.fileDate,
2047
2023
  auditType: req.userAudit?.auditType,
2048
- storeId: req.userAudit?.storeId,
2049
2024
  moduleType: req.userAudit?.moduleType,
2050
2025
  zoneName: req.userAudit?.zoneName,
2051
- queueName: req.userAudit?.queueName,
2052
- clientId: req.userAudit?.clientId,
2053
- beforeCount: req.userAudit?.beforeCount,
2054
- afterCount: inputData.customerCount,
2055
2026
  startTime: req.userAudit?.startTime,
2056
- endTime: Date.now(),
2057
- timeSpent: inputData.timeSpent,
2058
- auditId: new mongoose.Types.ObjectId( inputData.auditId ),
2059
- },
2060
- createdAt: Date.now(),
2061
- };
2027
+ function: 'save',
2028
+ } );
2029
+ } else {
2030
+ logger.info(
2031
+ `Hit in ReAudit pushing queue Store Id=${inputData.storeId},File Date=${inputData.fileDate}, /n \nQueue Name = ${clientData[0].auditConfigs.queueName}`,
2032
+ 'ReAudit',
2033
+ );
2034
+ const queueName = inputData.moduleType == 'zone' ? clientData[0].auditConfigs.zoneQueueName : clientData[0].auditConfigs.trafficQueueName;
2035
+ if ( !queueName ) {
2036
+ logger.error( { error: 'queueName is undefined', queueName: queueName, moduleType: inputData.moduleType, function: 'save' } );
2037
+ }
2038
+ const sqsProduceQueue = {
2039
+ QueueUrl: `${sqs.url}${queueName}`,
2040
+ MessageBody: JSON.stringify( {
2041
+ store_id: inputData.storeId,
2042
+ zone_id: inputData.zoneName,
2043
+ audit_type: 'ReAudit',
2044
+ curr_date: inputData.fileDate,
2045
+ total_count: inputData.customerCount,
2046
+ before_count: storeAuditData.beforeCount,
2047
+ } ),
2048
+ };
2049
+ const sqsQueue = await sendMessageToQueue(
2050
+ sqsProduceQueue.QueueUrl,
2051
+ sqsProduceQueue.MessageBody,
2052
+ );
2062
2053
 
2063
- await insertOpenSearchData( openSearch.auditLog, logData );
2054
+ if ( sqsQueue.statusCode ) {
2055
+ logger.error( {
2056
+ error: `${sqsQueue}`,
2057
+ type: 'UPLOAD_ERROR',
2058
+ } );
2059
+ return res.sendError( mappingUpload, 500 );
2060
+ }
2061
+
2062
+ await updateOneStoreAudit(
2063
+ {
2064
+ storeId: inputData.storeId,
2065
+ fileDate: inputData.fileDate,
2066
+ moduleType: inputData.moduleType,
2067
+ zoneName: inputData.zoneName,
2068
+ },
2069
+ {
2070
+ status: 'not_assign',
2071
+ afterCount: inputData.customerCount,
2072
+ auditType: 'ReAudit',
2073
+ timeSpent: inputData.timeSpent,
2074
+ },
2075
+ );
2076
+
2077
+ const logData = {
2078
+ userId: req.user._id,
2079
+ userName: req.user.userName,
2080
+ logType: 'audit',
2081
+ logSubType: 'reAuditPush',
2082
+ logData: {
2083
+ fileDate: req.userAudit?.fileDate,
2084
+ auditType: req.userAudit?.auditType,
2085
+ storeId: req.userAudit?.storeId,
2086
+ moduleType: req.userAudit?.moduleType,
2087
+ zoneName: req.userAudit?.zoneName,
2088
+ queueName: req.userAudit?.queueName,
2089
+ clientId: req.userAudit?.clientId,
2090
+ beforeCount: req.userAudit?.beforeCount,
2091
+ afterCount: inputData.customerCount,
2092
+ startTime: req.userAudit?.startTime,
2093
+ endTime: Date.now(),
2094
+ timeSpent: inputData.timeSpent,
2095
+ auditId: new mongoose.Types.ObjectId( inputData.auditId ),
2096
+ },
2097
+ createdAt: Date.now(),
2098
+ };
2099
+
2100
+ await insertOpenSearchData( openSearch.auditLog, logData );
2101
+ }
2064
2102
  } else {
2065
2103
  /* openSearch logs start*/
2066
2104
  let id = `${inputData.moduleType}_${inputData.auditId}_${inputData.storeId}_${inputData.moduleType}_${inputData.auditType}_save_Info_${req?.user?.email}_${Date.now()}`;
@@ -3632,9 +3670,37 @@ export async function overAllAuditSummary( req, res ) {
3632
3670
  }
3633
3671
 
3634
3672
  export async function reTrigger( req, res ) {
3673
+ const inputData = req.body;
3674
+ const lockKey = {
3675
+ storeId: inputData.storeId,
3676
+ fileDate: inputData.fileDate,
3677
+ moduleType: inputData.moduleType,
3678
+ zoneName: inputData.zoneName,
3679
+ };
3680
+ let lockAcquired = false;
3635
3681
  try {
3682
+ // --- Idempotency gate ------------------------------------------------
3683
+ // Atomically claim this (storeId, fileDate, moduleType, zoneName) combo.
3684
+ // Under a burst of identical requests exactly one caller wins; the rest get
3685
+ // the existing status back and skip re-processing - so no duplicate SQS
3686
+ // messages / assignments are produced.
3687
+ const { acquired, lock } = await acquireReTriggerLock( {
3688
+ ...lockKey,
3689
+ triggeredBy: req.user._id,
3690
+ triggerType: inputData.triggerType,
3691
+ } );
3692
+ if ( !acquired ) {
3693
+ return res.sendSuccess( {
3694
+ result: lock?.status === 'completed' ?
3695
+ 'This file has already been Re-Triggered.' :
3696
+ 'A Re-Trigger for this file is already in progress.',
3697
+ status: lock?.status || 'processing',
3698
+ duplicate: true,
3699
+ } );
3700
+ }
3701
+ lockAcquired = true;
3702
+
3636
3703
  const openSearch = JSON.parse( process.env.OPENSEARCH );
3637
- const inputData = req.body;
3638
3704
  const sqs = JSON.parse( process.env.SQS );
3639
3705
  const query = {
3640
3706
  storeId: inputData.storeId,
@@ -3649,6 +3715,13 @@ export async function reTrigger( req, res ) {
3649
3715
  }
3650
3716
  switch ( inputData.triggerType ) {
3651
3717
  case 'queue':
3718
+ // Only push to the queue when the store has not already been queued.
3719
+ // A 'not_assign' status means a prior reTrigger already pushed this
3720
+ // store - pushing again would create a duplicate queue message.
3721
+ if ( req.audit.status === 'not_assign' ) {
3722
+ await releaseReTriggerLock( lockKey );
3723
+ return res.sendError( 'This store has already been pushed to the queue', 400 );
3724
+ }
3652
3725
  const msg = {
3653
3726
  store_id: inputData.storeId,
3654
3727
  zone_id: inputData.zoneName,
@@ -3713,6 +3786,10 @@ export async function reTrigger( req, res ) {
3713
3786
  }
3714
3787
  break;
3715
3788
  case 'user':
3789
+ if ( req.audit.status === 'not_assign' ) {
3790
+ await releaseReTriggerLock( lockKey );
3791
+ return res.sendError( 'This store has already assigned to the user', 400 );
3792
+ }
3716
3793
  const assignUser = await findOneUser( { _id: new mongoose.Types.ObjectId( inputData.userId ) } );
3717
3794
  const assignUserRecord = {
3718
3795
  storeId: inputData.storeId,
@@ -3782,8 +3859,15 @@ export async function reTrigger( req, res ) {
3782
3859
 
3783
3860
 
3784
3861
  await updateOpenSearchData( openSearch.trackerInput, `${inputData.storeId}_${inputData.fileDate}_23:00`, { doc: updateQuery } );
3862
+ // Processing + SQS send succeeded - mark the lock completed so any later
3863
+ // identical request returns the existing status instead of re-triggering.
3864
+ await completeReTriggerLock( lockKey );
3785
3865
  return res.sendSuccess( { result: 'The File has been Re-Triggered Succesfully' } );
3786
3866
  } catch ( error ) {
3867
+ // Release the lock on failure so a legitimate retry can re-acquire it.
3868
+ if ( lockAcquired ) {
3869
+ await releaseReTriggerLock( lockKey ).catch( () => {} );
3870
+ }
3787
3871
  const err = error.message;
3788
3872
  logger.error( { error: error, message: req.body, function: 'reTrigger' } );
3789
3873
  return res.sendError( err, 500 );
@@ -5815,5 +5899,3 @@ export async function rePushAuditStores( req, res ) {
5815
5899
  return res.sendError( err, 500 );
5816
5900
  }
5817
5901
  }
5818
-
5819
-
@@ -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
- }
@@ -321,5 +321,3 @@ export const rePushAuditStoresSchema = joi.object( {
321
321
  export const rePushAuditStoresValid = {
322
322
  body: rePushAuditStoresSchema,
323
323
  };
324
-
325
-
@@ -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, getConversationDetailsValid, getEmailConfigValid, getFileHistoryValid, getFileValid, getListValid, getUserConfigValid, rmListValid, saveValid, summaryCardValid, summaryListValid, updateEmailConfigValid, updateUserConfigValid, userAuditedDataValid, viewFileValid } from '../dtos/eyeTestAudit.dtos.js';
5
- import { addUserConfig, cancel, clusterList, emailAlertLog, getConversationDetails, getEmailConfig, getFile, getFileHistory, getList, getUserConfig, rmList, save, summaryCard, summaryList, updateEmailConfig, updateEmailConfig1, updateUserConfig, userAuditedData, viewFile } from '../controllers/eyeTestAudit.controllers.js';
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
+ }
@@ -45,11 +45,14 @@ export async function validateUserAudit( req, res, next ) {
45
45
  const userAuditDetails = await findOneUserAudit( { _id: inputData.auditId } );
46
46
  if ( !userAuditDetails ) {
47
47
  return res.sendError( 'No Data Available' );
48
+ } else if ( userAuditDetails && userAuditDetails.auditStatus == 'completed' ) {
49
+ return res.sendError( 'The Audit file has been already submitted', 403 );
48
50
  } else if ( userAuditDetails && userAuditDetails.auditStatus == 'skipped' ) {
49
51
  return res.sendError( 'The Audit file Assigned to some one else', 203 );
52
+ } else {
53
+ req.userAudit = userAuditDetails;
54
+ next();
50
55
  }
51
- req.userAudit = userAuditDetails;
52
- next();
53
56
  } catch ( error ) {
54
57
  const err = error.message || 'Internal Server Error';
55
58
  logger.error( { error: error, message: req.query, function: 'validateUserAudit' } );
@@ -150,11 +153,13 @@ export async function mapCustomer( sourceMappingData, customerData ) {
150
153
  try {
151
154
  const filterCustomerData = await filteredCustomerMap( customerData );
152
155
  logger.info( { filterCustomerData: filterCustomerData } );
153
- // Build the lookup once, then retag every row in a single pass. This is
154
- // O(rows + mappedIds) and scales for concurrent submissions, versus the old
155
- // O(customers x mappedIds x rows) scan that also re-read its own mutations.
156
- const customerMap = buildCustomerMap( filterCustomerData );
157
- return mapFunction( sourceMappingData, customerMap );
156
+ const chunkedMappingData = await chunkArray( sourceMappingData, 10 );
157
+
158
+ const promises = chunkedMappingData.map( async ( chunk ) => {
159
+ return mapFunction( chunk, filterCustomerData );
160
+ } );
161
+ const mappedArrays = await Promise.all( promises );
162
+ return mappedArrays.flat();
158
163
  } catch ( error ) {
159
164
  logger.error( { error: error, type: 'mapCustomer' } );
160
165
  return error;
@@ -165,39 +170,22 @@ const filteredCustomerMap = async ( data ) => {
165
170
  return data.filter( ( map ) => map.count >= 1 );
166
171
  };
167
172
 
168
- // Lookup of original cluster id (child img_name) -> the customer it was tagged
169
- // into. Last write wins if the same id is referenced by more than one group, so
170
- // the result is deterministic regardless of customer order.
171
- const buildCustomerMap = ( filterData ) => {
172
- const customerMap = new Map();
173
+ const mapFunction = async ( chunkData, filterData ) => {
173
174
  for ( const data of filterData ) {
174
- if ( !data?.mappedid ) continue;
175
- for ( const mappedId of data.mappedid ) {
176
- customerMap.set( mappedId.img_name, {
177
- parentId: data.img_name,
178
- demographic: data.demographic || '',
179
- // The chosen representative (img_name === parent) keeps its query flag so
180
- // it resurfaces in reaudit; every other merged image is cleared so it does
181
- // not (query_status is the reaudit gate in getFilterData / getAuditFilterData).
182
- isChild: mappedId.img_name !== data.img_name,
183
- } );
175
+ const mappedIds = data.mappedid;
176
+ if ( data?.mappedid ) {
177
+ for ( const mappedId of mappedIds ) {
178
+ chunkData.find( ( item ) => {
179
+ if ( mappedId.img_name === item.temp_ids ) {
180
+ item.temp_ids = data.img_name;
181
+ ( mappedId.img_name !== data.img_name ) ? item.query_status = '' : null;
182
+ item.demographic = data.demographic || '';
183
+ }
184
+ } );
185
+ }
184
186
  }
185
187
  }
186
- return customerMap;
187
- };
188
-
189
- // Single pass over the master-json rows. Each row's ORIGINAL temp_ids is read
190
- // exactly once and reassigned to its customer's parent id, so every row sharing
191
- // a temp_ids is retagged and the retag never feeds back into later lookups.
192
- const mapFunction = ( personData, customerMap ) => {
193
- for ( const item of personData ) {
194
- const mapped = customerMap.get( item.temp_ids );
195
- if ( !mapped ) continue;
196
- item.temp_ids = mapped.parentId;
197
- item.query_status = mapped.isChild ? '' : item.query_status;
198
- item.demographic = mapped.demographic;
199
- }
200
- return personData;
188
+ return chunkData;
201
189
  };
202
190
 
203
191
  export async function isAuditInputFolderExist( req, res, next ) {
@@ -359,8 +347,7 @@ export async function getAuditFilterData( data ) {
359
347
  ( item ) =>
360
348
  item.temp_ids < 20000 &&
361
349
  item.temp_ids > 0 &&
362
- item.person_status === '' &&
363
- item.query_status !== '',
350
+ item.person_status === '',
364
351
  );
365
352
  const finalResult = await filterData.map( ( i ) => {
366
353
  return { ...i, img: i.index.concat( '_', i.temp_ids ) };