tango-app-api-audit 3.6.57 → 3.6.58

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.57",
3
+ "version": "3.6.58",
4
4
  "description": "audit & audit metrics apis",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "nodemailer": "^7.0.10",
29
29
  "nodemon": "^3.1.3",
30
30
  "swagger-ui-express": "^5.0.1",
31
- "tango-api-schema": "^2.5.27",
31
+ "tango-api-schema": "^2.6.41",
32
32
  "tango-app-api-middleware": "^3.6.18",
33
33
  "winston": "^3.13.0",
34
34
  "winston-daily-rotate-file": "^5.0.0"
@@ -73,6 +73,7 @@ import { aggregateTraxAuditData, findOneTraxAuditData } from '../service/traxAud
73
73
  import { bulkWriteAuditUserWallet, createAuditUserWallet, findOneAuditUserWallet } from '../service/auditUserWallet.service.js';
74
74
  import { updateOneAuditUsers } from '../service/auditUsers.service.js';
75
75
  import { bulkUpdate, clearScroll, upsertOpenSearchData, scrollResponse, searchOpenSearchData, updateOpenSearchData } from 'tango-app-api-middleware/src/utils/openSearch.js';
76
+
76
77
  // import { aggregateAuditUsers } from '../service/auditUsers.service.js';
77
78
 
78
79
  /* < -- *** Configuration *** --> */
@@ -937,7 +938,8 @@ export async function getFilterData( msg, user ) {
937
938
  ( item ) =>
938
939
  item.temp_ids < 20000 &&
939
940
  item.temp_ids > 0 &&
940
- item.person_status === '',
941
+ item.person_status === '' &&
942
+ item.query_status !== '',
941
943
  );
