tango-app-api-trax 3.8.25 → 3.8.26-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' );
@@ -4360,7 +4365,6 @@ export async function recurringFlagAlert( req, res ) {
4360
4365
  ],
4361
4366
  },
4362
4367
  }, { _id: 1, checkListName: 1, recurringFlag: 1, notifyFlags: 1, approver: 1, client_id: 1 } );
4363
- console.log( JSON.stringify( checklistDetails ) );
4364
4368
  if ( !checklistDetails.length ) {
4365
4369
  return res.sendSuccess( 'No checklists configured for recurring flag' );
4366
4370
  }
@@ -4390,6 +4394,20 @@ export async function recurringFlagAlert( req, res ) {
4390
4394
 
4391
4395
  if ( !recipients.length ) return;
4392
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
+
4393
4411
  // Read tracker rows where EITHER the sop streak or the runAI count has hit threshold and the
4394
4412
  // current trigger hasn't been emailed yet (lastEmailDate vs the relevant flagged date).
4395
4413
  const trackerRows = await recurringFlagTracker.find( {
@@ -4414,7 +4432,12 @@ export async function recurringFlagAlert( req, res ) {
4414
4432
  // Determine which streak crossed threshold for this row — drives reset granularity below.
4415
4433
  const sopFired = ( t.consecutiveCount || 0 ) >= threshold && t.lastEmailDate !== t.lastFlaggedDate;
4416
4434
  const runAIFired = ( t.runAICount || 0 ) >= threshold && t.lastEmailDate !== t.lastRunAIFlaggedDate;
4417
- 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;
4418
4441
  triggers.push( {
4419
4442
  recipient,
4420
4443
  clientId: t.client_id,
@@ -4438,14 +4461,19 @@ export async function recurringFlagAlert( req, res ) {
4438
4461
  lastSubmittedBy: t.lastSubmittedBy || '--',
4439
4462
  lastSubmissionDate: t.lastSubmissionDate || t.lastFlaggedDate || '',
4440
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
+ } );
4441
4476
  }
4442
- trackerIdsToReset.push( {
4443
- _id: t._id,
4444
- lastFlaggedDate: t.lastFlaggedDate,
4445
- lastRunAIFlaggedDate: t.lastRunAIFlaggedDate,
4446
- sopFired,
4447
- runAIFired,
4448
- } );
4449
4477
  }
4450
4478
  } ) );
4451
4479
 
