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