942
944
  const finalResult = await filterData.map( ( i ) => {
943
945
  return { ...i, img: i.index.concat( '_', i.temp_ids ) };
@@ -1084,9 +1086,14 @@ export async function workSpace( req, res ) {
1084
1086
  const dateRange = await getDate( new Date(), new Date() );
1085
1087
  const filter = [
1086
1088
  { status: { $in: [ 'active', 'hold' ] } },
1087
- { 'auditConfigs.audit': { $eq: true } },
1088
1089
  { clientId: { $in: inputData.clientId } },
1089
1090
  ];
1091
+ if ( [ 'traffic', 'zone' ].includes( inputData?.moduleType ) ) {
1092
+ filter.push( { 'auditConfigs.audit': { $eq: true } } );
1093
+ }
1094
+ if ( inputData?.moduleType === 'track' ) {
1095
+ filter.push( { 'auditConfigs.trackAudit': { $eq: true } } );
1096
+ }
1090
1097
 
1091
1098
  const temp = [];
1092
1099
  const clientQuery = [
@@ -2560,26 +2567,31 @@ export async function clientMetrics( req, res ) {
2560
2567
  { moduleType: { $eq: inputData.moduleType } },
2561
2568
  ];
2562
2569
 
2563
- let storeAuditFilter = [
2564
- { $eq: [ '$clientId', '$$clientId' ] },
2565
- { $eq: [ '$fileDate', '$$fileDate' ] },
2566
- {
2567
- $eq: [ '$moduleType', inputData.moduleType ],
2568
- },
2569
-
2570
- ];
2571
-
2572
2570
  if ( inputData?.filterByClient?.length > 0 ) {
2573
2571
  filter.push(
2574
2572
  { clientId: { $in: inputData.filterByClient } },
2575
2573
  );
2576
2574
  }
2577
- const query = [
2578
- {
2579
- $match: {
2580
- $and: filter,
2581
- },
2582
- },
2575
+
2576
+ // Only show clients enabled for audit ("access" clients): traffic/zone use
2577
+ // auditConfigs.audit, track uses auditConfigs.trackAudit.
2578
+ const clientAccessFilter = [];
2579
+ if ( [ 'traffic', 'zone' ].includes( inputData?.moduleType ) ) {
2580
+ clientAccessFilter.push( { 'auditConfigs.audit': { $eq: true } } );
2581
+ }
2582
+ if ( inputData?.moduleType === 'track' ) {
2583
+ clientAccessFilter.push( { 'auditConfigs.trackAudit': { $eq: true } } );
2584
+ }
2585
+ if ( clientAccessFilter.length > 0 ) {
2586
+ const accessClients = await aggregateClient( [
2587
+ { $match: { $and: clientAccessFilter } },
2588
+ { $project: { _id: 0, clientId: 1 } },
2589
+ ] );
2590
+ filter.push( { clientId: { $in: accessClients.map( ( c ) => c.clientId ) } } );
2591
+ }
2592
+ // --- Base metrics from auditStoreData (one indexed aggregation) --------
2593
+ const baseQuery = [
2594
+ { $match: { $and: filter } },
2583
2595
  {
2584
2596
  $group: {
2585
2597
  _id: { clientId: '$clientId', fileDate: '$fileDate', moduleType: '$moduleType' },
@@ -2592,181 +2604,125 @@ export async function clientMetrics( req, res ) {
2592
2604
  totalFilesCount: { $sum: 1 },
2593
2605
  },
2594
2606
  },
2607
+ ];
2608
+
2609
+ // --- Store status counts from storeAudit (one indexed aggregation) -----
2610
+ // Replaces the old correlated per-group $lookup: instead of running a
2611
+ // sub-query for every client/date group, aggregate storeAudit once over the
2612
+ // same filter and merge by clientId+fileDate in JS below. storeAudit has the
2613
+ // same clientId / moduleType / fileDateISO fields, so `filter` applies as-is.
2614
+ const storeAuditQuery = [
2615
+ { $match: { $and: filter } },
2595
2616
  {
2596
- $project: {
2597
- fileDate: 1,
2598
- clientId: 1,
2599
- clientName: 1,
2600
- queueName: 1,
2601
- installedStore: 1,
2602
- totalFilesCount: 1,
2603
- moduleType: 1,
2604
- notAssignedCount: { $ifNull: [ 0, 0 ] },
2605
- clientStatus: { $ifNull: [ '', '' ] },
2617
+ $group: {
2618
+ _id: { clientId: '$clientId', fileDate: '$fileDate' },
2619
+ clientId: { $first: '$clientId' },
2620
+ fileDate: { $first: '$fileDate' },
2621
+ completedStores: { $sum: { $cond: [ { $eq: [ '$status', 'completed' ] }, 1, 0 ] } },
2622
+ notAssignedStores: { $sum: { $cond: [ { $eq: [ '$status', 'not_assign' ] }, 1, 0 ] } },
2623
+ inprogressStores: { $sum: { $cond: [ { $in: [ '$status', [ 'inprogress', 'drafted', 'assigned' ] ] }, 1, 0 ] } },
2624
+ fileCount: { $sum: 1 },
2606
2625
  },
2607
2626
  },
2608
- {
2609
- $lookup: {
2610
- 'from': 'storeAudit',
2611
- 'let': { clientId: '$clientId', fileDate: '$fileDate', totalAuditFiles: '$totalFilesCount' },
2612
- 'pipeline': [
2627
+ ];
2613
2628
 
2614
- {
2615
- $match: {
2616
- $expr: {
2617
- $and: storeAuditFilter,
2618
- },
2619
- },
2620
- },
2621
- {
2622
- $project: {
2623
- completedStores: { $cond: [ { $eq: [ '$status', 'completed' ] }, 1, 0 ] },
2624
- notAssignedStores: { $cond: [ { $eq: [ '$status', 'not_assign' ] }, 1, 0 ] },
2625
- inprogressStores: { $cond: [ { $in: [ '$status', [ 'inprogress', 'drafted', 'assigned' ] ] }, 1, 0 ] },
2626
- },
2627
- },
2628
- {
2629
- $group: {
2630
- _id: { clientId: '$clientId', fileDate: '$fileDate' },
2631
- completedStores: { $sum: '$completedStores' },
2632
- inprogressStores: { $sum: '$inprogressStores' },
2633
- notAssignedStores: { $sum: '$notAssignedStores' },
2634
- fileCount: { $sum: 1 },
2635
- },
2636
- },
2637
- {
2638
- $project: {
2639
- _id: 0,
2640
- completedStores: 1,
2641
- inprogressStores: 1,
2642
- notAssignedStores: 1,
2643
- fileCount: { $ifNull: [ '$fileCount', 0 ] },
2644
- completionPercentage: {
2645
- $ifNull: [
2646
- {
2647
- $round: [ {
2648
- $multiply: [
2649
- 100, {
2650
- $divide: [
2651
- '$completedStores', '$$totalAuditFiles',
2652
- ],
2653
- },
2654
- ],
2655
- }, 1 ],
2629
+ const [ baseRows, storeRows ] = await Promise.all( [
2630
+ aggregateAuditStoreData( baseQuery ),
2631
+ aggregateStoreAudit( storeAuditQuery ),
2632
+ ] );
2656
2633
 
2657
- },
2658
- 0,
2659
- ],
2660
- },
2661
- },
2662
- },
2663
- ], 'as': 'storeAudit',
2664
- },
2665
- },
2666
- {
2667
- $unwind: {
2668
- path: '$storeAudit',
2669
- preserveNullAndEmptyArrays: true,
2670
- },
2671
- },
2672
- {
2673
- $project: {
2674
- _id: 0,
2675
- fileDate: 1,
2676
- clientId: 1,
2677
- clientName: 1,
2678
- totalFilesCount: 1,
2679
- moduleType: 1,
2680
- installedStore: 1,
2681
- // notAssignedStores: '$storeAudit.notAssignedStores',
2682
- inprogressStoresCount: '$storeAudit.inprogressStores',
2683
- // fileCount: '$storeAudit.fileCount',
2684
- notAssignedCount: { $subtract: [ '$totalFilesCount', { $subtract: [ { $ifNull: [ '$storeAudit.fileCount', 0 ] }, { $ifNull: [ '$storeAudit.notAssignedStores', 0 ] } ] } ] },
2685
- clientStatus: { $cond: [ { $eq: [ { $ifNull: [ '$storeAudit.fileCount', 0 ] }, 0 ] }, 'not-taken', { $cond: [ { $gte: [ '$storeAudit.completedStores', '$totalFilesCount' ] }, 'completed', 'pending' ] } ] }, // { $cond: [ { $or: [ { $gt: [ '$notAssignedCount', 0 ] }, { $gt: [ '$storeAudit.notAssignedStores', 0 ] } ] }
2686
- completedStores: '$storeAudit.completedStores',
2687
- completionPercentage: '$storeAudit.completionPercentage',
2634
+ // Index storeAudit counts by clientId + fileDate for an O(1) merge.
2635
+ const storeByKey = new Map();
2636
+ for ( const s of storeRows ) {
2637
+ storeByKey.set( `${s.clientId}__${s.fileDate}`, s );
2638
+ }
2688
2639
 
2689
- },
2690
- },
2691
- ];
2640
+ // Merge and derive the same fields the old $lookup / $project produced.
2641
+ let merged = baseRows.map( ( base ) => {
2642
+ const sa = storeByKey.get( `${base.clientId}__${base.fileDate}` );
2643
+ const fileCount = sa?.fileCount || 0;
2644
+ const notAssignedStores = sa?.notAssignedStores || 0;
2645
+ const completedStores = sa ? sa.completedStores : undefined;
2646
+ const clientStatus = fileCount === 0 ?
2647
+ 'not-taken' :
2648
+ ( ( completedStores || 0 ) >= base.totalFilesCount ? 'completed' : 'pending' );
2649
+ return {
2650
+ fileDate: base.fileDate,
2651
+ clientId: base.clientId,
2652
+ clientName: base.clientName,
2653
+ totalFilesCount: base.totalFilesCount,
2654
+ moduleType: base.moduleType,
2655
+ installedStore: base.installedStore,
2656
+ inprogressStoresCount: sa ? sa.inprogressStores : undefined,
2657
+ notAssignedCount: base.totalFilesCount - ( fileCount - notAssignedStores ),
2658
+ clientStatus: clientStatus,
2659
+ completedStores: completedStores,
2660
+ completionPercentage: sa ?
2661
+ Math.round( ( ( 100 * sa.completedStores ) / base.totalFilesCount ) * 10 ) / 10 :
2662
+ undefined,
2663
+ };
2664
+ } );
2692
2665
 
2666
+ // --- Post-merge filters (depend on the derived clientStatus) -----------
2693
2667
  if ( inputData?.filterByStatus?.length > 0 ) {
2694
- query.push(
2695
- {
2696
- $match: { clientStatus: { $in: inputData.filterByStatus } },
2697
- },
2698
-
2699
- );
2668
+ merged = merged.filter( ( r ) => inputData.filterByStatus.includes( r.clientStatus ) );
2700
2669
  }
2701
2670
 
2702
2671
  if ( inputData.searchValue && inputData.searchValue !== '' ) {
2703
- query.push( {
2704
- $match: {
2705
- $or: [
2706
- { clientId: { $regex: inputData.searchValue, $options: 'i' } },
2707
- { clientName: { $regex: inputData.searchValue, $options: 'i' } },
2708
- { moduleType: { $regex: inputData.searchValue, $options: 'i' } },
2709
- { clientStatus: { $regex: inputData.searchValue, $options: 'i' } },
2710
- ],
2711
-
2712
- },
2713
- } );
2672
+ const rx = new RegExp( inputData.searchValue, 'i' );
2673
+ merged = merged.filter( ( r ) =>
2674
+ rx.test( r.clientId || '' ) ||
2675
+ rx.test( r.clientName || '' ) ||
2676
+ rx.test( r.moduleType || '' ) ||
2677
+ rx.test( r.clientStatus || '' ),
2678
+ );
2714
2679
  }
2680
+
2681
+ // --- Sort: numeric columns numerically, strings case-insensitively to
2682
+ // match the DB collation ({ locale: 'en', strength: 2 }). --------------
2715
2683
  if ( inputData.sortColumnName ) {
2716
- const sortBy = inputData.sortBy || -1;
2717
- const sortColumn = inputData.sortColumnName;
2718
- query.push( {
2719
- $sort: { [sortColumn]: sortBy },
2720
- },
2721
- );
2684
+ const dir = Number( inputData.sortBy ) === 1 ? 1 : -1;
2685
+ const col = inputData.sortColumnName;
2686
+ merged.sort( ( a, b ) => {
2687
+ const av = a[col];
2688
+ const bv = b[col];
2689
+ let cmp;
2690
+ if ( typeof av === 'number' && typeof bv === 'number' ) {
2691
+ cmp = av - bv;
2692
+ } else {
2693
+ cmp = String( av ?? '' ).localeCompare( String( bv ?? '' ), 'en', { sensitivity: 'accent' } );
2694
+ }
2695
+ return dir * cmp;
2696
+ } );
2722
2697
  }
2723
2698
 
2724
- const count = await aggregateAuditStoreData( query );
2725
- if ( count.length == 0 ) {
2699
+ const count = merged.length;
2700
+ if ( count === 0 ) {
2726
2701
  return res.sendError( 'No Data Found', 204 );
2727
2702
  }
2728
2703
 
2729
2704
  if ( inputData.isExport ) {
2730
- query.push( { $limit: 10000 } );
2731
- } else {
2732
- query.push( {
2733
- $skip: offset,
2734
- },
2735
- {
2736
- $limit: limit,
2737
- } );
2738
- }
2739
- if ( inputData.isExport ) {
2740
- query.push(
2741
- {
2742
- $project: {
2743
- '_id': 0,
2744
- 'File Date': '$fileDate',
2745
- 'Brand Name': '$clientName',
2746
- 'Brand Id': '$clientId',
2747
- 'Product Type': '$moduleType',
2748
- // 'Completed Percentage': '$completionPercentage',
2749
- 'Installed Stores': '$installedStore',
2750
- 'Audit Stores': '$totalFilesCount',
2751
- 'Inprogress Stores': '$inprogressStoresCount',
2752
- 'Not Assigned stores': '$notAssignedCount',
2753
- // 'Not Assigned Stores': '$notAssignedStores',
2754
- 'Completed Stores': '$completedStores',
2755
- 'Status': '$clientStatus',
2756
- },
2757
- },
2758
- );
2705
+ const exportRows = merged.slice( 0, 10000 ).map( ( r ) => ( {
2706
+ 'File Date': r.fileDate,
2707
+ 'Brand Name': r.clientName,
2708
+ 'Brand Id': r.clientId,
2709
+ 'Product Type': r.moduleType,
2710
+ 'Installed Stores': r.installedStore,
2711
+ 'Audit Stores': r.totalFilesCount,
2712
+ 'Inprogress Stores': r.inprogressStoresCount,
2713
+ 'Not Assigned stores': r.notAssignedCount,
2714
+ 'Completed Stores': r.completedStores,
2715
+ 'Status': r.clientStatus,
2716
+ } ) );
2717
+ await download( exportRows, res );
2718
+ return;
2759
2719
  }
2760
2720
 
2761
- const result = await aggregateAuditStoreData( query );
2762
- if ( result.length == 0 ) {
2721
+ const pageResult = merged.slice( offset, offset + limit );
2722
+ if ( pageResult.length === 0 ) {
2763
2723
  return res.sendError( 'No Data Found', 204 );
2764
2724
  }
2765
- if ( inputData.isExport ) {
2766
- await download( result, res );
2767
- return;
2768
- }
2769
- return res.sendSuccess( { result: result, count: count.length } );
2725
+ return res.sendSuccess( { result: pageResult, count: count } );
2770
2726
  } catch ( error ) {
2771
2727
  const err = error.error || 'Internal Server Error';
2772
2728
  logger.error( { error: error, message: req.body, function: 'clientMetrics' } );
@@ -3715,6 +3671,13 @@ export async function reTrigger( req, res ) {
3715
3671
  }
3716
3672
  switch ( inputData.triggerType ) {
3717
3673
  case 'queue':
3674
+ // Only push to the queue when the store has not already been queued.
3675
+ // A 'not_assign' status means a prior reTrigger already pushed this
3676
+ // store - pushing again would create a duplicate queue message.
3677
+ if ( req.audit.status === 'not_assign' ) {
3678
+ await releaseReTriggerLock( lockKey );
3679
+ return res.sendError( 'This store has already been pushed to the queue', 400 );
3680
+ }
3718
3681
  // Only push to the queue when the store has not already been queued.
3719
3682
  // A 'not_assign' status means a prior reTrigger already pushed this
3720
3683
  // store - pushing again would create a duplicate queue message.
@@ -5899,3 +5862,5 @@ export async function rePushAuditStores( req, res ) {
5899
5862
  return res.sendError( err, 500 );
5900
5863
  }
5901
5864
  }
5865
+
5866
+
@@ -1414,7 +1414,6 @@ export async function summaryList( req, res ) {
1414
1414
  if ( inputData.clientId !== '11' ) {
1415
1415
  return res.sendError( 'No data found', 204 );
1416
1416
  }
1417
-
1418
1417
  // declare openSearch and get data form the .env/secret manager
1419
1418
  const openSearch = JSON.parse( process.env.OPENSEARCH );
1420
1419
 
@@ -1764,6 +1763,7 @@ export async function summaryList( req, res ) {
1764
1763
  } );
1765
1764
  }
1766
1765
  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,6 +1787,7 @@ 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 } );
1790
1791
  if ( !getCluster || getCluster?.length == 0 ) {
1791
1792
  return res.sendError( 'No data', 204 );
1792
1793
  }
@@ -3174,3 +3175,175 @@ export async function updateEmailConfig1( req, res ) {
3174
3175
  return res.sendError( err, 500 );
3175
3176
  }
3176
3177
  }
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,3 +321,5 @@ export const rePushAuditStoresSchema = joi.object( {
321
321
  export const rePushAuditStoresValid = {
322
322
  body: rePushAuditStoresSchema,
323
323
  };
324
+
325
+
@@ -418,3 +418,14 @@ 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,7 +45,10 @@ 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
+
48
50
  export default auditRouter;
49
51
 
50
52
  //
51
53
  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, 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';
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';
6
6
  import { isSuperAdmin, roleValidation, sendAlert } from '../validation/eyeTest.validation.js';
7
7
 
8
8
  export const eyeTestAuditRouter = Router();
@@ -15,6 +15,10 @@ 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
+
18
22
  // enhancement of aug of 2025
19
23
 
20
24
  eyeTestAuditRouter.post( '/summary-list', isAllowedSessionHandler, accessVerification( { userType: [ 'tango', 'client' ] } ), validate( summaryListValid ), roleValidation, summaryList );
@@ -151,13 +151,11 @@ export async function mapCustomer( sourceMappingData, customerData ) {
151
151
  try {
152
152
  const filterCustomerData = await filteredCustomerMap( customerData );
153
153
  logger.info( { filterCustomerData: filterCustomerData } );
154
- const chunkedMappingData = await chunkArray( sourceMappingData, 10 );
155
-
156
- const promises = chunkedMappingData.map( async ( chunk ) => {
157
- return mapFunction( chunk, filterCustomerData );
158
- } );
159
- const mappedArrays = await Promise.all( promises );
160
- return mappedArrays.flat();
154
+ // Build the lookup once, then retag every row in a single pass. This is
155
+ // O(rows + mappedIds) and scales for concurrent submissions, versus the old
156
+ // O(customers x mappedIds x rows) scan that also re-read its own mutations.
157
+ const customerMap = buildCustomerMap( filterCustomerData );
158
+ return mapFunction( sourceMappingData, customerMap );
161
159
  } catch ( error ) {
162
160
  logger.error( { error: error, type: 'mapCustomer' } );
163
161
  return error;
@@ -168,22 +166,39 @@ const filteredCustomerMap = async ( data ) => {
168
166
  return data.filter( ( map ) => map.count >= 1 );
169
167
  };
170
168
 
171
- const mapFunction = async ( chunkData, filterData ) => {
169
+ // Lookup of original cluster id (child img_name) -> the customer it was tagged
170
+ // into. Last write wins if the same id is referenced by more than one group, so
171
+ // the result is deterministic regardless of customer order.
172
+ const buildCustomerMap = ( filterData ) => {
173
+ const customerMap = new Map();
172
174
  for ( const data of filterData ) {
173
- const mappedIds = data.mappedid;
174
- if ( data?.mappedid ) {
175
- for ( const mappedId of mappedIds ) {
176
- chunkData.find( ( item ) => {
177
- if ( mappedId.img_name === item.temp_ids ) {
178
- item.temp_ids = data.img_name;
179
- ( mappedId.img_name !== data.img_name ) ? item.query_status = '' : null;
180
- item.demographic = data.demographic || '';
181
- }
182
- } );
183
- }
175
+ if ( !data?.mappedid ) continue;
176
+ for ( const mappedId of data.mappedid ) {
177
+ customerMap.set( mappedId.img_name, {
178
+ parentId: data.img_name,
179
+ demographic: data.demographic || '',
180
+ // The chosen representative (img_name === parent) keeps its query flag so
181
+ // it resurfaces in reaudit; every other merged image is cleared so it does
182
+ // not (query_status is the reaudit gate in getFilterData / getAuditFilterData).
183
+ isChild: mappedId.img_name !== data.img_name,
184
+ } );
184
185
  }
185
186
  }
186
- return chunkData;
187
+ return customerMap;
188
+ };
189
+
190
+ // Single pass over the master-json rows. Each row's ORIGINAL temp_ids is read
191
+ // exactly once and reassigned to its customer's parent id, so every row sharing
192
+ // a temp_ids is retagged and the retag never feeds back into later lookups.
193
+ const mapFunction = ( personData, customerMap ) => {
194
+ for ( const item of personData ) {
195
+ const mapped = customerMap.get( item.temp_ids );
196
+ if ( !mapped ) continue;
197
+ item.temp_ids = mapped.parentId;
198
+ item.query_status = mapped.isChild ? '' : item.query_status;
199
+ item.demographic = mapped.demographic;
200
+ }
201
+ return personData;
187
202
  };
188
203
 
189
204
  export async function isAuditInputFolderExist( req, res, next ) {
@@ -345,7 +360,8 @@ export async function getAuditFilterData( data ) {
345
360
  ( item ) =>
346
361
  item.temp_ids < 20000 &&
347
362
  item.temp_ids > 0 &&
348
- item.person_status === '',
363
+ item.person_status === '' &&
364
+ item.query_status !== '',
349
365
  );
350
366
  const finalResult = await filterData.map( ( i ) => {
351
367
  return { ...i, img: i.index.concat( '_', i.temp_ids ) };