@@ -4470,9 +4498,11 @@ export async function recurringFlagAlert( req, res ) {
4470
4498
  await Promise.all( [ ...byRecipient.entries() ].map( async ( [ recipient, items ] ) => {
4471
4499
  const subjects = new Set( items.map( ( i ) => i.subjectId ) );
4472
4500
  const checklists = new Set( items.map( ( i ) => i.checklistId ) );
4473
- console.log( checklists );
4474
4501
  const isMultiStore = subjects.size > 1; // "isMultiStore" name retained for template back-compat
4475
4502
  const isMultiChecklist = !isMultiStore && checklists.size > 1;
4503
+ // Sub-mode of multi-store when the recipient's flagged subjects all share a single checklist.
4504
+ // Drives a tighter email layout (no Checklist column, no "Total Checklists" line, checklist name in intro).
4505
+ const isMultiStoreSingleChecklist = isMultiStore && checklists.size === 1;
4476
4506
  // Threshold for the message line — when grouping spans multiple checklists/subjects, take min threshold seen.
4477
4507
  const thresholdShown = items.reduce( ( acc, it ) => Math.min( acc, it.days ), items[0].days );
4478
4508
 
@@ -4508,8 +4538,8 @@ export async function recurringFlagAlert( req, res ) {
4508
4538
  } );
4509
4539
  }
4510
4540
  const g = groupMap.get( k );
4511
- g.questionCount += 1;
4512
- g.runAICount += ( i.runAICount || 0 );
4541
+ if ( i.sopFired ) g.questionCount += 1;
4542
+ if ( i.runAIFired ) g.runAICount += 1;
4513
4543
  if ( parseSubmissionDate( i.lastSubmissionDate ) > parseSubmissionDate( g.lastSubmissionDate ) ) {
4514
4544
  g.lastSubmissionDate = i.lastSubmissionDate;
4515
4545
  g.lastSubmittedBy = i.lastSubmittedBy;
@@ -4521,9 +4551,10 @@ export async function recurringFlagAlert( req, res ) {
4521
4551
  checklistName: g.checklistName,
4522
4552
  lastSubmittedBy: g.lastSubmittedBy,
4523
4553
  lastSubmissionDate: g.lastSubmissionDate,
4524
- days: g.questionCount, // template column "Total Recurring Flags" reads `days`
4554
+ days: g.questionCount, // legacy alias kept for back-compat with older template builds
4525
4555
  flagCount: g.questionCount,
4526
4556
  runAICount: g.runAICount,
4557
+ totalFlags: g.questionCount + g.runAICount,
4527
4558
  } ) );
4528
4559
 
4529
4560
  // Excel attachment keeps per-question detail (one row per flagged question).
@@ -4546,6 +4577,8 @@ export async function recurringFlagAlert( req, res ) {
4546
4577
  threshold: thresholdShown,
4547
4578
  isMultiStore,
4548
4579
  isMultiChecklist,
4580
+ isMultiStoreSingleChecklist,
4581
+ isUserCoverage: isAllUser,
4549
4582
  showTable: isMultiStore || isMultiChecklist,
4550
4583
  hasAttachment,
4551
4584
  domain: flagDomain,
@@ -4563,11 +4596,15 @@ export async function recurringFlagAlert( req, res ) {
4563
4596
  totalChecklists: checklists.size,
4564
4597
  totalFlags: items.length,
4565
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
+ }
4566
4603
  } else if ( isMultiChecklist ) {
4567
4604
  data.subjectName = items[0].subjectName;
4568
4605
  data.storeName = items[0].subjectName;
4569
4606
  } else {
4570
- // Single mode: one (subject, checklist) — flagCount is the number of questions that hit threshold.
4607
+ // Single mode: one (subject, checklist) — totalFlags = sop question flags + runAI flags.
4571
4608
  const single = rows[0];
4572
4609
  data.subjectName = single.subjectName;
4573
4610
  data.storeName = single.subjectName;
@@ -4575,9 +4612,8 @@ export async function recurringFlagAlert( req, res ) {
4575
4612
  data.lastSubmittedBy = single.lastSubmittedBy;
4576
4613
  data.lastSubmissionDate = single.lastSubmissionDate;
4577
4614
  data.flagCount = single.flagCount;
4578
- data.flagCountPlural = single.flagCount > 1;
4579
4615
  data.runAICount = single.runAICount;
4580
- data.runAICountPlural = single.runAICount > 1;
4616
+ data.totalFlags = single.totalFlags;
4581
4617
  }
4582
4618
 
4583
4619
  const html = compiled( { data } );
@@ -4602,7 +4638,6 @@ export async function recurringFlagAlert( req, res ) {
4602
4638
  logger.error( { functionName: 'recurringFlagAlert.buildExcel', error: e } );
4603
4639
  }
4604
4640
  }
4605
- console.log( params.toEmail );
4606
4641
  sendEmailWithSES( params.toEmail, params.mailSubject, params.htmlBody, params.attachment, params.sourceEmail );
4607
4642
  sentSummary.push( { recipient, count: items.length, mode: isMultiStore ? 'multi-store' : ( isMultiChecklist ? 'multi-checklist' : 'single' ) } );
4608
4643
  } ) );
