tango-app-api-trax 3.8.26 → 3.8.27-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 };
@@ -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' );
@@ -4389,6 +4394,20 @@ export async function recurringFlagAlert( req, res ) {
4389
4394
 
4390
4395
  if ( !recipients.length ) return;
4391
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
+
4392
4411
  // Read tracker rows where EITHER the sop streak or the runAI count has hit threshold and the
4393
4412
  // current trigger hasn't been emailed yet (lastEmailDate vs the relevant flagged date).
4394
4413
  const trackerRows = await recurringFlagTracker.find( {
@@ -4413,7 +4432,12 @@ export async function recurringFlagAlert( req, res ) {
4413
4432
  // Determine which streak crossed threshold for this row — drives reset granularity below.
4414
4433
  const sopFired = ( t.consecutiveCount || 0 ) >= threshold && t.lastEmailDate !== t.lastFlaggedDate;
4415
4434
  const runAIFired = ( t.runAICount || 0 ) >= threshold && t.lastEmailDate !== t.lastRunAIFlaggedDate;
4416
- for ( const recipient of recipients ) {
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;
4417
4441
  triggers.push( {
4418
4442
  recipient,
4419
4443
  clientId: t.client_id,
@@ -4437,14 +4461,19 @@ export async function recurringFlagAlert( req, res ) {
4437
4461
  lastSubmittedBy: t.lastSubmittedBy || '--',
4438
4462
  lastSubmissionDate: t.lastSubmissionDate || t.lastFlaggedDate || '',
4439
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
+ } );
4440
4476
  }
4441
- trackerIdsToReset.push( {
4442
- _id: t._id,
4443
- lastFlaggedDate: t.lastFlaggedDate,
4444
- lastRunAIFlaggedDate: t.lastRunAIFlaggedDate,
4445
- sopFired,
4446
- runAIFired,
4447
- } );
4448
4477
  }
4449
4478
  } ) );
4450
4479
 
@@ -4509,8 +4538,8 @@ export async function recurringFlagAlert( req, res ) {
4509
4538
  } );
4510
4539
  }
4511
4540
  const g = groupMap.get( k );
