tango-app-api-trax 3.8.18 → 3.8.19-nike

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.
@@ -35,6 +35,8 @@ import fs from 'fs';
35
35
  import path from 'path';
36
36
  import { fileURLToPath as toPath } from 'url';
37
37
  import * as cameraService from '../services/camera.service.js';
38
+ import * as recurringFlagTracker from '../services/recurringFlagTracker.service.js';
39
+ import ExcelJS from 'exceljs';
38
40
 
39
41
  const __ctrlDir = path.dirname( toPath( import.meta.url ) );
40
42
  const tangoyeLogoSvg = fs.readFileSync(
@@ -323,7 +325,7 @@ export async function PCLconfigCreation( req, res ) {
323
325
  },
324
326
  } );
325
327
  let getSections = await CLquestions.aggregate( sectionQuery );
326
- if ( getSections.length || [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'eyetest', 'remoteoptometrist', 'storehygienemonitoring', 'cleaning', 'scrum', 'suspiciousactivity', 'boxalert', 'suspiciousfootfall', 'drinking', 'bagdetection', 'inventorycount', 'carsattended', 'numberplateinfo', 'vehicle_check_in', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert', 'storehygienemonitoring' ].includes( getCLconfig.checkListType ) ) {
328
+ if ( getSections.length || [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'eyetest', 'remoteoptometrist', 'storehygienemonitoring', 'cleaning', 'scrum', 'suspiciousactivity', 'suspiciousfootfall', 'drinking', 'bagdetection', 'inventorycount', 'carsattended', 'numberplateinfo', 'vehicle_check_in', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert', 'storehygienemonitoring', 'staffgrouping', 'boxalert', 'employeeCount' ].includes( getCLconfig.checkListType ) ) {
327
329
  if ( getSections.length ) {
328
330
  for ( let element3 of getSections ) {
329
331
  let collectQuestions = {};
@@ -648,11 +650,11 @@ export async function PCLconfigCreation( req, res ) {
648
650
  // }
649
651
  }
650
652
  } else {
651
- if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'eyetest', 'remoteoptometrist', 'storehygienemonitoring', 'cleaning', 'scrum', 'suspiciousactivity', 'boxalert', 'suspiciousfootfall', 'drinking', 'bagdetection', 'inventorycount', 'carsattended', 'numberplateinfo', 'vehicle_check_in', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert' ].includes( getCLconfig.checkListType ) ) {
653
+ if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'eyetest', 'remoteoptometrist', 'storehygienemonitoring', 'cleaning', 'scrum', 'suspiciousactivity', 'suspiciousfootfall', 'drinking', 'bagdetection', 'inventorycount', 'carsattended', 'numberplateinfo', 'vehicle_check_in', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert', 'staffgrouping', 'boxalert' ].includes( getCLconfig.checkListType ) ) {
652
654
  let storeNameList = allQuestion.map( ( item ) => item.store_id );
653
- let storeDetails = await storeService.find( { clientId: getCLconfig.client_id, status: 'active', ...( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert' ].includes( getCLconfig.checkListType ) ) ? { storeId: { $in: storeNameList } } : {} }, { storeId: 1 } );
655
+ let storeDetails = await storeService.find( { clientId: getCLconfig.client_id, status: 'active', ...( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert', 'staffgrouping', 'boxalert', 'employeeCount' ].includes( getCLconfig.checkListType ) ) ? { storeId: { $in: storeNameList } } : {} }, { storeId: 1 } );
654
656
  let storeList = storeDetails.map( ( store ) => store.storeId );
655
- if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert' ].includes( getCLconfig.checkListType ) ) {
657
+ if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert', 'staffgrouping', 'boxalert', 'employeeCount' ].includes( getCLconfig.checkListType ) ) {
656
658
  allQuestion = allQuestion.filter( ( ele ) => storeList.includes( ele?.store_id ) );
657
659
  } else {
658
660
  allQuestion = storeDetails.map( ( item ) => {
@@ -686,7 +688,7 @@ export async function PCLconfigCreation( req, res ) {
686
688
  client_id: getCLconfig.client_id,
687
689
  aiStoreList: allQuestion.length ? allQuestion.map( ( store ) => store.store_id ) : [],
688
690
  };
689
- if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert' ].includes( getCLconfig.checkListType ) ) {
691
+ if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert', 'staffgrouping', 'boxalert', 'employeeCount' ].includes( getCLconfig.checkListType ) ) {
690
692
  let processData = {
691
693
  aiStoreList: allQuestion.length ? allQuestion.map( ( store ) => {
692
694
  return { storeName: store.storeName, storeId: store.store_id, events: store.events };
@@ -927,7 +929,7 @@ async function insertData( requestData ) {
927
929
  },
928
930
  } );
929
931
  let getSections = await CLquestions.aggregate( sectionQuery );
930
- if ( getSections.length || [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'eyetest', 'remoteoptometrist', 'storehygienemonitoring', 'cleaning', 'scrum', 'suspiciousactivity', 'boxalert', 'suspiciousfootfall', 'drinking', 'bagdetection', 'inventorycount', 'carsattended', 'numberplateinfo', 'vehicle_check_in', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert' ].includes( getCLconfig.checkListType ) ) {
932
+ if ( getSections.length || [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'eyetest', 'remoteoptometrist', 'storehygienemonitoring', 'cleaning', 'scrum', 'suspiciousactivity', 'suspiciousfootfall', 'drinking', 'bagdetection', 'inventorycount', 'carsattended', 'numberplateinfo', 'vehicle_check_in', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert', 'staffgrouping', 'boxalert', 'employeeCount' ].includes( getCLconfig.checkListType ) ) {
931
933
  if ( getSections.length ) {
932
934
  for ( let element3 of getSections ) {
933
935
  let collectQuestions = {};
@@ -1222,11 +1224,11 @@ async function insertData( requestData ) {
1222
1224
  // }
1223
1225
  }
1224
1226
  } else {
1225
- if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'eyetest', 'remoteoptometrist', 'storehygienemonitoring', 'cleaning', 'scrum', 'suspiciousactivity', 'boxalert', 'suspiciousfootfall', 'drinking', 'bagdetection', 'inventorycount', 'carsattended', 'numberplateinfo', 'vehicle_check_in', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert' ].includes( getCLconfig.checkListType ) ) {
1227
+ if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'eyetest', 'remoteoptometrist', 'storehygienemonitoring', 'cleaning', 'scrum', 'suspiciousactivity', 'suspiciousfootfall', 'drinking', 'bagdetection', 'inventorycount', 'carsattended', 'numberplateinfo', 'vehicle_check_in', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert', 'staffgrouping', 'boxalert', 'employeeCount' ].includes( getCLconfig.checkListType ) ) {
1226
1228
  let storeNameList = allQuestion.map( ( item ) => item.store_id );
1227
- let storeDetails = await storeService.find( { clientId: getCLconfig.client_id, status: 'active', ...( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert' ].includes( getCLconfig.checkListType ) ) ? { storeId: { $in: storeNameList } } : {} }, { storeId: 1 } );
1229
+ let storeDetails = await storeService.find( { clientId: getCLconfig.client_id, status: 'active', ...( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert', 'staffgrouping', 'boxalert', 'employeeCount' ].includes( getCLconfig.checkListType ) ) ? { storeId: { $in: storeNameList } } : {} }, { storeId: 1 } );
1228
1230
  let storeList = storeDetails.map( ( store ) => store.storeId );
1229
- if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert' ].includes( getCLconfig.checkListType ) ) {
1231
+ if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert', 'staffgrouping', 'boxalert', 'employeeCount' ].includes( getCLconfig.checkListType ) ) {
1230
1232
  allQuestion = allQuestion.filter( ( ele ) => storeList.includes( ele?.store_id ) );
1231
1233
  } else {
1232
1234
  allQuestion = storeDetails.map( ( item ) => {
@@ -1260,7 +1262,7 @@ async function insertData( requestData ) {
1260
1262
  client_id: getCLconfig.client_id,
1261
1263
  aiStoreList: allQuestion.length ? allQuestion.map( ( store ) => store.store_id ) : [],
1262
1264
  };
1263
- if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert' ].includes( getCLconfig.checkListType ) ) {
1265
+ if ( [ 'storeopenandclose', 'mobileusagedetection', 'uniformdetection', 'customerunattended', 'staffleftinthemiddle', 'scrum', 'cleaning', 'outsidebusinesshoursqueuetracking', 'halfshutter', 'tvcompliance', 'cameratampering', 'queuealert', 'staffgrouping', 'boxalert', 'employeeCount' ].includes( getCLconfig.checkListType ) ) {
1264
1266
  let processData = {
1265
1267
  aiStoreList: allQuestion.length ? allQuestion.map( ( store ) => {
1266
1268
  return { storeName: store.storeName, storeId: store.store_id, events: store.events };
@@ -2117,7 +2119,6 @@ export async function getPDFCSVChecklistDetails( req, res ) {
2117
2119
  }
2118
2120
  }
2119
2121
 
2120
-
2121
2122
  export async function AiPushNotificationAlert( req, res ) {
2122
2123
  try {
2123
2124
  // console.log( req.body );
@@ -2174,7 +2175,6 @@ export async function liveAiPushNotificationAlert( req, res ) {
2174
2175
  }
2175
2176
  }
2176
2177
 
2177
-
2178
2178
  export async function taskPushNotification( req, res ) {
2179
2179
  try {
2180
2180
  let query = [ {
@@ -2643,6 +2643,9 @@ export async function updateRunAI( req, res ) {
2643
2643
  if ( !req.body.qName ) {
2644
2644
  return res.sendError( 'Question name is required', 400 );
2645
2645
  }
2646
+ if ( !req.body.userAnswer ) {
2647
+ return res.sendError( 'answer is required', 400 );
2648
+ }
2646
2649
  let getDetails = await processedchecklist.findOne( { _id: req.body.id } );
2647
2650
  if ( !getDetails ) {
2648
2651
  return res.sendError( 'No data found', 204 );
@@ -2651,7 +2654,7 @@ export async function updateRunAI( req, res ) {
2651
2654
  let updateData = {};
2652
2655
 
2653
2656
  for ( let k of Object.keys( req.body.data ) ) {
2654
- let keyPath = `questionAnswers.$[section].questions.$[question].userAnswer.0.${k}`;
2657
+ let keyPath = `questionAnswers.$[section].questions.$[question].userAnswer.$[userAnswer].${k}`;
2655
2658
  updateData[keyPath] = req.body.data[k];
2656
2659
  }
2657
2660
 
@@ -2662,7 +2665,9 @@ export async function updateRunAI( req, res ) {
2662
2665
  arrayFilters: [
2663
2666
  { 'section.section_id': new ObjectId( req.body.sectionId ) },
2664
2667
  { 'question.qname': req.body.qName },
2668
+ { 'userAnswer.answer': req.body.userAnswer },
2665
2669
  ],
2670
+ strict: false,
2666
2671
  },
2667
2672
  );
2668
2673
  return res.sendSuccess( 'RunAI details updated successfully' );
@@ -2697,6 +2702,113 @@ export async function countUpdateRunAI( req, res ) {
2697
2702
  }
2698
2703
  }
2699
2704
 
2705
+ // Called by the runAI processing team once per (subject, checklist, section, qno, date) when their cron
2706
+ // detects a runAI flag. Idempotent per day via lastRunAIFlaggedDate — replaying the same date is a no-op.
2707
+ export async function incrementRunAIRecurring( req, res ) {
2708
+ try {
2709
+ let body = { ...( req.body || {} ) };
2710
+ if ( !body.section_id ) return res.sendError( 'section_id is required', 400 );
2711
+ if ( body.qno === undefined || body.qno === null || body.qno === '' ) return res.sendError( 'qno is required', 400 );
2712
+ if ( !body.id ) return res.sendError( 'Id is required', 400 );
2713
+ if ( !body.qname ) return res.sendError( 'Question name is required', 400 );
2714
+ if ( !body.sectionName ) return res.sendError( 'Section name is required', 400 );
2715
+
2716
+ const checklistDetails = await processedchecklist.findOne( { _id: body.id } );
2717
+ if ( !checklistDetails ) {
2718
+ return res.sendError( 'Checklist not found', 204 );
2719
+ }
2720
+
2721
+ // sectionName/qname live inside questionAnswers; surface them so the first-insert tracker doc is complete.
2722
+ // const section = ( checklistDetails.questionAnswers || [] ).find( ( s ) => String( s?.section_id ) === String( body.section_id ) );
2723
+ // const question = section?.questions?.find( ( q ) => String( q?.qno ) === String( body.qno ) );
2724
+
2725
+ body = {
2726
+ ...body,
2727
+ date: checklistDetails.date_string,
2728
+ sourceCheckList_id: checklistDetails.sourceCheckList_id,
2729
+ client_id: checklistDetails.client_id,
2730
+ coverage: checklistDetails.coverage,
2731
+ store_id: checklistDetails.store_id,
2732
+ storeName: checklistDetails.storeName,
2733
+ user_id: checklistDetails.userId,
2734
+ userEmail: checklistDetails.userEmail,
2735
+ userName: checklistDetails.userName,
2736
+ checkListName: checklistDetails.checkListName,
2737
+ // sectionName: section?.sectionName || '',
2738
+ // qname: question?.qname || '',
2739
+ lastSubmittedBy: checklistDetails.userName || checklistDetails.userEmail || '--',
2740
+ lastSubmissionDate: checklistDetails.submitTime_string,
2741
+ };
2742
+
2743
+ // Skip checklists that don't have recurring flag configured — same gate that recurringFlagAlert uses
2744
+ // when picking which checklists to email for. Avoids creating tracker docs that would never be acted on.
2745
+ const checklistConfig = await CLconfig.findOne( { _id: body.sourceCheckList_id }, { recurringFlag: 1, publish: 1 } );
2746
+ if ( !checklistConfig ) {
2747
+ return res.sendError( 'Checklist not found', 404 );
2748
+ }
2749
+ const hasRecurring = ( Array.isArray( checklistConfig?.recurringFlag?.users ) && checklistConfig.recurringFlag.users.length > 0 ) ||
2750
+ ( Array.isArray( checklistConfig?.recurringFlag?.notifyType ) && checklistConfig.recurringFlag.notifyType.length > 0 );
2751
+ if ( !hasRecurring ) {
2752
+ return res.sendSuccess( { message: 'Recurring flag not configured for this checklist', noop: true } );
2753
+ }
2754
+
2755
+ const isUserBased = ( body.coverage === 'user' ) || ( !body.store_id && ( body.user_id || body.userEmail ) );
2756
+ const storeId = isUserBased ? '' : ( body.store_id || '' );
2757
+ const userId = isUserBased ? ( body.user_id ? String( body.user_id ) : ( body.userEmail || '' ) ) : '';
2758
+ if ( !isUserBased && !storeId ) return res.sendError( 'store_id is required for store-based', 400 );
2759
+ if ( isUserBased && !userId ) return res.sendError( 'user_id or userEmail is required for user-based', 400 );
2760
+
2761
+ const date = body.date;
2762
+ const filter = {
2763
+ client_id: body.client_id,
2764
+ sourceCheckList_id: body.sourceCheckList_id,
2765
+ section_id: body.section_id,
2766
+ qno: String( body.qno ),
2767
+ ...( isUserBased ? { user_id: userId } : { store_id: storeId } ),
2768
+ };
2769
+
2770
+ const existing = await recurringFlagTracker.findOne( filter, { lastRunAIFlaggedDate: 1 } );
2771
+ if ( existing && existing.lastRunAIFlaggedDate === date ) {
2772
+ return res.sendSuccess( { message: 'Already counted for this date', noop: true } );
2773
+ }
2774
+
2775
+ const setOnInsert = {
2776
+ coverage: isUserBased ? 'user' : 'store',
2777
+ checkListName: body.checkListName || '',
2778
+ sectionName: body.sectionName || '',
2779
+ qname: body.qname || '',
2780
+ storeName: isUserBased ? '' : ( body.storeName || '' ),
2781
+ userName: body.userName || '',
2782
+ userEmail: body.userEmail || '',
2783
+ };
2784
+
2785
+ console.log( setOnInsert );
2786
+
2787
+ await recurringFlagTracker.bulkWrite( [
2788
+ {
2789
+ updateOne: {
2790
+ filter,
2791
+ update: {
2792
+ $setOnInsert: setOnInsert,
2793
+ $set: {
2794
+ lastRunAIFlaggedDate: date,
2795
+ ...( body.lastSubmittedBy ? { lastSubmittedBy: body.lastSubmittedBy } : {} ),
2796
+ ...( body.lastSubmissionDate ? { lastSubmissionDate: body.lastSubmissionDate } : {} ),
2797
+ },
2798
+ $inc: { runAICount: 1 },
2799
+ },
2800
+ upsert: true,
2801
+ },
2802
+ },
2803
+ ] );
2804
+
2805
+ return res.sendSuccess( { message: 'runAI recurring count updated' } );
2806
+ } catch ( e ) {
2807
+ logger.error( { functionName: 'incrementRunAIRecurring', error: e } );
2808
+ return res.sendError( e, 500 );
2809
+ }
2810
+ }
2811
+
2700
2812
  export async function getRunAIQuestions( req, res ) {
2701
2813
  try {
2702
2814
  let requestData = req.body;
@@ -3747,7 +3859,6 @@ export async function checklistAutoMailList( req, res ) {
3747
3859
  }
3748
3860
  }
3749
3861
 
3750
-
3751
3862
  export const downloadInsertPdfOld = async ( req, res ) => {
3752
3863
  try {
3753
3864
  setImmediate( async () => {
@@ -4224,3 +4335,895 @@ export async function getEyetestStream( req, res ) {
4224
4335
  return res.sendError( e, 500 );
4225
4336
  }
4226
4337
  }
4338
+
4339
+ function buildRecurringFlagExcel( rows, subjectLabel = 'Store' ) {
4340
+ const workbook = new ExcelJS.Workbook();
4341
+ const sheet = workbook.addWorksheet( 'Recurring Flags' );
4342
+ sheet.columns = [
4343
+ { header: `${subjectLabel} Name`, key: 'storeName', width: 25 },
4344
+ { header: 'Checklist Name', key: 'checklistName', width: 30 },
4345
+ { header: 'Section', key: 'sectionName', width: 25 },
4346
+ { header: 'Question', key: 'questionName', width: 40 },
4347
+ { header: 'Last Submitted By', key: 'lastSubmittedBy', width: 25 },
4348
+ { header: 'Last Submission Date', key: 'lastSubmissionDate', width: 22 },
4349
+ { header: 'Recurring Days', key: 'days', width: 16 },
4350
+ { header: 'Run AI Flags', key: 'runAICount', width: 14 },
4351
+ ];
4352
+ sheet.getRow( 1 ).font = { bold: true };
4353
+ rows.forEach( ( r ) => sheet.addRow( r ) );
4354
+ return workbook.xlsx.writeBuffer();
4355
+ }
4356
+
4357
+ export async function recurringFlagAlert( req, res ) {
4358
+ try {
4359
+ const checklistDetails = await CLconfig.find( {
4360
+ publish: true,
4361
+ $expr: {
4362
+ $or: [
4363
+ { $gt: [ { $size: { $cond: [ { $isArray: '$recurringFlag.users' }, '$recurringFlag.users', [] ] } }, 0 ] },
4364
+ { $gt: [ { $size: { $cond: [ { $isArray: '$recurringFlag.notifyType' }, '$recurringFlag.notifyType', [] ] } }, 0 ] },
4365
+ ],
4366
+ },
4367
+ }, { _id: 1, checkListName: 1, recurringFlag: 1, notifyFlags: 1, approver: 1, client_id: 1 } );
4368
+ if ( !checklistDetails.length ) {
4369
+ return res.sendSuccess( 'No checklists configured for recurring flag' );
4370
+ }
4371
+
4372
+ // Pending triggers will be grouped per recipient email at the end.
4373
+ const triggers = [];
4374
+ const trackerIdsToReset = [];
4375
+
4376
+ await Promise.all( checklistDetails.map( async ( cl ) => {
4377
+ const threshold = cl?.recurringFlag?.threshold || 3;
4378
+ const notifyType = cl?.recurringFlag?.notifyType || [];
4379
+ const users = cl?.recurringFlag?.users || [];
4380
+ let recipients = [];
4381
+ if ( notifyType.includes( 'sameAsNotify' ) ) {
4382
+ const nfType = cl?.notifyFlags?.notifyType || [];
4383
+ const nfUsers = cl?.notifyFlags?.users || [];
4384
+ if ( nfType.includes( 'approver' ) && Array.isArray( cl.approver ) ) {
4385
+ recipients = cl.approver.map( ( a ) => a?.value ).filter( Boolean );
4386
+ }
4387
+ recipients = [ ...recipients, ...nfUsers.map( ( u ) => u?.value ).filter( Boolean ) ];
4388
+ }
4389
+ if ( notifyType.includes( 'approver' ) && Array.isArray( cl.approver ) ) {
4390
+ recipients = [ ...recipients, ...cl.approver.map( ( a ) => a?.value ).filter( Boolean ) ];
4391
+ }
4392
+ recipients = [ ...recipients, ...users.map( ( u ) => u?.value ).filter( Boolean ) ];
4393
+ recipients = [ ...new Set( recipients ) ];
4394
+
4395
+ if ( !recipients.length ) return;
4396
+
4397
+ // Resolve each recipient's allowed-store scope once per checklist:
4398
+ // allowed === null → full access (superadmin / non-client userType / external recipient not in users collection)
4399
+ // allowed === Set → restrict store-based rows to these storeIds
4400
+ // User-based tracker rows pass through regardless (no store binding).
4401
+ const recipientFilters = await Promise.all( recipients.map( async ( recipient ) => {
4402
+ const userDetails = await userService.findOne(
4403
+ { email: recipient },
4404
+ { email: 1, assignedStores: 1, userType: 1, role: 1, clientId: 1 },
4405
+ );
4406
+ // Unknown recipients (external approvers etc.) keep full access — preserves existing behavior.
4407
+ const allowed = userDetails ? await resolveUserAssignedStores( userDetails ) : null;
4408
+ return { recipient, allowed };
4409
+ } ) );
4410
+
4411
+ // Read tracker rows where EITHER the sop streak or the runAI count has hit threshold and the
4412
+ // current trigger hasn't been emailed yet (lastEmailDate vs the relevant flagged date).
4413
+ const trackerRows = await recurringFlagTracker.find( {
4414
+ sourceCheckList_id: cl._id,
4415
+ $or: [
4416
+ {
4417
+ consecutiveCount: { $gte: threshold },
4418
+ $expr: { $ne: [ { $ifNull: [ '$lastEmailDate', '' ] }, { $ifNull: [ '$lastFlaggedDate', '' ] } ] },
4419
+ },
4420
+ {
4421
+ runAICount: { $gte: threshold },
4422
+ $expr: { $ne: [ { $ifNull: [ '$lastEmailDate', '' ] }, { $ifNull: [ '$lastRunAIFlaggedDate', '' ] } ] },
4423
+ },
4424
+ ],
4425
+ } );
4426
+
4427
+ for ( const t of trackerRows ) {
4428
+ const isUserBased = t.coverage === 'user' || ( !t.store_id && ( t.user_id || t.userEmail ) );
4429
+ // For user-based checklists, group/identify by userEmail; for store-based, by store_id.
4430
+ const subjectId = isUserBased ? ( t.userEmail || t.user_id || '' ) : ( t.store_id || '' );
4431
+ const subjectName = isUserBased ? ( t.userName || t.userEmail || '--' ) : ( t.storeName || '--' );
4432
+ // Determine which streak crossed threshold for this row — drives reset granularity below.
4433
+ const sopFired = ( t.consecutiveCount || 0 ) >= threshold && t.lastEmailDate !== t.lastFlaggedDate;
4434
+ const runAIFired = ( t.runAICount || 0 ) >= threshold && t.lastEmailDate !== t.lastRunAIFlaggedDate;
4435
+
4436
+ let rowEmitted = false;
4437
+ for ( const { recipient, allowed } of recipientFilters ) {
4438
+ // Skip store-based rows outside the recipient's reachable stores. Full-access (null) and
4439
+ // user-based rows always pass through.
4440
+ if ( allowed !== null && !isUserBased && !allowed.has( t.store_id ) ) continue;
4441
+ triggers.push( {
4442
+ recipient,
4443
+ clientId: t.client_id,
4444
+ coverage: isUserBased ? 'user' : 'store',
4445
+ subjectId,
4446
+ subjectName,
4447
+ storeId: t.store_id || '',
4448
+ storeName: t.storeName || '',
4449
+ userId: t.user_id || '',
4450
+ userName: t.userName || '',
4451
+ userEmail: t.userEmail || '',
4452
+ checklistId: cl._id.toString(),
4453
+ checklistName: cl.checkListName?.trim() || t.checkListName || '',
4454
+ sectionName: t.sectionName,
4455
+ qno: t.qno,
4456
+ qname: t.qname,
4457
+ days: t.consecutiveCount,
4458
+ runAICount: t.runAICount || 0,
4459
+ sopFired,
4460
+ runAIFired,
4461
+ lastSubmittedBy: t.lastSubmittedBy || '--',
4462
+ lastSubmissionDate: t.lastSubmissionDate || t.lastFlaggedDate || '',
4463
+ } );
4464
+ rowEmitted = true;
4465
+ }
4466
+ // Only reset rows that actually went into at least one recipient's email — otherwise a row
4467
+ // visible to nobody would silently zero its streak without an email being sent.
4468
+ if ( rowEmitted ) {
4469
+ trackerIdsToReset.push( {
4470
+ _id: t._id,
4471
+ lastFlaggedDate: t.lastFlaggedDate,
4472
+ lastRunAIFlaggedDate: t.lastRunAIFlaggedDate,
4473
+ sopFired,
4474
+ runAIFired,
4475
+ } );
4476
+ }
4477
+ }
4478
+ } ) );
4479
+
4480
+ if ( !triggers.length ) {
4481
+ return res.sendSuccess( 'No recurring flags reached threshold' );
4482
+ }
4483
+
4484
+
4485
+ // Group triggers by recipient.
4486
+ const byRecipient = new Map();
4487
+ for ( const t of triggers ) {
4488
+ if ( !byRecipient.has( t.recipient ) ) byRecipient.set( t.recipient, [] );
4489
+ byRecipient.get( t.recipient ).push( t );
4490
+ }
4491
+
4492
+ const flagDomain = `${JSON.parse( process.env.URL ).domain}/manage/trax/flags?date=${dayjs().format( 'YYYY-MM-DD' )}`;
4493
+ const fileContent = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/recurringFlag.hbs', 'utf8' );
4494
+ const compiled = handlebars.compile( fileContent );
4495
+
4496
+ const sentSummary = [];
4497
+
4498
+ await Promise.all( [ ...byRecipient.entries() ].map( async ( [ recipient, items ] ) => {
4499
+ const subjects = new Set( items.map( ( i ) => i.subjectId ) );
4500
+ const checklists = new Set( items.map( ( i ) => i.checklistId ) );
4501
+ const isMultiStore = subjects.size > 1; // "isMultiStore" name retained for template back-compat
4502
+ const isMultiChecklist = !isMultiStore && checklists.size > 1;
4503
+ // Sub-mode of multi-store when the recipient's flagged subjects all share a single checklist.
4504
+ // Drives a tighter email layout (no Checklist column, no "Total Checklists" line, checklist name in intro).
4505
+ const isMultiStoreSingleChecklist = isMultiStore && checklists.size === 1;
4506
+ // Threshold for the message line — when grouping spans multiple checklists/subjects, take min threshold seen.
4507
+ const thresholdShown = items.reduce( ( acc, it ) => Math.min( acc, it.days ), items[0].days );
4508
+
4509
+ // If every trigger for this recipient is user-based, label as User. Mixed sets fall back to Store.
4510
+ const coverages = new Set( items.map( ( i ) => i.coverage ) );
4511
+ const isAllUser = coverages.size === 1 && coverages.has( 'user' );
4512
+ const subjectLabel = isAllUser ? 'User' : 'Store';
4513
+ const subjectLabelPlural = isAllUser ? 'Users' : 'Stores';
4514
+ const subjectLabelLower = isAllUser ? 'user' : 'store';
4515
+ const subjectLabelPluralLower = isAllUser ? 'users' : 'stores';
4516
+
4517
+ // Aggregate triggers per (subject, checklist) — each table row counts how many distinct questions
4518
+ // hit the recurring threshold for that pair. The streak length on each question is no longer surfaced
4519
+ // in the email body; it remains in the per-question Excel breakdown below.
4520
+ const parseSubmissionDate = ( s ) => {
4521
+ if ( !s ) return 0;
4522
+ const d = dayjs( s, 'hh:mm A, DD MMM YYYY' );
4523
+ return d.isValid() ? d.valueOf() : 0;
4524
+ };
4525
+ const groupMap = new Map();
4526
+ for ( const i of items ) {
4527
+ const k = `${i.subjectId}::${i.checklistId}`;
4528
+ if ( !groupMap.has( k ) ) {
4529
+ groupMap.set( k, {
4530
+ subjectId: i.subjectId,
4531
+ subjectName: i.subjectName,
4532
+ checklistId: i.checklistId,
4533
+ checklistName: i.checklistName,
4534
+ questionCount: 0,
4535
+ runAICount: 0,
4536
+ lastSubmittedBy: i.lastSubmittedBy,
4537
+ lastSubmissionDate: i.lastSubmissionDate,
4538
+ } );
4539
+ }
4540
+ const g = groupMap.get( k );
4541
+ if ( i.sopFired ) g.questionCount += 1;
4542
+ if ( i.runAIFired ) g.runAICount += 1;
4543
+ if ( parseSubmissionDate( i.lastSubmissionDate ) > parseSubmissionDate( g.lastSubmissionDate ) ) {
4544
+ g.lastSubmissionDate = i.lastSubmissionDate;
4545
+ g.lastSubmittedBy = i.lastSubmittedBy;
4546
+ }
4547
+ }
4548
+ const rows = [ ...groupMap.values() ].map( ( g ) => ( {
4549
+ subjectName: g.subjectName,
4550
+ storeName: g.subjectName, // legacy field name still consumed by template fallbacks
4551
+ checklistName: g.checklistName,
4552
+ lastSubmittedBy: g.lastSubmittedBy,
4553
+ lastSubmissionDate: g.lastSubmissionDate,
4554
+ days: g.questionCount, // legacy alias kept for back-compat with older template builds
4555
+ flagCount: g.questionCount,
4556
+ runAICount: g.runAICount,
4557
+ totalFlags: g.questionCount + g.runAICount,
4558
+ } ) );
4559
+
4560
+ // Excel attachment keeps per-question detail (one row per flagged question).
4561
+ const excelRows = items.map( ( i ) => ( {
4562
+ storeName: i.subjectName,
4563
+ checklistName: i.checklistName,
4564
+ sectionName: i.sectionName,
4565
+ questionName: i.qname,
4566
+ lastSubmittedBy: i.lastSubmittedBy,
4567
+ lastSubmissionDate: i.lastSubmissionDate,
4568
+ days: i.days,
4569
+ runAICount: i.runAICount || 0,
4570
+ } ) );
4571
+
4572
+ const ATTACHMENT_THRESHOLD = 10;
4573
+ const hasAttachment = ( isMultiStore || isMultiChecklist ) && rows.length > ATTACHMENT_THRESHOLD;
4574
+ const displayRows = hasAttachment ? rows.slice( 0, ATTACHMENT_THRESHOLD ) : rows;
4575
+
4576
+ const data = {
4577
+ threshold: thresholdShown,
4578
+ isMultiStore,
4579
+ isMultiChecklist,
4580
+ isMultiStoreSingleChecklist,
4581
+ isUserCoverage: isAllUser,
4582
+ showTable: isMultiStore || isMultiChecklist,
4583
+ hasAttachment,
4584
+ domain: flagDomain,
4585
+ rows: displayRows,
4586
+ subjectLabel,
4587
+ subjectLabelPlural,
4588
+ subjectLabelLower,
4589
+ subjectLabelPluralLower,
4590
+ };
4591
+
4592
+ if ( isMultiStore ) {
4593
+ data.highlights = {
4594
+ totalSubjects: subjects.size,
4595
+ totalStores: subjects.size, // legacy alias
4596
+ totalChecklists: checklists.size,
4597
+ totalFlags: items.length,
4598
+ };
4599
+ if ( isMultiStoreSingleChecklist ) {
4600
+ // Show the single checklist name in the intro line for this sub-mode.
4601
+ data.checklistName = items[0].checklistName;
4602
+ }
4603
+ } else if ( isMultiChecklist ) {
4604
+ data.subjectName = items[0].subjectName;
4605
+ data.storeName = items[0].subjectName;
4606
+ } else {
4607
+ // Single mode: one (subject, checklist) — totalFlags = sop question flags + runAI flags.
4608
+ const single = rows[0];
4609
+ data.subjectName = single.subjectName;
4610
+ data.storeName = single.subjectName;
4611
+ data.checklistName = single.checklistName;
4612
+ data.lastSubmittedBy = single.lastSubmittedBy;
4613
+ data.lastSubmissionDate = single.lastSubmissionDate;
4614
+ data.flagCount = single.flagCount;
4615
+ data.runAICount = single.runAICount;
4616
+ data.totalFlags = single.totalFlags;
4617
+ }
4618
+
4619
+ const html = compiled( { data } );
4620
+
4621
+ const params = {
4622
+ toEmail: recipient,
4623
+ mailSubject: 'TangoEye | Recurring Flags Detected',
4624
+ htmlBody: html,
4625
+ attachment: '',
4626
+ sourceEmail: JSON.parse( process.env.SES ).adminEmail,
4627
+ };
4628
+
4629
+ if ( hasAttachment ) {
4630
+ try {
4631
+ const buf = await buildRecurringFlagExcel( excelRows, subjectLabel );
4632
+ params.attachment = {
4633
+ filename: 'Recurring-Flags-Summary.xlsx',
4634
+ content: Buffer.from( buf ),
4635
+ contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
4636
+ };
4637
+ } catch ( e ) {
4638
+ logger.error( { functionName: 'recurringFlagAlert.buildExcel', error: e } );
4639
+ }
4640
+ }
4641
+ sendEmailWithSES( params.toEmail, params.mailSubject, params.htmlBody, params.attachment, params.sourceEmail );
4642
+ sentSummary.push( { recipient, count: items.length, mode: isMultiStore ? 'multi-store' : ( isMultiChecklist ? 'multi-checklist' : 'single' ) } );
4643
+ } ) );
4644
+
4645
+ // Reset only the streak(s) that crossed threshold. Stamp lastEmailDate to the most recent triggering
4646
+ // flag date so dedup remains correct. Append that date to emailHistory (rolling last 60).
4647
+ if ( trackerIdsToReset.length ) {
4648
+ const resetOps = trackerIdsToReset.map( ( r ) => {
4649
+ const set = {};
4650
+ if ( r.sopFired ) set.consecutiveCount = 0;
4651
+ if ( r.runAIFired ) set.runAICount = 0;
4652
+ const stampDate = r.runAIFired ?
4653
+ ( r.lastRunAIFlaggedDate || r.lastFlaggedDate || '' ) :
4654
+ ( r.lastFlaggedDate || r.lastRunAIFlaggedDate || '' );
4655
+ set.lastEmailDate = stampDate;
4656
+ return {
4657
+ updateOne: {
4658
+ filter: { _id: r._id },
4659
+ update: {
4660
+ $set: set,
4661
+ $push: { emailHistory: { $each: [ stampDate ], $slice: -60 } },
4662
+ },
4663
+ },
4664
+ };
4665
+ } );
4666
+ await recurringFlagTracker.bulkWrite( resetOps );
4667
+ }
4668
+
4669
+ return res.sendSuccess( { message: 'Recurring flag emails dispatched', sent: sentSummary } );
4670
+ } catch ( e ) {
4671
+ logger.error( { functionName: 'recurringFlagAlert', error: e } );
4672
+ return res.sendError( e, 500 );
4673
+ }
4674
+ }
4675
+
4676
+ function getLastWeekRange( ref = dayjs() ) {
4677
+ const today = ref.startOf( 'day' );
4678
+ const dow = today.day(); // 0 Sun ... 6 Sat
4679
+ const daysToThisMonday = ( dow + 6 ) % 7; // Mon=0, Tue=1, ..., Sun=6
4680
+ const thisMonday = today.subtract( daysToThisMonday, 'day' );
4681
+ // Current week (Mon..Sun) — primary range surfaced in the email.
4682
+ const weekStart = thisMonday;
4683
+ const weekEnd = thisMonday.add( 6, 'day' );
4684
+ // Previous full week (Mon..Sun) — used for WoW comparisons.
4685
+ const prevWeekStart = thisMonday.subtract( 7, 'day' );
4686
+ const prevWeekEnd = thisMonday.subtract( 1, 'day' );
4687
+ return {
4688
+ weekStart: weekStart.format( 'YYYY-MM-DD' ),
4689
+ weekEnd: weekEnd.format( 'YYYY-MM-DD' ),
4690
+ prevWeekStart: prevWeekStart.format( 'YYYY-MM-DD' ),
4691
+ prevWeekEnd: prevWeekEnd.format( 'YYYY-MM-DD' ),
4692
+ };
4693
+ }
4694
+
4695
+ function computeWow( current, previous ) {
4696
+ if ( !previous ) {
4697
+ if ( current ) return { value: '100%', direction: 'up' };
4698
+ return { value: '', direction: 'up' };
4699
+ }
4700
+ const delta = current - previous;
4701
+ if ( delta === 0 ) return { value: '0%', direction: 'up' };
4702
+ const pct = Math.min( 100, Math.round( ( Math.abs( delta ) / previous ) * 100 ) );
4703
+ return { value: `${pct}%`, direction: delta > 0 ? 'up' : 'down' };
4704
+ }
4705
+
4706
+ async function aggregateWeeklyFlagsByStore( weekStart, weekEnd ) {
4707
+ const rows = await processedchecklist.aggregate( [
4708
+ { $match: { date_string: { $gte: weekStart, $lte: weekEnd }, checkListType: 'custom' } },
4709
+ { $group: {
4710
+ _id: { client_id: '$client_id', store_id: '$store_id' },
4711
+ storeName: { $last: '$storeName' },
4712
+ questionFlag: { $sum: { $ifNull: [ '$questionFlag', 0 ] } },
4713
+ timeFlag: { $sum: { $ifNull: [ '$timeFlag', 0 ] } },
4714
+ runAIFlag: { $sum: { $ifNull: [ '$runAIFlag', 0 ] } },
4715
+ flaggedChecklistIds: { $addToSet: {
4716
+ $cond: [
4717
+ { $or: [
4718
+ { $gt: [ { $ifNull: [ '$questionFlag', 0 ] }, 0 ] },
4719
+ { $gt: [ { $ifNull: [ '$timeFlag', 0 ] }, 0 ] },
4720
+ { $gt: [ { $ifNull: [ '$runAIFlag', 0 ] }, 0 ] },
4721
+ ] },
4722
+ '$sourceCheckList_id',
4723
+ null,
4724
+ ],
4725
+ } },
4726
+ } },
4727
+ ] );
4728
+ const map = new Map();
4729
+ for ( const r of rows ) {
4730
+ const key = `${r._id.client_id}::${r._id.store_id}`;
4731
+ const flaggedChecklists = ( r.flaggedChecklistIds || [] ).filter( ( id ) => id ).map( ( id ) => String( id ) );
4732
+ const totalFlags = ( r.questionFlag || 0 ) + ( r.timeFlag || 0 ) + ( r.runAIFlag || 0 );
4733
+ map.set( key, {
4734
+ client_id: r._id.client_id,
4735
+ store_id: r._id.store_id,
4736
+ storeName: r.storeName,
4737
+ questionFlag: r.questionFlag || 0,
4738
+ timeFlag: r.timeFlag || 0,
4739
+ runAIFlag: r.runAIFlag || 0,
4740
+ flaggedChecklists,
4741
+ totalFlags,
4742
+ } );
4743
+ }
4744
+ return map;
4745
+ }
4746
+
4747
+ async function aggregateWeeklyRecurringByStore( weekStart, weekEnd ) {
4748
+ // Counts how many distinct days within the week the store received a recurring-flag email.
4749
+ // Each tracker doc is per-question, so a single email covering N flagged questions adds the same date
4750
+ // to N tracker docs — we de-dupe by date per store via $addToSet.
4751
+ const rows = await recurringFlagTracker.aggregate( [
4752
+ { $match: { emailHistory: { $elemMatch: { $gte: weekStart, $lte: weekEnd } } } },
4753
+ { $project: {
4754
+ client_id: 1,
4755
+ store_id: 1,
4756
+ storeName: 1,
4757
+ datesInWeek: {
4758
+ $filter: {
4759
+ input: { $ifNull: [ '$emailHistory', [] ] },
4760
+ as: 'd',
4761
+ cond: { $and: [ { $gte: [ '$$d', weekStart ] }, { $lte: [ '$$d', weekEnd ] } ] },
4762
+ },
4763
+ },
4764
+ } },
4765
+ { $unwind: '$datesInWeek' },
4766
+ { $group: {
4767
+ _id: { client_id: '$client_id', store_id: '$store_id' },
4768
+ storeName: { $last: '$storeName' },
4769
+ uniqueDates: { $addToSet: '$datesInWeek' },
4770
+ } },
4771
+ { $project: {
4772
+ _id: 1,
4773
+ storeName: 1,
4774
+ count: { $size: '$uniqueDates' },
4775
+ } },
4776
+ ] );
4777
+ const map = new Map();
4778
+ for ( const r of rows ) {
4779
+ map.set( `${r._id.client_id}::${r._id.store_id}`, {
4780
+ client_id: r._id.client_id,
4781
+ store_id: r._id.store_id,
4782
+ storeName: r.storeName,
4783
+ count: r.count,
4784
+ } );
4785
+ }
4786
+ return map;
4787
+ }
4788
+
4789
+ function buildWeeklyWrapExcel( header, perStoreChecklistRows ) {
4790
+ const workbook = new ExcelJS.Workbook();
4791
+ const sheet = workbook.addWorksheet( 'Weekly Summary' );
4792
+ sheet.addRow( [ header ] );
4793
+ sheet.getRow( 1 ).font = { bold: true, size: 14 };
4794
+ sheet.addRow( [] );
4795
+ sheet.columns = [
4796
+ { header: 'Store Name', key: 'storeName', width: 28 },
4797
+ { header: 'Checklist Name', key: 'checkListName', width: 32 },
4798
+ { header: 'Flags', key: 'flags', width: 12 },
4799
+ { header: 'Question Flags', key: 'questionFlag', width: 16 },
4800
+ { header: 'Not Submitted Flags', key: 'timeFlag', width: 20 },
4801
+ { header: 'Run AI Flags', key: 'runAIFlag', width: 14 },
4802
+ { header: 'Recurring Flags', key: 'recurringFlag', width: 18 },
4803
+ ];
4804
+ sheet.getRow( 3 ).font = { bold: true };
4805
+ sheet.getRow( 3 ).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
4806
+ for ( const r of perStoreChecklistRows ) sheet.addRow( r );
4807
+ return workbook.xlsx.writeBuffer();
4808
+ }
4809
+
4810
+ // Per-(store, checklist) flag aggregation for the weekly Excel attachment. Same source as
4811
+ // aggregateWeeklyFlagsByStore but additionally grouped by sourceCheckList_id so each row in the export
4812
+ // corresponds to one store × one checklist.
4813
+ async function aggregateWeeklyFlagsByStoreChecklist( weekStart, weekEnd ) {
4814
+ const rows = await processedchecklist.aggregate( [
4815
+ { $match: { date_string: { $gte: weekStart, $lte: weekEnd }, isdeleted: { $ne: true } } },
4816
+ { $group: {
4817
+ _id: { client_id: '$client_id', store_id: '$store_id', sourceCheckList_id: '$sourceCheckList_id' },
4818
+ storeName: { $last: '$storeName' },
4819
+ checkListName: { $last: '$checkListName' },
4820
+ questionFlag: { $sum: { $ifNull: [ '$questionFlag', 0 ] } },
4821
+ timeFlag: { $sum: { $ifNull: [ '$timeFlag', 0 ] } },
4822
+ runAIFlag: { $sum: { $ifNull: [ '$runAIFlag', 0 ] } },
4823
+ } },
4824
+ ] );
4825
+ const map = new Map();
4826
+ for ( const r of rows ) {
4827
+ const key = `${r._id.client_id}::${r._id.store_id}::${r._id.sourceCheckList_id}`;
4828
+ map.set( key, {
4829
+ client_id: r._id.client_id,
4830
+ store_id: r._id.store_id,
4831
+ sourceCheckList_id: String( r._id.sourceCheckList_id || '' ),
4832
+ storeName: r.storeName,
4833
+ checkListName: r.checkListName,
4834
+ questionFlag: r.questionFlag || 0,
4835
+ timeFlag: r.timeFlag || 0,
4836
+ runAIFlag: r.runAIFlag || 0,
4837
+ } );
4838
+ }
4839
+ return map;
4840
+ }
4841
+
4842
+ // Per-(store, checklist) recurring count = distinct days the (store, checklist) was emailed in the week.
4843
+ async function aggregateWeeklyRecurringByStoreChecklist( weekStart, weekEnd ) {
4844
+ const rows = await recurringFlagTracker.aggregate( [
4845
+ { $match: { emailHistory: { $elemMatch: { $gte: weekStart, $lte: weekEnd } } } },
4846
+ { $project: {
4847
+ client_id: 1,
4848
+ store_id: 1,
4849
+ storeName: 1,
4850
+ sourceCheckList_id: 1,
4851
+ checkListName: 1,
4852
+ datesInWeek: {
4853
+ $filter: {
4854
+ input: { $ifNull: [ '$emailHistory', [] ] },
4855
+ as: 'd',
4856
+ cond: { $and: [ { $gte: [ '$$d', weekStart ] }, { $lte: [ '$$d', weekEnd ] } ] },
4857
+ },
4858
+ },
4859
+ } },
4860
+ { $unwind: '$datesInWeek' },
4861
+ { $group: {
4862
+ _id: { client_id: '$client_id', store_id: '$store_id', sourceCheckList_id: '$sourceCheckList_id' },
4863
+ storeName: { $last: '$storeName' },
4864
+ checkListName: { $last: '$checkListName' },
4865
+ uniqueDates: { $addToSet: '$datesInWeek' },
4866
+ } },
4867
+ { $project: {
4868
+ _id: 1,
4869
+ storeName: 1,
4870
+ checkListName: 1,
4871
+ count: { $size: '$uniqueDates' },
4872
+ } },
4873
+ ] );
4874
+ const map = new Map();
4875
+ for ( const r of rows ) {
4876
+ const key = `${r._id.client_id}::${r._id.store_id}::${r._id.sourceCheckList_id}`;
4877
+ map.set( key, {
4878
+ client_id: r._id.client_id,
4879
+ store_id: r._id.store_id,
4880
+ sourceCheckList_id: String( r._id.sourceCheckList_id || '' ),
4881
+ storeName: r.storeName,
4882
+ checkListName: r.checkListName,
4883
+ count: r.count,
4884
+ } );
4885
+ }
4886
+ return map;
4887
+ }
4888
+
4889
+ // Resolves the set of storeIds a user can see. Returns:
4890
+ // null — full access (superadmin or non-client userType); caller should not filter
4891
+ // Set<storeId> — restricted access; iterate this user's reachable stores
4892
+ // Mirrors the existing pattern at internalTrax.controller.js ~L4192 (assignedStores + clusters where the
4893
+ // user is a Teamlead + teams the user leads + teams the user is a member of, all expanded to storeIds).
4894
+ async function resolveUserAssignedStores( userDetails ) {
4895
+ if ( !userDetails ) return new Set();
4896
+ if ( userDetails.userType !== 'client' || userDetails.role === 'superadmin' ) {
4897
+ return null;
4898
+ }
4899
+ const storeIds = new Set( ( userDetails.assignedStores || [] ).map( ( s ) => s?.storeId ).filter( Boolean ) );
4900
+
4901
+ const [ leadClusters, leadTeams ] = await Promise.all( [
4902
+ clusterServices.findcluster( { clientId: userDetails.clientId, Teamlead: { $elemMatch: { email: userDetails.email } } } ),
4903
+ teamsServices.findteams( { clientId: userDetails.clientId, Teamlead: { $elemMatch: { email: userDetails.email } } } ),
4904
+ ] );
4905
+
4906
+ for ( const cluster of leadClusters || [] ) {
4907
+ ( cluster.stores || [] ).forEach( ( s ) => s?.storeId && storeIds.add( s.storeId ) );
4908
+ }
4909
+
4910
+ // Teams the user leads — pull each member's assignedStores + clusters where THAT member is Teamlead.
4911
+ for ( const team of leadTeams || [] ) {
4912
+ for ( const member of team.users || [] ) {
4913
+ const memberDetails = await userService.findOne( { _id: member.userId } );
4914
+ if ( memberDetails?.assignedStores?.length ) {
4915
+ memberDetails.assignedStores.forEach( ( s ) => s?.storeId && storeIds.add( s.storeId ) );
4916
+ }
4917
+ if ( memberDetails?.email ) {
4918
+ const memberClusters = await clusterServices.findcluster( { clientId: userDetails.clientId, Teamlead: { $elemMatch: { email: memberDetails.email } } } );
4919
+ for ( const cluster of memberClusters || [] ) {
4920
+ ( cluster.stores || [] ).forEach( ( s ) => s?.storeId && storeIds.add( s.storeId ) );
4921
+ }
4922
+ }
4923
+ }
4924
+ }
4925
+
4926
+ // Teams the user is a member of — pull clusters that reference those teams.
4927
+ const memberTeams = await teamsServices.findteams( { clientId: userDetails.clientId, users: { $elemMatch: { email: userDetails.email } } } );
4928
+ for ( const team of memberTeams || [] ) {
4929
+ const clusters = await clusterServices.findcluster( { clientId: userDetails.clientId, teams: { $elemMatch: { name: team.teamName } } } );
4930
+ for ( const cluster of clusters || [] ) {
4931
+ ( cluster.stores || [] ).forEach( ( s ) => s?.storeId && storeIds.add( s.storeId ) );
4932
+ }
4933
+ }
4934
+
4935
+ // Teams the user leads — also pull clusters that reference those teams.
4936
+ for ( const team of leadTeams || [] ) {
4937
+ const clusters = await clusterServices.findcluster( { clientId: userDetails.clientId, teams: { $elemMatch: { name: team.teamName } } } );
4938
+ for ( const cluster of clusters || [] ) {
4939
+ ( cluster.stores || [] ).forEach( ( s ) => s?.storeId && storeIds.add( s.storeId ) );
4940
+ }
4941
+ }
4942
+
4943
+ return storeIds;
4944
+ }
4945
+
4946
+ export async function weeklyWrapAlert( req, res ) {
4947
+ try {
4948
+ const range = getLastWeekRange();
4949
+ const startDateLabel = dayjs( range.weekStart ).format( 'DD/MM/YY' );
4950
+ const endDateLabel = dayjs( range.weekEnd ).format( 'DD/MM/YY' );
4951
+
4952
+ // Only send to clients that opted in via weeklyFlagEmail. Build the allow-list once.
4953
+ const enabledClients = await clientService.find( { weeklyFlagEmail: true }, { clientId: 1 } );
4954
+ console.log( enabledClients );
4955
+ if ( !enabledClients.length ) {
4956
+ return res.sendSuccess( { message: 'No clients configured for weeklyFlagEmail', sent: [] } );
4957
+ }
4958
+ const enabledClientIds = new Set( enabledClients.map( ( c ) => String( c.clientId ) ) );
4959
+
4960
+ // Aggregate flag data for both weeks across all stores in one shot. The per-(store, checklist)
4961
+ // maps drive the Excel attachment rows; the per-store maps drive the email body's totals + top picks.
4962
+ const [ flagsThis, flagsPrev, recurThis, recurPrev, flagsThisCL, recurThisCL ] = await Promise.all( [
4963
+ aggregateWeeklyFlagsByStore( range.weekStart, range.weekEnd ),
4964
+ aggregateWeeklyFlagsByStore( range.prevWeekStart, range.prevWeekEnd ),
4965
+ aggregateWeeklyRecurringByStore( range.weekStart, range.weekEnd ),
4966
+ aggregateWeeklyRecurringByStore( range.prevWeekStart, range.prevWeekEnd ),
4967
+ aggregateWeeklyFlagsByStoreChecklist( range.weekStart, range.weekEnd ),
4968
+ aggregateWeeklyRecurringByStoreChecklist( range.weekStart, range.weekEnd ),
4969
+ ] );
4970
+
4971
+ const fileContent = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/weeklyWrap.hbs', 'utf8' );
4972
+ const compiled = handlebars.compile( fileContent );
4973
+ const flagDomain = `${JSON.parse( process.env.URL ).domain}/manage/trax/dashboard`;
4974
+ const sentSummary = [];
4975
+
4976
+ // Build one digest per client present in the data, restricted to clients that opted in.
4977
+ const clientIds = new Set();
4978
+ for ( const v of flagsThis.values() ) {
4979
+ if ( enabledClientIds.has( String( v.client_id ) ) ) clientIds.add( v.client_id );
4980
+ }
4981
+ for ( const v of recurThis.values() ) {
4982
+ if ( enabledClientIds.has( String( v.client_id ) ) ) clientIds.add( v.client_id );
4983
+ }
4984
+
4985
+ await Promise.all( [ ...clientIds ].map( async ( clientId ) => {
4986
+ try {
4987
+ // Recipients = admin + superadmin users for this client. Each user's digest is scoped via
4988
+ // resolveUserAssignedStores — superadmin / tango users see everything, regular admins see only
4989
+ // stores they own (assignedStores + clusters they lead + teams they're in/lead).
4990
+ const adminUsers = await userService.find(
4991
+ { clientId: String( clientId ), role: { $in: [ 'admin', 'superadmin' ] } },
4992
+ { email: 1, assignedStores: 1, userName: 1, userType: 1, role: 1, clientId: 1 },
4993
+ );
4994
+ console.log( adminUsers );
4995
+ if ( !adminUsers.length ) {
4996
+ logger.info( `[weeklyWrapAlert] no admin/superadmin users for client ${clientId}, skipping` );
4997
+ return;
4998
+ }
4999
+
5000
+ for ( const user of adminUsers ) {
5001
+ try {
5002
+ if ( !user?.email ) continue;
5003
+ const allowed = await resolveUserAssignedStores( user );
5004
+ // null = full access (superadmin / tango). Empty set = no reachable stores → skip.
5005
+ if ( allowed && allowed.size === 0 ) {
5006
+ logger.info( `[weeklyWrapAlert] user ${user.email} has no reachable stores, skipping` );
5007
+ continue;
5008
+ }
5009
+
5010
+ // Iterate stores belonging to this client; intersect with the user's allowed set when present.
5011
+ const clientPrefix = `${clientId}::`;
5012
+ const isAllowed = ( storeId ) => allowed === null || allowed.has( storeId );
5013
+ const clientStoreKeys = new Set();
5014
+ for ( const k of flagsThis.keys() ) {
5015
+ if ( k.startsWith( clientPrefix ) && isAllowed( k.slice( clientPrefix.length ) ) ) clientStoreKeys.add( k );
5016
+ }
5017
+ for ( const k of flagsPrev.keys() ) {
5018
+ if ( k.startsWith( clientPrefix ) && isAllowed( k.slice( clientPrefix.length ) ) ) clientStoreKeys.add( k );
5019
+ }
5020
+ for ( const k of recurThis.keys() ) {
5021
+ if ( k.startsWith( clientPrefix ) && isAllowed( k.slice( clientPrefix.length ) ) ) clientStoreKeys.add( k );
5022
+ }
5023
+
5024
+ let storesFlagged = 0;
5025
+ let totalFlags = 0;
5026
+ let prevTotalFlags = 0;
5027
+ let prevStoresFlagged = 0;
5028
+ const flaggedChecklistsThis = new Set();
5029
+ const flaggedChecklistsPrev = new Set();
5030
+ let questionFlags = 0;
5031
+ let notSubmittedFlags = 0;
5032
+ let runAIFlags = 0;
5033
+ let recurringFlags = 0;
5034
+ const excelRows = [];
5035
+ // Track top flagged + top recurring within this user's assigned stores only.
5036
+ let topFlag = null;
5037
+ let topRecurring = null;
5038
+
5039
+ for ( const k of clientStoreKeys ) {
5040
+ const cur = flagsThis.get( k );
5041
+ const prev = flagsPrev.get( k );
5042
+ const recCur = recurThis.get( k );
5043
+ const recPrev = recurPrev.get( k );
5044
+ const storeRecurring = recCur?.count || 0;
5045
+ const storeTotal = ( cur?.totalFlags || 0 ) + storeRecurring;
5046
+ const prevStoreTotal = ( prev?.totalFlags || 0 ) + ( recPrev?.count || 0 );
5047
+
5048
+ if ( storeTotal > 0 ) storesFlagged += 1;
5049
+ if ( prevStoreTotal > 0 ) prevStoresFlagged += 1;
5050
+ totalFlags += storeTotal;
5051
+ prevTotalFlags += prevStoreTotal;
5052
+ questionFlags += cur?.questionFlag || 0;
5053
+ notSubmittedFlags += cur?.timeFlag || 0;
5054
+ runAIFlags += cur?.runAIFlag || 0;
5055
+ recurringFlags += storeRecurring;
5056
+
5057
+ ( cur?.flaggedChecklists || [] ).forEach( ( id ) => flaggedChecklistsThis.add( id ) );
5058
+ ( prev?.flaggedChecklists || [] ).forEach( ( id ) => flaggedChecklistsPrev.add( id ) );
5059
+
5060
+ if ( cur && ( !topFlag || ( cur.totalFlags || 0 ) > topFlag.value ) ) {
5061
+ topFlag = { storeId: cur.store_id, storeName: cur.storeName, value: cur.totalFlags || 0 };
5062
+ }
5063
+ if ( recCur && ( !topRecurring || ( recCur.count || 0 ) > topRecurring.value ) ) {
5064
+ topRecurring = { storeId: recCur.store_id, storeName: recCur.storeName, value: recCur.count || 0 };
5065
+ }
5066
+ }
5067
+
5068
+ if ( totalFlags === 0 && prevTotalFlags === 0 ) {
5069
+ continue; // user has nothing to report this week
5070
+ }
5071
+
5072
+ // Build the Excel attachment as one row per (store, checklist) the user can see.
5073
+ // Pulls from the per-checklist flag aggregation + per-checklist recurring email count.
5074
+ const seenStoreChecklist = new Set();
5075
+ for ( const k of flagsThisCL.keys() ) {
5076
+ if ( !k.startsWith( clientPrefix ) ) continue;
5077
+ const cur = flagsThisCL.get( k );
5078
+ if ( !isAllowed( cur.store_id ) ) continue;
5079
+ const recCur = recurThisCL.get( k );
5080
+ const recurringCount = recCur?.count || 0;
5081
+ const flags = ( cur.questionFlag || 0 ) + ( cur.timeFlag || 0 ) + ( cur.runAIFlag || 0 ) + recurringCount;
5082
+ if ( flags === 0 ) continue;
5083
+ excelRows.push( {
5084
+ storeName: cur.storeName || '',
5085
+ checkListName: cur.checkListName || '',
5086
+ flags,
5087
+ questionFlag: cur.questionFlag || 0,
5088
+ timeFlag: cur.timeFlag || 0,
5089
+ runAIFlag: cur.runAIFlag || 0,
5090
+ recurringFlag: recurringCount,
5091
+ } );
5092
+ seenStoreChecklist.add( k );
5093
+ }
5094
+ // (store, checklist) pairs that hit recurring threshold but had no flag aggregation entry.
5095
+ for ( const k of recurThisCL.keys() ) {
5096
+ if ( !k.startsWith( clientPrefix ) ) continue;
5097
+ if ( seenStoreChecklist.has( k ) ) continue;
5098
+ const recCur = recurThisCL.get( k );
5099
+ if ( !isAllowed( recCur.store_id ) ) continue;
5100
+ if ( !recCur.count ) continue;
5101
+ excelRows.push( {
5102
+ storeName: recCur.storeName || '',
5103
+ checkListName: recCur.checkListName || '',
5104
+ flags: recCur.count,
5105
+ questionFlag: 0,
5106
+ timeFlag: 0,
5107
+ runAIFlag: 0,
5108
+ recurringFlag: recCur.count,
5109
+ } );
5110
+ }
5111
+
5112
+ let highestFlaggedStoreWow = { value: '', direction: 'up' };
5113
+ if ( topFlag ) {
5114
+ const prev = flagsPrev.get( `${clientId}::${topFlag.storeId}` );
5115
+ highestFlaggedStoreWow = computeWow( topFlag.value, prev?.totalFlags || 0 );
5116
+ }
5117
+ let highestRecurringStoreWow = { value: '', direction: 'up' };
5118
+ if ( topRecurring ) {
5119
+ const prev = recurPrev.get( `${clientId}::${topRecurring.storeId}` );
5120
+ highestRecurringStoreWow = computeWow( topRecurring.value, prev?.count || 0 );
5121
+ }
5122
+
5123
+ const excelHeader = `Weekly Summary ${startDateLabel} - ${endDateLabel}`;
5124
+ const buf = await buildWeeklyWrapExcel( excelHeader, excelRows );
5125
+ const attachmentBuffer = Buffer.from( buf );
5126
+ const sizeKb = Math.max( 1, Math.round( attachmentBuffer.length / 1024 ) );
5127
+
5128
+ const data = {
5129
+ startDate: startDateLabel,
5130
+ endDate: endDateLabel,
5131
+ storesFlagged,
5132
+ storesFlaggedWow: computeWow( storesFlagged, prevStoresFlagged ),
5133
+ totalFlags,
5134
+ totalFlagsWow: computeWow( totalFlags, prevTotalFlags ),
5135
+ checklistFlags: flaggedChecklistsThis.size,
5136
+ checklistFlagsWow: computeWow( flaggedChecklistsThis.size, flaggedChecklistsPrev.size ),
5137
+ questionFlags,
5138
+ notSubmittedFlags,
5139
+ runAIFlags,
5140
+ recurringFlags,
5141
+ highestFlaggedStore: topFlag?.storeName || topFlag?.storeId || '--',
5142
+ highestFlaggedStoreWow,
5143
+ highestRecurringStore: topRecurring?.storeName || topRecurring?.storeId || '--',
5144
+ highestRecurringStoreWow,
5145
+ attachmentName: `${excelHeader}.xlsx`,
5146
+ attachmentSize: `${sizeKb} KB`,
5147
+ domain: flagDomain,
5148
+ };
5149
+
5150
+ const html = compiled( { data } );
5151
+ const attachment = {
5152
+ filename: `${excelHeader}.xlsx`,
5153
+ content: attachmentBuffer,
5154
+ contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
5155
+ };
5156
+ const sourceEmail = JSON.parse( process.env.SES ).adminEmail;
5157
+ const subject = `TangoEye | Weekly Wrap (${startDateLabel} - ${endDateLabel})`;
5158
+
5159
+ sendEmailWithSES( user.email, subject, html, attachment, sourceEmail );
5160
+ sentSummary.push( { recipient: user.email, clientId, totalFlags } );
5161
+ } catch ( e ) {
5162
+ logger.error( { functionName: 'weeklyWrapAlert.user', clientId, email: user?.email, error: e } );
5163
+ }
5164
+ }
5165
+ } catch ( e ) {
5166
+ logger.error( { functionName: 'weeklyWrapAlert.client', clientId, error: e } );
5167
+ }
5168
+ } ) );
5169
+
5170
+ return res.sendSuccess( { message: 'Weekly wrap dispatched', range, sent: sentSummary } );
5171
+ } catch ( e ) {
5172
+ logger.error( { functionName: 'weeklyWrapAlert', error: e } );
5173
+ return res.sendError( e, 500 );
5174
+ }
5175
+ }
5176
+
5177
+ export async function updateStoreLatLong( req, res ) {
5178
+ try {
5179
+ const defaultStores = [];
5180
+
5181
+ const list = Array.isArray( req.body?.stores ) && req.body.stores.length ? req.body.stores : defaultStores;
5182
+
5183
+ const updated = [];
5184
+ const unchanged = [];
5185
+ const notFound = [];
5186
+ const invalid = [];
5187
+
5188
+ for ( const item of list ) {
5189
+ const storeName = item?.storeName;
5190
+ const latitude = parseFloat( item?.lat );
5191
+ const longitude = parseFloat( item?.long );
5192
+
5193
+ if ( !storeName || Number.isNaN( latitude ) || Number.isNaN( longitude ) ) {
5194
+ invalid.push( storeName || '<missing>' );
5195
+ continue;
5196
+ }
5197
+
5198
+ const result = await storeService.updateOne(
5199
+ { storeName, clientId: '467' },
5200
+ { 'storeProfile.latitude': latitude, 'storeProfile.longitude': longitude },
5201
+ );
5202
+
5203
+ if ( result.matchedCount === 0 ) {
5204
+ notFound.push( storeName );
5205
+ } else if ( result.modifiedCount === 0 ) {
5206
+ unchanged.push( storeName );
5207
+ } else {
5208
+ updated.push( storeName );
5209
+ }
5210
+ }
5211
+
5212
+ logger.info( {
5213
+ functionName: 'updateStoreLatLong',
5214
+ summary: { updated: updated.length, unchanged: unchanged.length, notFound: notFound.length, invalid: invalid.length },
5215
+ } );
5216
+
5217
+ return res.sendSuccess( {
5218
+ message: 'Store lat/long update complete',
5219
+ updated,
5220
+ unchanged,
5221
+ notFound,
5222
+ invalid,
5223
+ } );
5224
+ } catch ( e ) {
5225
+ logger.error( { functionName: 'updateStoreLatLong', error: e } );
5226
+ return res.sendError( e, 500 );
5227
+ }
5228
+ }
5229
+