@@ -4643,10 +4678,12 @@ function getLastWeekRange( ref = dayjs() ) {
4643
4678
  const dow = today.day(); // 0 Sun ... 6 Sat
4644
4679
  const daysToThisMonday = ( dow + 6 ) % 7; // Mon=0, Tue=1, ..., Sun=6
4645
4680
  const thisMonday = today.subtract( daysToThisMonday, 'day' );
4646
- const weekStart = thisMonday.subtract( 7, 'day' );
4647
- const weekEnd = thisMonday.subtract( 1, 'day' );
4648
- const prevWeekStart = weekStart.subtract( 7, 'day' );
4649
- 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' );
4650
4687
  return {
4651
4688
  weekStart: weekStart.format( 'YYYY-MM-DD' ),
4652
4689
  weekEnd: weekEnd.format( 'YYYY-MM-DD' ),
@@ -4668,7 +4705,7 @@ function computeWow( current, previous ) {
4668
4705
 
4669
4706
  async function aggregateWeeklyFlagsByStore( weekStart, weekEnd ) {
4670
4707
  const rows = await processedchecklist.aggregate( [
4671
- { $match: { date_string: { $gte: weekStart, $lte: weekEnd }, isdeleted: { $ne: true } } },
4708
+ { $match: { date_string: { $gte: weekStart, $lte: weekEnd }, checkListType: 'custom' } },
4672
4709
  { $group: {
4673
4710
  _id: { client_id: '$client_id', store_id: '$store_id' },
4674
4711
  storeName: { $last: '$storeName' },
@@ -4708,26 +4745,33 @@ async function aggregateWeeklyFlagsByStore( weekStart, weekEnd ) {
4708
4745
  }
4709
4746
 
4710
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.
4711
4751
  const rows = await recurringFlagTracker.aggregate( [
4712
4752
  { $match: { emailHistory: { $elemMatch: { $gte: weekStart, $lte: weekEnd } } } },
4713
4753
  { $project: {
4714
4754
  client_id: 1,
4715
4755
  store_id: 1,
4716
4756
  storeName: 1,
4717
- firedInWeek: {
4718
- $size: {
4719
- $filter: {
4720
- input: { $ifNull: [ '$emailHistory', [] ] },
4721
- as: 'd',
4722
- cond: { $and: [ { $gte: [ '$$d', weekStart ] }, { $lte: [ '$$d', weekEnd ] } ] },
4723
- },
4757
+ datesInWeek: {
4758
+ $filter: {
4759
+ input: { $ifNull: [ '$emailHistory', [] ] },
4760
+ as: 'd',
4761
+ cond: { $and: [ { $gte: [ '$$d', weekStart ] }, { $lte: [ '$$d', weekEnd ] } ] },
4724
4762
  },
4725
4763
  },
4726
4764
  } },
4765
+ { $unwind: '$datesInWeek' },
4727
4766
  { $group: {
4728
4767
  _id: { client_id: '$client_id', store_id: '$store_id' },
4729
4768
  storeName: { $last: '$storeName' },
4730
- count: { $sum: '$firedInWeek' },
4769
+ uniqueDates: { $addToSet: '$datesInWeek' },
4770
+ } },
4771
+ { $project: {
4772
+ _id: 1,
4773
+ storeName: 1,
4774
+ count: { $size: '$uniqueDates' },
4731
4775
  } },
4732
4776
  ] );
4733
4777
  const map = new Map();
@@ -4742,180 +4786,382 @@ async function aggregateWeeklyRecurringByStore( weekStart, weekEnd ) {
4742
4786
  return map;
4743
4787
  }
4744
4788
 
4745
- function buildWeeklyWrapExcel( header, perStoreRows ) {
4789
+ function buildWeeklyWrapExcel( header, perStoreChecklistRows ) {
4746
4790
  const workbook = new ExcelJS.Workbook();
4747
4791
  const sheet = workbook.addWorksheet( 'Weekly Summary' );
4748
4792
  sheet.addRow( [ header ] );
4749
4793
  sheet.getRow( 1 ).font = { bold: true, size: 14 };
4750
4794
  sheet.addRow( [] );
4751
4795
  sheet.columns = [
4752
- { header: 'Store ID', key: 'storeId', width: 18 },
4753
4796
  { header: 'Store Name', key: 'storeName', width: 28 },
4797
+ { header: 'Checklist Name', key: 'checkListName', width: 32 },
4798
+ { header: 'Flags', key: 'flags', width: 12 },
4754
4799
  { header: 'Question Flags', key: 'questionFlag', width: 16 },
4755
- { header: 'Not Submitted', key: 'timeFlag', width: 16 },
4756
- { 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 },
4757
4802
  { header: 'Recurring Flags', key: 'recurringFlag', width: 18 },
4758
- { header: 'Total Flags', key: 'totalFlags', width: 14 },
4759
- { header: 'Last Week Total', key: 'prevTotal', width: 18 },
4760
- { header: 'WoW %', key: 'wow', width: 12 },
4761
4803
  ];
4762
4804
  sheet.getRow( 3 ).font = { bold: true };
4763
4805
  sheet.getRow( 3 ).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } };
4764
- for ( const r of perStoreRows ) sheet.addRow( r );
4806
+ for ( const r of perStoreChecklistRows ) sheet.addRow( r );
4765
4807
  return workbook.xlsx.writeBuffer();
4766
4808
  }
4767
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
+
4768
4946
  export async function weeklyWrapAlert( req, res ) {
4769
4947
  try {
4770
4948
  const range = getLastWeekRange();
4771
4949
  const startDateLabel = dayjs( range.weekStart ).format( 'DD/MM/YY' );
4772
4950
  const endDateLabel = dayjs( range.weekEnd ).format( 'DD/MM/YY' );
4773
4951
 
4774
- // TEMP: static recipient for dev. Replace with client-config lookup later.
4775
- 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 ) ) );
4776
4959
 
4777
- // Aggregate flag data for both weeks across all stores in one shot.
4778
- 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( [
4779
4963
  aggregateWeeklyFlagsByStore( range.weekStart, range.weekEnd ),
4780
4964
  aggregateWeeklyFlagsByStore( range.prevWeekStart, range.prevWeekEnd ),
4781
4965
  aggregateWeeklyRecurringByStore( range.weekStart, range.weekEnd ),
4782
4966
  aggregateWeeklyRecurringByStore( range.prevWeekStart, range.prevWeekEnd ),
4967
+ aggregateWeeklyFlagsByStoreChecklist( range.weekStart, range.weekEnd ),
4968
+ aggregateWeeklyRecurringByStoreChecklist( range.weekStart, range.weekEnd ),
4783
4969
  ] );
4784
4970
 
4785
- // Compute per-client highest flagged store and highest recurring flagged store (this week).
4786
- const topByClient = new Map(); // clientId -> { topFlag, topRecurring }
4787
- const upsertTop = ( client, key, candidate ) => {
4788
- if ( !topByClient.has( client ) ) topByClient.set( client, { topFlag: null, topRecurring: null } );
4789
- const entry = topByClient.get( client );
4790
- if ( !entry[key] || candidate.value > entry[key].value ) entry[key] = candidate;
4791
- };
4792
- for ( const v of flagsThis.values() ) {
4793
- upsertTop( v.client_id, 'topFlag', { storeId: v.store_id, storeName: v.storeName, value: v.totalFlags } );
4794
- }
4795
- for ( const v of recurThis.values() ) {
4796
- upsertTop( v.client_id, 'topRecurring', { storeId: v.store_id, storeName: v.storeName, value: v.count } );
4797
- }
4798
-
4799
4971
  const fileContent = fs.readFileSync( path.resolve( path.dirname( '' ) ) + '/src/hbs/weeklyWrap.hbs', 'utf8' );
4800
4972
  const compiled = handlebars.compile( fileContent );
4801
4973
  const flagDomain = `${JSON.parse( process.env.URL ).domain}/manage/trax/dashboard`;
4802
4974
  const sentSummary = [];
4803
4975
 
4804
- // 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.
4805
4977
  const clientIds = new Set();
4806
- for ( const v of flagsThis.values() ) clientIds.add( v.client_id );
4807
- 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
+ }
4808
4984
 
4809
4985
  await Promise.all( [ ...clientIds ].map( async ( clientId ) => {
4810
4986
  try {
4811
- let storesFlagged = 0;
4812
- let totalFlags = 0;
4813
- let prevTotalFlags = 0;
4814
- let prevStoresFlagged = 0;
4815
- const flaggedChecklistsThis = new Set();
4816
- const flaggedChecklistsPrev = new Set();
4817
- let questionFlags = 0;
4818
- let notSubmittedFlags = 0;
4819
- let runAIFlags = 0;
4820
- let recurringFlags = 0;
4821
- const excelRows = [];
4822
-
4823
- // Iterate only stores belonging to this client (key prefix match).
4824
- const clientPrefix = `${clientId}::`;
4825
- const clientStoreKeys = new Set();
4826
- for ( const k of flagsThis.keys() ) if ( k.startsWith( clientPrefix ) ) clientStoreKeys.add( k );
4827
- for ( const k of flagsPrev.keys() ) if ( k.startsWith( clientPrefix ) ) clientStoreKeys.add( k );
4828
- for ( const k of recurThis.keys() ) if ( k.startsWith( clientPrefix ) ) clientStoreKeys.add( k );
4829
-
4830
- for ( const k of clientStoreKeys ) {
4831
- const cur = flagsThis.get( k );
4832
- const prev = flagsPrev.get( k );
4833
- const recCur = recurThis.get( k );
4834
- const recPrev = recurPrev.get( k );
4835
- const storeRecurring = recCur?.count || 0;
4836
- const storeTotal = ( cur?.totalFlags || 0 ) + storeRecurring;
4837
- const prevStoreTotal = ( prev?.totalFlags || 0 ) + ( recPrev?.count || 0 );
4838
-
4839
- if ( storeTotal > 0 ) storesFlagged += 1;
4840
- if ( prevStoreTotal > 0 ) prevStoresFlagged += 1;
4841
- totalFlags += storeTotal;
4842
- prevTotalFlags += prevStoreTotal;
4843
- questionFlags += cur?.questionFlag || 0;
4844
- notSubmittedFlags += cur?.timeFlag || 0;
4845
- runAIFlags += cur?.runAIFlag || 0;
4846
- recurringFlags += storeRecurring;
4847
-
4848
- ( cur?.flaggedChecklists || [] ).forEach( ( id ) => flaggedChecklistsThis.add( id ) );
4849
- ( prev?.flaggedChecklists || [] ).forEach( ( id ) => flaggedChecklistsPrev.add( id ) );
4850
-
4851
- if ( storeTotal > 0 || prevStoreTotal > 0 ) {
4852
- const w = computeWow( storeTotal, prevStoreTotal );
4853
- excelRows.push( {
4854
- storeId: ( cur?.store_id || prev?.store_id || k.slice( clientPrefix.length ) ),
4855
- storeName: cur?.storeName || prev?.storeName || '',
4856
- questionFlag: cur?.questionFlag || 0,
4857
- timeFlag: cur?.timeFlag || 0,
4858
- runAIFlag: cur?.runAIFlag || 0,
4859
- recurringFlag: storeRecurring,
4860
- totalFlags: storeTotal,
4861
- prevTotal: prevStoreTotal,
4862
- wow: w.value ? `${w.direction === 'up' ? '+' : '-'}${w.value}` : '0%',
4863
- } );
4864
- }
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;
4865
4998
  }
4866
4999
 
4867
- 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
+ }
4868
5009
 
4869
- const top = topByClient.get( clientId ) || { topFlag: null, topRecurring: null };
4870
- let highestFlaggedStoreWow = { value: '', direction: 'up' };
4871
- if ( top.topFlag ) {
4872
- const prev = flagsPrev.get( `${clientId}::${top.topFlag.storeId}` );
4873
- highestFlaggedStoreWow = computeWow( top.topFlag.value, prev?.totalFlags || 0 );
4874
- }
4875
- let highestRecurringStoreWow = { value: '', direction: 'up' };
4876
- if ( top.topRecurring ) {
4877
- const prev = recurPrev.get( `${clientId}::${top.topRecurring.storeId}` );
4878
- highestRecurringStoreWow = computeWow( top.topRecurring.value, prev?.count || 0 );
4879
- }
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
+ }
4880
5023
 
4881
- const excelHeader = `Weekly Summary ${startDateLabel} - ${endDateLabel}`;
4882
- const buf = await buildWeeklyWrapExcel( excelHeader, excelRows );
4883
- const attachmentBuffer = Buffer.from( buf );
4884
- const sizeKb = Math.max( 1, Math.round( attachmentBuffer.length / 1024 ) );
4885
-
4886
- const data = {
4887
- startDate: startDateLabel,
4888
- endDate: endDateLabel,
4889
- storesFlagged,
4890
- storesFlaggedWow: computeWow( storesFlagged, prevStoresFlagged ),
4891
- totalFlags,
4892
- totalFlagsWow: computeWow( totalFlags, prevTotalFlags ),
4893
- checklistFlags: flaggedChecklistsThis.size,
4894
- checklistFlagsWow: computeWow( flaggedChecklistsThis.size, flaggedChecklistsPrev.size ),
4895
- questionFlags,
4896
- notSubmittedFlags,
4897
- runAIFlags,
4898
- recurringFlags,
4899
- highestFlaggedStore: top.topFlag?.storeName || top.topFlag?.storeId || '--',
4900
- highestFlaggedStoreWow,
4901
- highestRecurringStore: top.topRecurring?.storeName || top.topRecurring?.storeId || '--',
4902
- highestRecurringStoreWow,
4903
- attachmentName: `${excelHeader}.xlsx`,
4904
- attachmentSize: `${sizeKb} KB`,
4905
- domain: flagDomain,
4906
- };
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
+ }
4907
5067
 
4908
- const html = compiled( { data } );
4909
- const attachment = {
4910
- filename: `${excelHeader}.xlsx`,
4911
- content: attachmentBuffer,
4912
- contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
4913
- };
4914
- const sourceEmail = JSON.parse( process.env.SES ).adminEmail;
4915
- 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})`;
4916
5158
 
4917
- sendEmailWithSES( STATIC_RECIPIENT, subject, html, attachment, sourceEmail );
4918
- 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
+ }
4919
5165
  } catch ( e ) {
4920
5166
  logger.error( { functionName: 'weeklyWrapAlert.client', clientId, error: e } );
4921
5167
  }