4512
- g.questionCount += 1;
4513
- g.runAICount += ( i.runAICount || 0 );
4541
+ if ( i.sopFired ) g.questionCount += 1;
4542
+ if ( i.runAIFired ) g.runAICount += 1;
4514
4543
  if ( parseSubmissionDate( i.lastSubmissionDate ) > parseSubmissionDate( g.lastSubmissionDate ) ) {
4515
4544
  g.lastSubmissionDate = i.lastSubmissionDate;
4516
4545
  g.lastSubmittedBy = i.lastSubmittedBy;
@@ -4549,6 +4578,7 @@ export async function recurringFlagAlert( req, res ) {
4549
4578
  isMultiStore,
4550
4579
  isMultiChecklist,
4551
4580
  isMultiStoreSingleChecklist,
4581
+ isUserCoverage: isAllUser,
4552
4582
  showTable: isMultiStore || isMultiChecklist,
4553
4583
  hasAttachment,
4554
4584
  domain: flagDomain,
@@ -4648,10 +4678,12 @@ function getLastWeekRange( ref = dayjs() ) {
4648
4678
  const dow = today.day(); // 0 Sun ... 6 Sat
4649
4679
  const daysToThisMonday = ( dow + 6 ) % 7; // Mon=0, Tue=1, ..., Sun=6
4650
4680
  const thisMonday = today.subtract( daysToThisMonday, 'day' );
4651
- const weekStart = thisMonday.subtract( 7, 'day' );
4652
- const weekEnd = thisMonday.subtract( 1, 'day' );
4653
- const prevWeekStart = weekStart.subtract( 7, 'day' );
4654
- const prevWeekEnd = weekStart.subtract( 1, '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' );
4655
4687
  return {
4656
4688
  weekStart: weekStart.format( 'YYYY-MM-DD' ),
4657
4689
  weekEnd: weekEnd.format( 'YYYY-MM-DD' ),
@@ -4673,7 +4705,7 @@ function computeWow( current, previous ) {
4673
4705
 
4674
4706
  async function aggregateWeeklyFlagsByStore( weekStart, weekEnd ) {
4675
4707
  const rows = await processedchecklist.aggregate( [
4676
- { $match: { date_string: { $gte: weekStart, $lte: weekEnd }, isdeleted: { $ne: true } } },
4708
+ { $match: { date_string: { $gte: weekStart, $lte: weekEnd }, checkListType: 'custom' } },
4677
4709
  { $group: {
4678
4710
  _id: { client_id: '$client_id', store_id: '$store_id' },
4679
4711
  storeName: { $last: '$storeName' },
@@ -4713,26 +4745,33 @@ async function aggregateWeeklyFlagsByStore( weekStart, weekEnd ) {
4713
4745
  }
4714
4746
 
4715
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.
4716
4751
  const rows = await recurringFlagTracker.aggregate( [
4717
4752
  { $match: { emailHistory: { $elemMatch: { $gte: weekStart, $lte: weekEnd } } } },
4718
4753
  { $project: {
4719
4754
  client_id: 1,
4720
4755
  store_id: 1,
4721
4756
  storeName: 1,
4722
- firedInWeek: {
4723
- $size: {
4724
- $filter: {
4725
- input: { $ifNull: [ '$emailHistory', [] ] },
4726
- as: 'd',
4727
- cond: { $and: [ { $gte: [ '$$d', weekStart ] }, { $lte: [ '$$d', weekEnd ] } ] },
4728
- },
4757
+ datesInWeek: {
4758
+ $filter: {
4759
+ input: { $ifNull: [ '$emailHistory', [] ] },
4760
+ as: 'd',
4761
+ cond: { $and: [ { $gte: [ '$$d', weekStart ] }, { $lte: [ '$$d', weekEnd ] } ] },
4729
4762
  },
4730
4763
  },
4731
4764
  } },
4765
+ { $unwind: '$datesInWeek' },
4732
4766
  { $group: {
4733
4767
  _id: { client_id: '$client_id', store_id: '$store_id' },
4734
4768
  storeName: { $last: '$storeName' },
4735
- count: { $sum: '$firedInWeek' },
4769
+ uniqueDates: { $addToSet: '$datesInWeek' },
4770
+ } },
4771
+ { $project: {
4772
+ _id: 1,
4773
+ storeName: 1,
4774
+ count: { $size: '$uniqueDates' },
4736
4775
  } },
4737
4776
  ] );
4738
4777
  const map = new Map();
@@ -4747,180 +4786,382 @@ async function aggregateWeeklyRecurringByStore( weekStart, weekEnd ) {
4747
4786
  return map;
4748
4787
  }
4749
4788
 
4750
- function buildWeeklyWrapExcel( header, perStoreRows ) {
4789
+ function buildWeeklyWrapExcel( header, perStoreChecklistRows ) {
4751
4790
  const workbook = new ExcelJS.Workbook();
4752
4791
  const sheet = workbook.addWorksheet( 'Weekly Summary' );
4753
4792
  sheet.addRow( [ header ] );
4754
4793
  sheet.getRow( 1 ).font = { bold: true, size: 14 };
4755
4794
  sheet.addRow( [] );
4756
4795
  sheet.columns = [
4757
- { header: 'Store ID', key: 'storeId', width: 18 },
4758
4796
  { header: 'Store Name', key: 'storeName', width: 28 },
4797
+ { header: 'Checklist Name', key: 'checkListName', width: 32 },
4798
+ { header: 'Flags', key: 'flags', width: 12 },
4759
4799
  { header: 'Question Flags', key: 'questionFlag', width: 16 },
4760
- { header: 'Not Submitted', key: 'timeFlag', width: 16 },
4761
- { header: 'Run AI Flags', key: 'runAIFlag', width: 16 },
4800
+ { header: 'Not Submitted Flags', key: 'timeFlag', width: 20 },
4801
+ { header: 'Run AI Flags', key: 'runAIFlag', width: 14 },
4762
4802
  { header: 'Recurring Flags', key: 'recurringFlag', width: 18 },
4763
- { header: 'Total Flags', key: 'totalFlags', width: 14 },
4764
- { header: 'Last Week Total', key: 'prevTotal', width: 18 },
4765
- { header: 'WoW %', key: 'wow', width: 12 },
4766
4803
  ];
4767
4804
  sheet.getRow( 3 ).font = { bold: true };
4768
4805
  sheet.getRow( 3 ).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
4769
- for ( const r of perStoreRows ) sheet.addRow( r );
4806
+ for ( const r of perStoreChecklistRows ) sheet.addRow( r );
4770
4807
  return workbook.xlsx.writeBuffer();
4771
4808
  }
4772
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
+
4773
4946
  export async function weeklyWrapAlert( req, res ) {
4774
4947
  try {
4775
4948
  const range = getLastWeekRange();
4776
4949
  const startDateLabel = dayjs( range.weekStart ).format( 'DD/MM/YY' );
4777
4950
  const endDateLabel = dayjs( range.weekEnd ).format( 'DD/MM/YY' );
4778
4951
 
4779
- // TEMP: static recipient for dev. Replace with client-config lookup later.
4780
- const STATIC_RECIPIENT = 'gopisjkg@gmail.com';
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 ) ) );
4781
4959
 
4782
- // Aggregate flag data for both weeks across all stores in one shot.
4783
- const [ flagsThis, flagsPrev, recurThis, recurPrev ] = await Promise.all( [
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( [
4784
4963
  aggregateWeeklyFlagsByStore( range.weekStart, range.weekEnd ),
4785
4964
  aggregateWeeklyFlagsByStore( range.prevWeekStart, range.prevWeekEnd ),
4786
4965
  aggregateWeeklyRecurringByStore( range.weekStart, range.weekEnd ),
4787
4966
  aggregateWeeklyRecurringByStore( range.prevWeekStart, range.prevWeekEnd ),
4967
+ aggregateWeeklyFlagsByStoreChecklist( range.weekStart, range.weekEnd ),
4968
+ aggregateWeeklyRecurringByStoreChecklist( range.weekStart, range.weekEnd ),
4788
4969
  ] );
4789
4970
 
4790
- // Compute per-client highest flagged store and highest recurring flagged store (this week).
4791
- const topByClient = new Map(); // clientId -> { topFlag, topRecurring }
4792
- const upsertTop = ( client, key, candidate ) => {
4793
- if ( !topByClient.has( client ) ) topByClient.set( client, { topFlag: null, topRecurring: null } );
4794
- const entry = topByClient.get( client );
4795
- if ( !entry[key] || candidate.value > entry[key].value ) entry[key] = candidate;
4796
- };
4797
- for ( const v of flagsThis.values() ) {
4798
- upsertTop( v.client_id, 'topFlag', { storeId: v.store_id, storeName: v.storeName, value: v.totalFlags } );
4799
- }
4800
- for ( const v of recurThis.values() ) {
4801
- upsertTop( v.client_id, 'topRecurring', { storeId: v.store_id, storeName: v.storeName, value: v.count } );
4802
- }
4803
-
4804
4971
  const fileContent = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/weeklyWrap.hbs', 'utf8' );
4805
4972
  const compiled = handlebars.compile( fileContent );
4806
4973
  const flagDomain = `${JSON.parse( process.env.URL ).domain}/manage/trax/dashboard`;
4807
4974
  const sentSummary = [];
4808
4975
 
4809
- // Build one digest per client present in the data and email the static recipient.
4976
+ // Build one digest per client present in the data, restricted to clients that opted in.
4810
4977
  const clientIds = new Set();
4811
- for ( const v of flagsThis.values() ) clientIds.add( v.client_id );
4812
- for ( const v of recurThis.values() ) clientIds.add( v.client_id );
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
+ }
4813
4984
 
4814
4985
  await Promise.all( [ ...clientIds ].map( async ( clientId ) => {
4815
4986
  try {
4816
- let storesFlagged = 0;
4817
- let totalFlags = 0;
4818
- let prevTotalFlags = 0;
4819
- let prevStoresFlagged = 0;
4820
- const flaggedChecklistsThis = new Set();
4821
- const flaggedChecklistsPrev = new Set();
4822
- let questionFlags = 0;
4823
- let notSubmittedFlags = 0;
4824
- let runAIFlags = 0;
4825
- let recurringFlags = 0;
4826
- const excelRows = [];
4827
-
4828
- // Iterate only stores belonging to this client (key prefix match).
4829
- const clientPrefix = `${clientId}::`;
4830
- const clientStoreKeys = new Set();
4831
- for ( const k of flagsThis.keys() ) if ( k.startsWith( clientPrefix ) ) clientStoreKeys.add( k );
4832
- for ( const k of flagsPrev.keys() ) if ( k.startsWith( clientPrefix ) ) clientStoreKeys.add( k );
4833
- for ( const k of recurThis.keys() ) if ( k.startsWith( clientPrefix ) ) clientStoreKeys.add( k );
4834
-
4835
- for ( const k of clientStoreKeys ) {
4836
- const cur = flagsThis.get( k );
4837
- const prev = flagsPrev.get( k );
4838
- const recCur = recurThis.get( k );
4839
- const recPrev = recurPrev.get( k );
4840
- const storeRecurring = recCur?.count || 0;
4841
- const storeTotal = ( cur?.totalFlags || 0 ) + storeRecurring;
4842
- const prevStoreTotal = ( prev?.totalFlags || 0 ) + ( recPrev?.count || 0 );
4843
-
4844
- if ( storeTotal > 0 ) storesFlagged += 1;
4845
- if ( prevStoreTotal > 0 ) prevStoresFlagged += 1;
4846
- totalFlags += storeTotal;
4847
- prevTotalFlags += prevStoreTotal;
4848
- questionFlags += cur?.questionFlag || 0;
4849
- notSubmittedFlags += cur?.timeFlag || 0;
4850
- runAIFlags += cur?.runAIFlag || 0;
4851
- recurringFlags += storeRecurring;
4852
-
4853
- ( cur?.flaggedChecklists || [] ).forEach( ( id ) => flaggedChecklistsThis.add( id ) );
4854
- ( prev?.flaggedChecklists || [] ).forEach( ( id ) => flaggedChecklistsPrev.add( id ) );
4855
-
4856
- if ( storeTotal > 0 || prevStoreTotal > 0 ) {
4857
- const w = computeWow( storeTotal, prevStoreTotal );
4858
- excelRows.push( {
4859
- storeId: ( cur?.store_id || prev?.store_id || k.slice( clientPrefix.length ) ),
4860
- storeName: cur?.storeName || prev?.storeName || '',
4861
- questionFlag: cur?.questionFlag || 0,
4862
- timeFlag: cur?.timeFlag || 0,
4863
- runAIFlag: cur?.runAIFlag || 0,
4864
- recurringFlag: storeRecurring,
4865
- totalFlags: storeTotal,
4866
- prevTotal: prevStoreTotal,
4867
- wow: w.value ? `${w.direction === 'up' ? '+' : '-'}${w.value}` : '0%',
4868
- } );
4869
- }
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;
4870
4998
  }
4871
4999
 
4872
- if ( totalFlags === 0 && prevTotalFlags === 0 ) return; // skip silent clients
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
+ }
4873
5009
 
4874
- const top = topByClient.get( clientId ) || { topFlag: null, topRecurring: null };
4875
- let highestFlaggedStoreWow = { value: '', direction: 'up' };
4876
- if ( top.topFlag ) {
4877
- const prev = flagsPrev.get( `${clientId}::${top.topFlag.storeId}` );
4878
- highestFlaggedStoreWow = computeWow( top.topFlag.value, prev?.totalFlags || 0 );
4879
- }
4880
- let highestRecurringStoreWow = { value: '', direction: 'up' };
4881
- if ( top.topRecurring ) {
4882
- const prev = recurPrev.get( `${clientId}::${top.topRecurring.storeId}` );
4883
- highestRecurringStoreWow = computeWow( top.topRecurring.value, prev?.count || 0 );
4884
- }
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
+ }
4885
5023
 
4886
- const excelHeader = `Weekly Summary ${startDateLabel} - ${endDateLabel}`;
4887
- const buf = await buildWeeklyWrapExcel( excelHeader, excelRows );
4888
- const attachmentBuffer = Buffer.from( buf );
4889
- const sizeKb = Math.max( 1, Math.round( attachmentBuffer.length / 1024 ) );
4890
-
4891
- const data = {
4892
- startDate: startDateLabel,
4893
- endDate: endDateLabel,
4894
- storesFlagged,
4895
- storesFlaggedWow: computeWow( storesFlagged, prevStoresFlagged ),
4896
- totalFlags,
4897
- totalFlagsWow: computeWow( totalFlags, prevTotalFlags ),
4898
- checklistFlags: flaggedChecklistsThis.size,
4899
- checklistFlagsWow: computeWow( flaggedChecklistsThis.size, flaggedChecklistsPrev.size ),
4900
- questionFlags,
4901
- notSubmittedFlags,
4902
- runAIFlags,
4903
- recurringFlags,
4904
- highestFlaggedStore: top.topFlag?.storeName || top.topFlag?.storeId || '--',
4905
- highestFlaggedStoreWow,
4906
- highestRecurringStore: top.topRecurring?.storeName || top.topRecurring?.storeId || '--',
4907
- highestRecurringStoreWow,
4908
- attachmentName: `${excelHeader}.xlsx`,
4909
- attachmentSize: `${sizeKb} KB`,
4910
- domain: flagDomain,
4911
- };
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
+ }
4912
5067
 
4913
- const html = compiled( { data } );
4914
- const attachment = {
4915
- filename: `${excelHeader}.xlsx`,
4916
- content: attachmentBuffer,
4917
- contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
4918
- };
4919
- const sourceEmail = JSON.parse( process.env.SES ).adminEmail;
4920
- const subject = `TangoEye | Weekly Wrap (${startDateLabel} - ${endDateLabel})`;
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})`;
4921
5158
 
4922
- sendEmailWithSES( STATIC_RECIPIENT, subject, html, attachment, sourceEmail );
4923
- sentSummary.push( { recipient: STATIC_RECIPIENT, clientId, totalFlags } );
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
+ }
4924
5165
  } catch ( e ) {
4925
5166
  logger.error( { functionName: 'weeklyWrapAlert.client', clientId, error: e } );
4926
5167
  }