tango-app-api-trax 3.8.21 → 3.8.22-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.
@@ -325,7 +325,7 @@ export async function PCLconfigCreation( req, res ) {
325
325
  },
326
326
  } );
327
327
  let getSections = await CLquestions.aggregate( sectionQuery );
328
- 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 ) ) {
329
329
  if ( getSections.length ) {
330
330
  for ( let element3 of getSections ) {
331
331
  let collectQuestions = {};
@@ -650,11 +650,11 @@ export async function PCLconfigCreation( req, res ) {
650
650
  // }
651
651
  }
652
652
  } else {
653
- 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 ) ) {
654
654
  let storeNameList = allQuestion.map( ( item ) => item.store_id );
655
- 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 } );
656
656
  let storeList = storeDetails.map( ( store ) => store.storeId );
657
- 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 ) ) {
658
658
  allQuestion = allQuestion.filter( ( ele ) => storeList.includes( ele?.store_id ) );
659
659
  } else {
660
660
  allQuestion = storeDetails.map( ( item ) => {
@@ -688,7 +688,7 @@ export async function PCLconfigCreation( req, res ) {
688
688
  client_id: getCLconfig.client_id,
689
689
  aiStoreList: allQuestion.length ? allQuestion.map( ( store ) => store.store_id ) : [],
690
690
  };
691
- 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 ) ) {
692
692
  let processData = {
693
693
  aiStoreList: allQuestion.length ? allQuestion.map( ( store ) => {
694
694
  return { storeName: store.storeName, storeId: store.store_id, events: store.events };
@@ -929,7 +929,7 @@ async function insertData( requestData ) {
929
929
  },
930
930
  } );
931
931
  let getSections = await CLquestions.aggregate( sectionQuery );
932
- 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 ) ) {
933
933
  if ( getSections.length ) {
934
934
  for ( let element3 of getSections ) {
935
935
  let collectQuestions = {};
@@ -1224,11 +1224,11 @@ async function insertData( requestData ) {
1224
1224
  // }
1225
1225
  }
1226
1226
  } else {
1227
- 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 ) ) {
1228
1228
  let storeNameList = allQuestion.map( ( item ) => item.store_id );
1229
- 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 } );
1230
1230
  let storeList = storeDetails.map( ( store ) => store.storeId );
1231
- 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 ) ) {
1232
1232
  allQuestion = allQuestion.filter( ( ele ) => storeList.includes( ele?.store_id ) );
1233
1233
  } else {
1234
1234
  allQuestion = storeDetails.map( ( item ) => {
@@ -1262,7 +1262,7 @@ async function insertData( requestData ) {
1262
1262
  client_id: getCLconfig.client_id,
1263
1263
  aiStoreList: allQuestion.length ? allQuestion.map( ( store ) => store.store_id ) : [],
1264
1264
  };
1265
- 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 ) ) {
1266
1266
  let processData = {
1267
1267
  aiStoreList: allQuestion.length ? allQuestion.map( ( store ) => {
1268
1268
  return { storeName: store.storeName, storeId: store.store_id, events: store.events };
@@ -2119,7 +2119,6 @@ export async function getPDFCSVChecklistDetails( req, res ) {
2119
2119
  }
2120
2120
  }
2121
2121
 
2122
-
2123
2122
  export async function AiPushNotificationAlert( req, res ) {
2124
2123
  try {
2125
2124
  // console.log( req.body );
@@ -2176,7 +2175,6 @@ export async function liveAiPushNotificationAlert( req, res ) {
2176
2175
  }
2177
2176
  }
2178
2177
 
2179
-
2180
2178
  export async function taskPushNotification( req, res ) {
2181
2179
  try {
2182
2180
  let query = [ {
@@ -2645,6 +2643,9 @@ export async function updateRunAI( req, res ) {
2645
2643
  if ( !req.body.qName ) {
2646
2644
  return res.sendError( 'Question name is required', 400 );
2647
2645
  }
2646
+ if ( !req.body.userAnswer ) {
2647
+ return res.sendError( 'answer is required', 400 );
2648
+ }
2648
2649
  let getDetails = await processedchecklist.findOne( { _id: req.body.id } );
2649
2650
  if ( !getDetails ) {
2650
2651
  return res.sendError( 'No data found', 204 );
@@ -2653,7 +2654,7 @@ export async function updateRunAI( req, res ) {
2653
2654
  let updateData = {};
2654
2655
 
2655
2656
  for ( let k of Object.keys( req.body.data ) ) {
2656
- let keyPath = `questionAnswers.$[section].questions.$[question].userAnswer.0.${k}`;
2657
+ let keyPath = `questionAnswers.$[section].questions.$[question].userAnswer.$[userAnswer].${k}`;
2657
2658
  updateData[keyPath] = req.body.data[k];
2658
2659
  }
2659
2660
 
@@ -2664,7 +2665,9 @@ export async function updateRunAI( req, res ) {
2664
2665
  arrayFilters: [
2665
2666
  { 'section.section_id': new ObjectId( req.body.sectionId ) },
2666
2667
  { 'question.qname': req.body.qName },
2668
+ { 'userAnswer.answer': req.body.userAnswer },
2667
2669
  ],
2670
+ strict: false,
2668
2671
  },
2669
2672
  );
2670
2673
  return res.sendSuccess( 'RunAI details updated successfully' );
@@ -2699,6 +2702,113 @@ export async function countUpdateRunAI( req, res ) {
2699
2702
  }
2700
2703
  }
2701
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
+
2702
2812
  export async function getRunAIQuestions( req, res ) {
2703
2813
  try {
2704
2814
  let requestData = req.body;
@@ -3749,7 +3859,6 @@ export async function checklistAutoMailList( req, res ) {
3749
3859
  }
3750
3860
  }
3751
3861
 
3752
-
3753
3862
  export const downloadInsertPdfOld = async ( req, res ) => {
3754
3863
  try {
3755
3864
  setImmediate( async () => {
@@ -4227,17 +4336,18 @@ export async function getEyetestStream( req, res ) {
4227
4336
  }
4228
4337
  }
4229
4338
 
4230
- function buildRecurringFlagExcel( rows ) {
4339
+ function buildRecurringFlagExcel( rows, subjectLabel = 'Store' ) {
4231
4340
  const workbook = new ExcelJS.Workbook();
4232
4341
  const sheet = workbook.addWorksheet( 'Recurring Flags' );
4233
4342
  sheet.columns = [
4234
- { header: 'Store Name', key: 'storeName', width: 25 },
4343
+ { header: `${subjectLabel} Name`, key: 'storeName', width: 25 },
4235
4344
  { header: 'Checklist Name', key: 'checklistName', width: 30 },
4236
4345
  { header: 'Section', key: 'sectionName', width: 25 },
4237
4346
  { header: 'Question', key: 'questionName', width: 40 },
4238
4347
  { header: 'Last Submitted By', key: 'lastSubmittedBy', width: 25 },
4239
4348
  { header: 'Last Submission Date', key: 'lastSubmissionDate', width: 22 },
4240
4349
  { header: 'Recurring Days', key: 'days', width: 16 },
4350
+ { header: 'Run AI Flags', key: 'runAICount', width: 14 },
4241
4351
  ];
4242
4352
  sheet.getRow( 1 ).font = { bold: true };
4243
4353
  rows.forEach( ( r ) => sheet.addRow( r ) );
@@ -4249,13 +4359,12 @@ export async function recurringFlagAlert( req, res ) {
4249
4359
  const checklistDetails = await CLconfig.find( {
4250
4360
  publish: true,
4251
4361
  $expr: {
4252
- $gt: [
4253
- { $size: { $cond: [ { $isArray: '$recurringFlag.users' }, '$recurringFlag.users', [] ] } },
4254
- 0,
4362
+ $or: [
4363
+ { $gt: [ { $size: { $cond: [ { $isArray: '$recurringFlag.users' }, '$recurringFlag.users', [] ] } }, 0 ] },
4364
+ { $gt: [ { $size: { $cond: [ { $isArray: '$recurringFlag.notifyType' }, '$recurringFlag.notifyType', [] ] } }, 0 ] },
4255
4365
  ],
4256
4366
  },
4257
- }, { _id: 1, checkListName: 1, recurringFlag: 1, approver: 1, client_id: 1 } );
4258
-
4367
+ }, { _id: 1, checkListName: 1, recurringFlag: 1, notifyFlags: 1, approver: 1, client_id: 1 } );
4259
4368
  if ( !checklistDetails.length ) {
4260
4369
  return res.sendSuccess( 'No checklists configured for recurring flag' );
4261
4370
  }
@@ -4268,42 +4377,103 @@ export async function recurringFlagAlert( req, res ) {
4268
4377
  const threshold = cl?.recurringFlag?.threshold || 3;
4269
4378
  const notifyType = cl?.recurringFlag?.notifyType || [];
4270
4379
  const users = cl?.recurringFlag?.users || [];
4271
-
4272
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
+ }
4273
4389
  if ( notifyType.includes( 'approver' ) && Array.isArray( cl.approver ) ) {
4274
- recipients = cl.approver.map( ( a ) => a?.value ).filter( Boolean );
4390
+ recipients = [ ...recipients, ...cl.approver.map( ( a ) => a?.value ).filter( Boolean ) ];
4275
4391
  }
4276
4392
  recipients = [ ...recipients, ...users.map( ( u ) => u?.value ).filter( Boolean ) ];
4277
4393
  recipients = [ ...new Set( recipients ) ];
4278
4394
 
4279
4395
  if ( !recipients.length ) return;
4280
4396
 
4281
- // Read tracker rows that have hit threshold and have not yet been emailed for the current streak.
4282
- // Submit-time updates already maintained consecutiveCount + lastFlaggedDate per (store, section, qno).
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).
4283
4413
  const trackerRows = await recurringFlagTracker.find( {
4284
4414
  sourceCheckList_id: cl._id,
4285
- consecutiveCount: { $gte: threshold },
4286
- $expr: { $ne: [ { $ifNull: [ '$lastEmailDate', '' ] }, { $ifNull: [ '$lastFlaggedDate', '' ] } ] },
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
+ ],
4287
4425
  } );
4288
4426
 
4289
4427
  for ( const t of trackerRows ) {
4290
- for ( const recipient of recipients ) {
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;
4291
4441
  triggers.push( {
4292
4442
  recipient,
4293
4443
  clientId: t.client_id,
4294
- storeId: t.store_id,
4295
- storeName: t.storeName,
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 || '',
4296
4452
  checklistId: cl._id.toString(),
4297
4453
  checklistName: cl.checkListName?.trim() || t.checkListName || '',
4298
4454
  sectionName: t.sectionName,
4299
4455
  qno: t.qno,
4300
4456
  qname: t.qname,
4301
4457
  days: t.consecutiveCount,
4458
+ runAICount: t.runAICount || 0,
4459
+ sopFired,
4460
+ runAIFired,
4302
4461
  lastSubmittedBy: t.lastSubmittedBy || '--',
4303
4462
  lastSubmissionDate: t.lastSubmissionDate || t.lastFlaggedDate || '',
4304
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
+ } );
4305
4476
  }
4306
- trackerIdsToReset.push( { _id: t._id, lastFlaggedDate: t.lastFlaggedDate } );
4307
4477
  }
4308
4478
  } ) );
4309
4479
 
@@ -4311,6 +4481,7 @@ export async function recurringFlagAlert( req, res ) {
4311
4481
  return res.sendSuccess( 'No recurring flags reached threshold' );
4312
4482
  }
4313
4483
 
4484
+
4314
4485
  // Group triggers by recipient.
4315
4486
  const byRecipient = new Map();
4316
4487
  for ( const t of triggers ) {
@@ -4325,21 +4496,77 @@ export async function recurringFlagAlert( req, res ) {
4325
4496
  const sentSummary = [];
4326
4497
 
4327
4498
  await Promise.all( [ ...byRecipient.entries() ].map( async ( [ recipient, items ] ) => {
4328
- const stores = new Set( items.map( ( i ) => i.storeId ) );
4499
+ const subjects = new Set( items.map( ( i ) => i.subjectId ) );
4329
4500
  const checklists = new Set( items.map( ( i ) => i.checklistId ) );
4330
- const isMultiStore = stores.size > 1;
4501
+ const isMultiStore = subjects.size > 1; // "isMultiStore" name retained for template back-compat
4331
4502
  const isMultiChecklist = !isMultiStore && checklists.size > 1;
4332
- // Threshold for the message line when grouping spans multiple checklists/stores, take min threshold seen.
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.
4333
4507
  const thresholdShown = items.reduce( ( acc, it ) => Math.min( acc, it.days ), items[0].days );
4334
4508
 
4335
- const rows = items.map( ( i ) => ( {
4336
- storeName: i.storeName,
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,
4337
4563
  checklistName: i.checklistName,
4338
4564
  sectionName: i.sectionName,
4339
4565
  questionName: i.qname,
4340
4566
  lastSubmittedBy: i.lastSubmittedBy,
4341
4567
  lastSubmissionDate: i.lastSubmissionDate,
4342
4568
  days: i.days,
4569
+ runAICount: i.runAICount || 0,
4343
4570
  } ) );
4344
4571
 
4345
4572
  const ATTACHMENT_THRESHOLD = 10;
@@ -4350,31 +4577,43 @@ export async function recurringFlagAlert( req, res ) {
4350
4577
  threshold: thresholdShown,
4351
4578
  isMultiStore,
4352
4579
  isMultiChecklist,
4580
+ isMultiStoreSingleChecklist,
4581
+ isUserCoverage: isAllUser,
4353
4582
  showTable: isMultiStore || isMultiChecklist,
4354
4583
  hasAttachment,
4355
4584
  domain: flagDomain,
4356
4585
  rows: displayRows,
4586
+ subjectLabel,
4587
+ subjectLabelPlural,
4588
+ subjectLabelLower,
4589
+ subjectLabelPluralLower,
4357
4590
  };
4358
4591
 
4359
4592
  if ( isMultiStore ) {
4360
4593
  data.highlights = {
4361
- totalStores: stores.size,
4594
+ totalSubjects: subjects.size,
4595
+ totalStores: subjects.size, // legacy alias
4362
4596
  totalChecklists: checklists.size,
4363
4597
  totalFlags: items.length,
4364
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
+ }
4365
4603
  } else if ( isMultiChecklist ) {
4366
- data.storeName = items[0].storeName;
4604
+ data.subjectName = items[0].subjectName;
4605
+ data.storeName = items[0].subjectName;
4367
4606
  } else {
4368
- const single = items[0];
4369
- data.storeName = single.storeName;
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;
4370
4611
  data.checklistName = single.checklistName;
4371
- data.questionName = single.qname;
4372
4612
  data.lastSubmittedBy = single.lastSubmittedBy;
4373
4613
  data.lastSubmissionDate = single.lastSubmissionDate;
4374
- data.days = single.days;
4375
- data.daysPlural = single.days > 1;
4376
- data.flagCount = 1;
4377
- data.flagCountPlural = false;
4614
+ data.flagCount = single.flagCount;
4615
+ data.runAICount = single.runAICount;
4616
+ data.totalFlags = single.totalFlags;
4378
4617
  }
4379
4618
 
4380
4619
  const html = compiled( { data } );
@@ -4389,7 +4628,7 @@ export async function recurringFlagAlert( req, res ) {
4389
4628
 
4390
4629
  if ( hasAttachment ) {
4391
4630
  try {
4392
- const buf = await buildRecurringFlagExcel( rows );
4631
+ const buf = await buildRecurringFlagExcel( excelRows, subjectLabel );
4393
4632
  params.attachment = {
4394
4633
  filename: 'Recurring-Flags-Summary.xlsx',
4395
4634
  content: Buffer.from( buf ),
@@ -4399,19 +4638,31 @@ export async function recurringFlagAlert( req, res ) {
4399
4638
  logger.error( { functionName: 'recurringFlagAlert.buildExcel', error: e } );
4400
4639
  }
4401
4640
  }
4402
-
4403
4641
  sendEmailWithSES( params.toEmail, params.mailSubject, params.htmlBody, params.attachment, params.sourceEmail );
4404
4642
  sentSummary.push( { recipient, count: items.length, mode: isMultiStore ? 'multi-store' : ( isMultiChecklist ? 'multi-checklist' : 'single' ) } );
4405
4643
  } ) );
4406
4644
 
4407
- // Reset counters and stamp lastEmailDate for all triggered tracker rows so the next streak starts fresh.
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).
4408
4647
  if ( trackerIdsToReset.length ) {
4409
- const resetOps = trackerIdsToReset.map( ( { _id, lastFlaggedDate } ) => ( {
4410
- updateOne: {
4411
- filter: { _id },
4412
- update: { $set: { consecutiveCount: 0, lastEmailDate: lastFlaggedDate } },
4413
- },
4414
- } ) );
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
+ } );
4415
4666
  await recurringFlagTracker.bulkWrite( resetOps );
4416
4667
  }
4417
4668
 
@@ -4422,41 +4673,510 @@ export async function recurringFlagAlert( req, res ) {
4422
4673
  }
4423
4674
  }
4424
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
+
4425
5177
  export async function updateStoreLatLong( req, res ) {
4426
5178
  try {
4427
- const defaultStores = [
4428
- { 'storeName': 'OFLBTM', 'lat': 12.9059052, 'long': 77.6057203 },
4429
- { 'storeName': 'OFLRRN', 'lat': 12.9096852, 'long': 77.5135813 },
4430
- { 'storeName': 'OFLGUN', 'lat': 12.9286282, 'long': 77.738055 },
4431
- { 'storeName': 'OFLJPN', 'lat': 12.8915891, 'long': 77.5776564 },
4432
- { 'storeName': 'OFLAECS', 'lat': 12.9638564, 'long': 77.7125467 },
4433
- { 'storeName': 'Seegehalli', 'lat': 13.0083064, 'long': 77.7588426 },
4434
- { 'storeName': 'OFLJPNS', 'lat': 12.8695409, 'long': 77.5820004 },
4435
- { 'storeName': 'OFLHAR', 'lat': 12.9136122, 'long': 77.6649999 },
4436
- { 'storeName': 'OFLKSNR', 'lat': 13.0051196, 'long': 77.6601987 },
4437
- { 'storeName': 'Hosa Road', 'lat': 12.8792369, 'long': 77.6721843 },
4438
- { 'storeName': 'OFLDEV', 'lat': 12.8942057, 'long': 77.6019696 },
4439
- { 'storeName': 'OFLAYN', 'lat': 12.958161, 'long': 77.570055 },
4440
- { 'storeName': 'OFLKAG', 'lat': 12.9845196, 'long': 77.6758945 },
4441
- { 'storeName': 'OFLBVR', 'lat': 12.9858428, 'long': 77.5424725 },
4442
- { 'storeName': 'OFLMTK', 'lat': 13.0279313, 'long': 77.5587934 },
4443
- { 'storeName': 'OFLMAG', 'lat': 12.9837929, 'long': 77.5325324 },
4444
- { 'storeName': 'OFLBEL', 'lat': 13.0370029, 'long': 77.5622911 },
4445
- { 'storeName': 'OFLKDU', 'lat': 12.8835726, 'long': 77.517709 },
4446
- { 'storeName': 'OFLAMLI', 'lat': 13.0685032, 'long': 77.5972815 },
4447
- { 'storeName': 'OFLSAN', 'lat': 19.0603798, 'long': 73.0041633 },
4448
- { 'storeName': 'OFLLOK', 'lat': 19.1471544, 'long': 72.5405682 },
4449
- { 'storeName': 'OFLTSTG', 'lat': 19.2471119, 'long': 72.9769107 },
4450
- { 'storeName': 'OFLKHGRS', 'lat': 19.0583611, 'long': 73.0584353 },
4451
- { 'storeName': 'OFLMAL', 'lat': 12.9673877, 'long': 77.499519 },
4452
- { 'storeName': 'OFLBAG', 'lat': 13.1218427, 'long': 77.6234015 },
4453
- { 'storeName': 'OFLYEL', 'lat': 13.114551, 'long': 77.5401312 },
4454
- { 'storeName': 'OFLTCP', 'lat': 13.0240695, 'long': 77.6928401 },
4455
- { 'storeName': 'OFLECTN', 'lat': 12.8185795, 'long': 77.6520784 },
4456
- { 'storeName': 'OFLHBL', 'lat': 13.0559812, 'long': 77.593941 },
4457
- { 'storeName': 'OFLBWR', 'lat': 12.9691559, 'long': 77.739599 },
4458
- { 'storeName': 'OFLHSR', 'lat': 12.9183666, 'long': 77.5793797 },
4459
- ];
5179
+ const defaultStores = [];
4460
5180
 
4461
5181
  const list = Array.isArray( req.body?.stores ) && req.body.stores.length ? req.body.stores : defaultStores;
4462
5182
 
@@ -4506,3 +5226,4 @@ export async function updateStoreLatLong( req, res ) {
4506
5226
  return res.sendError( e, 500 );
4507
5227
  }
4508
5228
  }
5229
+