tango-app-api-trax 3.9.39 → 3.9.41

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.
Files changed (51) hide show
  1. package/index.js +2 -1
  2. package/package.json +1 -1
  3. package/src/controllers/internalTrax.controller.js +225 -94
  4. package/src/controllers/mobileTrax.controller.js +69 -29
  5. package/src/controllers/trax.controller.js +3 -2
  6. package/src/controllers/traxDashboard.controllers.js +69 -55
  7. package/src/hbs/flag.hbs +1 -1
  8. package/src/hbs/template.hbs +7 -0
  9. package/src/hbs/visit-checklist.hbs +77 -93
  10. package/src/logging/activityLogFlusher.js +59 -0
  11. package/src/logging/activityLogMiddleware.js +45 -0
  12. package/src/logging/activityLogStore.js +91 -0
  13. package/src/logging/compressBatches.js +83 -0
  14. package/src/logging/config.js +24 -0
  15. package/src/logging/createLoggableService.js +46 -0
  16. package/src/logging/logExternalCall.js +37 -0
  17. package/src/routes/internalTraxApi.router.js +1 -0
  18. package/src/services/app.service.js +15 -9
  19. package/src/services/approver.service.js +23 -15
  20. package/src/services/authentication.service.js +9 -3
  21. package/src/services/camera.service.js +19 -13
  22. package/src/services/checklist.service.js +35 -27
  23. package/src/services/checklistAssign.service.js +43 -38
  24. package/src/services/checklistQuestion.service.js +39 -34
  25. package/src/services/checklistlog.service.js +39 -34
  26. package/src/services/clientRequest.service.js +9 -2
  27. package/src/services/clients.services.js +23 -18
  28. package/src/services/cluster.service.js +31 -23
  29. package/src/services/domain.service.js +23 -18
  30. package/src/services/download.services.js +35 -25
  31. package/src/services/group.service.js +23 -17
  32. package/src/services/lenskartEmployeeMapping.service.js +15 -10
  33. package/src/services/locus.service.js +35 -28
  34. package/src/services/notification.service.js +35 -26
  35. package/src/services/otp.service.js +20 -13
  36. package/src/services/planogram.service.js +9 -2
  37. package/src/services/processedTaskConfig.service.js +35 -27
  38. package/src/services/processedTaskList.service.js +32 -26
  39. package/src/services/processedchecklist.services.js +55 -47
  40. package/src/services/processedchecklistconfig.services.js +39 -34
  41. package/src/services/recurringFlagTracker.service.js +39 -32
  42. package/src/services/runAIFeatures.services.js +32 -27
  43. package/src/services/runAIRequest.services.js +43 -38
  44. package/src/services/store.service.js +32 -27
  45. package/src/services/tagging.service.js +9 -2
  46. package/src/services/taskConfig.service.js +35 -27
  47. package/src/services/teams.service.js +35 -24
  48. package/src/services/ticket.service.js +15 -10
  49. package/src/services/user.service.js +27 -20
  50. package/src/services/userAssignedstores.service.js +12 -5
  51. package/src/utils/visitChecklistPdf.utils.js +449 -21
@@ -146,6 +146,54 @@ function flattenImageRefs( arr ) {
146
146
  .filter( Boolean );
147
147
  }
148
148
 
149
+ /**
150
+ * Builds the ordered media list (reference → uploaded → validation) for a SINGLE
151
+ * answer entry, deduped within that entry. Used to render each answer's images
152
+ * inline next to its own text/label, instead of pooling every answer's images
153
+ * into one question-level grid (which decoupled labels from their images).
154
+ * @param {Object} ua a single entry produced by buildQuestionAnswerEntries
155
+ * @return {Array} list of { type, label, url, detectionStatus } for this entry, in render order
156
+ */
157
+ function buildEntryMediaItems( ua = {} ) {
158
+ const items = [];
159
+ const seen = new Set();
160
+
161
+ const push = ( type, label, url, detectionStatus = '' ) => {
162
+ if ( !url || typeof url !== 'string' ) return;
163
+ if ( seen.has( url ) ) return;
164
+ seen.add( url );
165
+ items.push( { type, label, url, detectionStatus } );
166
+ };
167
+
168
+ // Each uploaded/validation image shows its OWN runAI verdict (per-image, never
169
+ // aggregated). References are not AI-checked, so they carry no badge.
170
+ const statusByUrl = buildAnswerImageStatus( ua );
171
+ const statusFor = ( url ) => statusByUrl.get( url ) || '';
172
+
173
+ if ( ua.hasReferenceImage ) {
174
+ if ( Array.isArray( ua.multiReferenceImage ) && ua.multiReferenceImage.length ) {
175
+ ua.multiReferenceImage.forEach( ( url ) => push( 'reference', 'Reference Image', url ) );
176
+ } else if ( ua.referenceImage ) {
177
+ push( 'reference', 'Reference Image', ua.referenceImage );
178
+ }
179
+ }
180
+
181
+ if ( ua.answerType === 'image' && ua.answer ) {
182
+ push( 'uploaded', 'Uploaded Image', ua.answer, statusFor( ua.answer ) );
183
+ }
184
+
185
+ if ( ua.validation ) {
186
+ if ( ua.validationDisplayType === 'image' && ua.validationAnswer ) {
187
+ push( 'validation', 'Validation Image', ua.validationAnswer, statusFor( ua.validationAnswer ) );
188
+ }
189
+ if ( ua.validationDisplayType === 'multiImage' && Array.isArray( ua.validationImage ) ) {
190
+ ua.validationImage.forEach( ( url ) => push( 'validation', 'Validation Image', url, statusFor( url ) ) );
191
+ }
192
+ }
193
+
194
+ return items;
195
+ }
196
+
149
197
  function buildQuestionAnswerEntries( question ) {
150
198
  const rawUserAnswers = getSourceUserAnswers( question );
151
199
  const uniqueUserAnswers = [];
@@ -180,21 +228,278 @@ function buildQuestionAnswerEntries( question ) {
180
228
  }
181
229
  }
182
230
  const validationImage = flattenImageRefs( userAnswer?.validationImage );
183
-
184
- return {
231
+ // Videos captured alongside 'Capture Multiple Image with description' validation (no runAI; shown as links).
232
+ const validationVideo = flattenImageRefs( userAnswer?.validationVideo );
233
+
234
+ const referenceImage = userAnswer?.referenceImage || matchedAnswer?.referenceImage || '';
235
+ // Left column shows a reference image only for non-image/video, non-multipleImage answer types.
236
+ // When there is no reference image, uploaded/validation media is rendered on the left instead of the right.
237
+ const hasReferenceImage = question?.answerType !== 'image/video' &&
238
+ question?.answerType !== 'multipleImage' &&
239
+ Boolean( referenceImage || multiReferenceImage.length );
240
+
241
+ // runAI verdicts for this answer, in source order. Used to tag each uploaded /
242
+ // validation image with its OWN verdict (by answerImage filename, else by
243
+ // position) so multi-image answers don't all collapse to one aggregated status.
244
+ const isPerAnswerType = PER_ANSWER_DETECTION_TYPES.includes( question?.answerType );
245
+ const runAIValues = getRunAIMatchValues( userAnswer?.runAIData );
246
+ const imageStatus = buildImageStatusMap( userAnswer?.runAIData );
247
+
248
+ // Choice answers (yes/no, multiple choice, dropdown) take this answer's own
249
+ // verdict directly — never aggregated. Other types keep the aggregate only as
250
+ // a last-resort fallback.
251
+ const detectionStatus = isPerAnswerType ?
252
+ verdictFromValue( runAIValues[0] ) :
253
+ getDetectionStatus( userAnswer?.runAIData );
254
+
255
+ const entry = {
185
256
  answer: userAnswer?.answer || '',
186
257
  answerType: getMediaDisplayType( question?.answerType, userAnswer ),
187
- referenceImage: userAnswer?.referenceImage || matchedAnswer?.referenceImage || '',
258
+ referenceImage,
259
+ hasReferenceImage,
188
260
  multiReferenceImage,
189
261
  remarks: userAnswer?.remarks || '',
190
262
  sopFlag: userAnswer?.sopFlag ?? matchedAnswer?.sopFlag ?? false,
263
+ detectionStatus,
264
+ imageStatus,
265
+ runAIValues,
191
266
  validation,
192
267
  validationType,
193
268
  validationAnswer,
194
269
  validationImage,
270
+ validationVideo,
195
271
  validationDisplayType: getValidationDisplayType( validationType ),
196
272
  };
273
+
274
+ // Text-label answers (e.g. "Attach the image of Cat") carry their own
275
+ // reference/validation images. Group those images with this entry so the
276
+ // label and its images render together, instead of pooling every answer's
277
+ // images into one question-level grid. Image/video answers keep flowing
278
+ // into the pooled grid (handled by buildQuestionMediaItems).
279
+ entry.answerMedia = entry.answerType === 'text' ? buildEntryMediaItems( entry ) : [];
280
+
281
+ // Choice answers with no media show their verdict next to the answer text;
282
+ // when they carry validation media the badge sits on that media instead.
283
+ entry.showDetectionStatus = isPerAnswerType && Boolean( detectionStatus ) && !entry.answerMedia.length;
284
+
285
+ return entry;
286
+ } );
287
+ }
288
+
289
+
290
+ /**
291
+ * Flattens a question's user answers into one ordered, deduped image list for the
292
+ * 2-per-row answer media grid. Order: reference images first, then uploaded images,
293
+ * then validation images. Videos are shown as links elsewhere, so only images are
294
+ * collected here. When a question has no reference image, the uploaded images simply
295
+ * fill the grid from the start (taking the reference slot).
296
+ * @param {Array} userAnswers entries produced by buildQuestionAnswerEntries
297
+ * @param {string} [forcedStatus] when set (image / image-video), every uploaded &
298
+ * validation image shows this aggregated verdict instead of its own per-image one
299
+ * @return {Array} list of { type, label, url, detectionStatus } media items in render order
300
+ */
301
+ function buildQuestionMediaItems( userAnswers = [], forcedStatus = '' ) {
302
+ const items = [];
303
+ const seen = new Set();
304
+
305
+ const push = ( type, label, url, detectionStatus = '' ) => {
306
+ if ( !url || typeof url !== 'string' ) return;
307
+ if ( seen.has( url ) ) return;
308
+ seen.add( url );
309
+ items.push( { type, label, url, detectionStatus } );
310
+ };
311
+
312
+ // Entries that render their media inline with the answer (answerMedia) are
313
+ // excluded here so their images are not duplicated in the pooled grid.
314
+ const pool = userAnswers.filter( ( ua ) => !( ua.answerMedia && ua.answerMedia.length ) );
315
+
316
+ // Resolve each image's status per answer (per-image; forcedStatus aggregates
317
+ // image / image-video). Built per answer so positional matching stays aligned.
318
+ const statusByUrl = new Map();
319
+ pool.forEach( ( ua ) => {
320
+ buildAnswerImageStatus( ua, forcedStatus ).forEach( ( status, url ) => statusByUrl.set( url, status ) );
321
+ } );
322
+ const statusFor = ( url ) => statusByUrl.get( url ) || '';
323
+
324
+ // 1. Reference images first (deduped, so a single shared reference shows once).
325
+ // Skipped for image/video & multipleImage upload questions (hasReferenceImage
326
+ // is false), which have no comparison reference — only uploaded answers.
327
+ pool.forEach( ( ua ) => {
328
+ if ( !ua.hasReferenceImage ) return;
329
+ if ( Array.isArray( ua.multiReferenceImage ) && ua.multiReferenceImage.length ) {
330
+ ua.multiReferenceImage.forEach( ( url ) => push( 'reference', 'Reference Image', url ) );
331
+ } else if ( ua.referenceImage ) {
332
+ push( 'reference', 'Reference Image', ua.referenceImage );
333
+ }
334
+ } );
335
+
336
+ // 2. Uploaded images — tagged with each image's own detection status (from runAIData).
337
+ pool.forEach( ( ua ) => {
338
+ if ( ua.answerType === 'image' && ua.answer ) {
339
+ push( 'uploaded', 'Uploaded Image', ua.answer, statusFor( ua.answer ) );
340
+ }
341
+ } );
342
+
343
+ // 3. Validation images (single 'Capture Image' and multi 'Capture Multiple Image').
344
+ pool.forEach( ( ua ) => {
345
+ if ( !ua.validation ) return;
346
+
347
+ if ( ua.validationDisplayType === 'image' && ua.validationAnswer ) {
348
+ push( 'validation', 'Validation Image', ua.validationAnswer, statusFor( ua.validationAnswer ) );
349
+ }
350
+
351
+ if ( ua.validationDisplayType === 'multiImage' && Array.isArray( ua.validationImage ) ) {
352
+ ua.validationImage.forEach( ( url ) => push( 'validation', 'Validation Image', url, statusFor( url ) ) );
353
+ }
354
+ } );
355
+
356
+ return items;
357
+ }
358
+
359
+
360
+ /**
361
+ * Extracts every 'Matched/Not Matched' value from a userAnswer's runAIData.
362
+ * Handles both shapes:
363
+ * - [ { answerImage, results: [ { featureName, value } ] } ] (per-image nested)
364
+ * - [ { featureName, value } ] (flat)
365
+ * @param {Array} runAIData
366
+ * @return {string[]} list of 'True' / 'False' values (in source order)
367
+ */
368
+ function getRunAIMatchValues( runAIData ) {
369
+ if ( !Array.isArray( runAIData ) ) return [];
370
+
371
+ const values = [];
372
+
373
+ runAIData.forEach( ( item ) => {
374
+ if ( Array.isArray( item?.results ) ) {
375
+ item.results.forEach( ( res ) => {
376
+ if ( res?.featureName === 'Matched/Not Matched' ) values.push( res.value );
377
+ } );
378
+ } else if ( item?.featureName === 'Matched/Not Matched' ) {
379
+ values.push( item.value );
380
+ }
381
+ } );
382
+
383
+ return values;
384
+ }
385
+
386
+
387
+ // Answer types whose runAI verdict is aggregated across all uploaded images:
388
+ // if any image is not matched, the whole question reads 'Not Matched'. Every
389
+ // other type shows each image's own verdict.
390
+ const AGGREGATE_DETECTION_TYPES = [ 'image', 'image/video' ];
391
+
392
+ // Choice answers: each selected answer carries its OWN runAI verdict and shows it
393
+ // directly — never aggregated ('.every') across the question's other answers.
394
+ const PER_ANSWER_DETECTION_TYPES = [ 'yes/no', 'multiplechoicesingle', 'multiplechoicemultiple', 'dropdown' ];
395
+
396
+
397
+ /**
398
+ * Maps a single runAI value to a display status.
399
+ * @param {string|boolean} value
400
+ * @return {string} 'Matched' | 'Not Matched' | ''
401
+ */
402
+ function verdictFromValue( value ) {
403
+ if ( value === 'True' || value === true ) return 'Matched';
404
+ if ( value === 'False' || value === false ) return 'Not Matched';
405
+ return '';
406
+ }
407
+
408
+
409
+ /**
410
+ * The filename of an image URL/path, ignoring any query string. Used to match an
411
+ * uploaded/validation image against its runAIData entry (whose answerImage may
412
+ * carry a different prefix / signed-URL query), regardless of CDN resolution.
413
+ * @param {string} url
414
+ * @return {string}
415
+ */
416
+ function imageBasename( url ) {
417
+ return String( url || '' ).split( '?' )[0].split( '/' ).pop();
418
+ }
419
+
420
+
421
+ /**
422
+ * Builds a per-image detection status map from the nested runAIData shape
423
+ * ([ { answerImage, results: [ { featureName, value } ] } ]), keyed by the
424
+ * answerImage's filename. The flat shape has no per-image key, so it yields an
425
+ * empty map and callers fall back to the answer-level status.
426
+ * @param {Array} runAIData
427
+ * @return {Object} { [filename]: 'Matched' | 'Not Matched' }
428
+ */
429
+ function buildImageStatusMap( runAIData ) {
430
+ const map = {};
431
+ if ( !Array.isArray( runAIData ) ) return map;
432
+
433
+ runAIData.forEach( ( item ) => {
434
+ if ( !item || !item.answerImage ) return;
435
+ const results = Array.isArray( item.results ) ? item.results : [ item ];
436
+ const values = results
437
+ .filter( ( r ) => r?.featureName === 'Matched/Not Matched' )
438
+ .map( ( r ) => r.value );
439
+ if ( !values.length ) return;
440
+ const matched = values.every( ( v ) => v === 'True' || v === true );
441
+ map[imageBasename( item.answerImage )] = matched ? 'Matched' : 'Not Matched';
197
442
  } );
443
+
444
+ return map;
445
+ }
446
+
447
+
448
+ /**
449
+ * Resolves the detection status for each checked image (uploaded + validation) of a
450
+ * single answer, returned as a Map of url → 'Matched' | 'Not Matched' | ''.
451
+ *
452
+ * Each image gets its OWN verdict — never aggregated across the answer's other images:
453
+ * 1. matched explicitly by its answerImage filename (nested runAIData), else
454
+ * 2. matched positionally — the Nth checked image takes the Nth runAI verdict
455
+ * (works for the flat runAIData shape and when filenames don't line up).
456
+ * For image / image-video, forcedStatus (the question-level aggregate) overrides all.
457
+ * @param {Object} ua a single entry produced by buildQuestionAnswerEntries
458
+ * @param {string} [forcedStatus] aggregated status applied to every image (image/image-video)
459
+ * @return {Map<string,string>} url → status
460
+ */
461
+ function buildAnswerImageStatus( ua = {}, forcedStatus = '' ) {
462
+ const statusByUrl = new Map();
463
+ const byName = ua.imageStatus || {};
464
+ const values = Array.isArray( ua.runAIValues ) ? ua.runAIValues : [];
465
+ let idx = 0;
466
+
467
+ const assign = ( url ) => {
468
+ if ( !url || typeof url !== 'string' || statusByUrl.has( url ) ) return;
469
+ let status = forcedStatus;
470
+ if ( !status ) {
471
+ const key = imageBasename( url );
472
+ status = ( key in byName ) ? byName[key] : verdictFromValue( values[idx] );
473
+ idx++;
474
+ }
475
+ statusByUrl.set( url, status );
476
+ };
477
+
478
+ if ( ua.answerType === 'image' && ua.answer ) assign( ua.answer );
479
+
480
+ if ( ua.validation ) {
481
+ if ( ua.validationDisplayType === 'image' && ua.validationAnswer ) assign( ua.validationAnswer );
482
+ if ( ua.validationDisplayType === 'multiImage' && Array.isArray( ua.validationImage ) ) {
483
+ ua.validationImage.forEach( assign );
484
+ }
485
+ }
486
+
487
+ return statusByUrl;
488
+ }
489
+
490
+
491
+ /**
492
+ * Resolves a 'Matched' / 'Not Matched' detection status from runAIData. Handles both
493
+ * runAIData shapes (per-image nested results, and flat). Any non-match verdict makes
494
+ * the whole status 'Not Matched'. Returns '' when there is no runAI verdict to report.
495
+ * @param {Array} runAIData
496
+ * @return {string} 'Matched' | 'Not Matched' | ''
497
+ */
498
+ function getDetectionStatus( runAIData ) {
499
+ const values = getRunAIMatchValues( runAIData );
500
+ if ( !values.length ) return '';
501
+ const allMatched = values.every( ( v ) => v === 'True' || v === true );
502
+ return allMatched ? 'Matched' : 'Not Matched';
198
503
  }
199
504
 
200
505
 
@@ -219,6 +524,8 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
219
524
 
220
525
  const flags = [];
221
526
 
527
+ let hasSopFlag = false;
528
+
222
529
 
223
530
  ( questionAnswer || [] ).forEach( ( section, sectionIdx ) => {
224
531
  let sectionScore = 0;
@@ -240,6 +547,10 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
240
547
 
241
548
  const ua = userAnswersWithRef[0];
242
549
 
550
+ if ( !hasSopFlag && userAnswersWithRef.some( ( entry ) => entry.sopFlag === true ) ) {
551
+ hasSopFlag = true;
552
+ }
553
+
243
554
 
244
555
  const max = q.compliance ? Math.max( ...q?.answers.map( ( o ) => o?.complianceScore ?? Math.max( o?.matchedCount ?? 0, o?.notMatchedCount ?? 0 ) ) ) : 0;
245
556
 
@@ -247,14 +558,34 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
247
558
  let score = q.compliance ? Math.max( ...q?.userAnswer?.map( ( o ) => o?.complianceScore ?? 0 ) ) : 0;
248
559
 
249
560
 
250
- if ( q.answerType == 'image' && q.compliance && q.userAnswer?.[0]?.runAIData ) {
251
- let find = q.userAnswer?.[0]?.runAIData?.find( ( run ) => run?.featureName == 'Matched/Not Matched' );
252
- if ( find ) {
253
- if ( find?.value == 'True' ) {
254
- score = q?.answers?.[0]?.matchedCount;
255
- } else {
256
- score = q?.answers?.[0]?.notMatchedCount;
257
- }
561
+ if ( q.compliance && ( q.answerType === 'image' || q.answerType === 'image/video' ) ) {
562
+ // image/video questions mix image + video entries in userAnswer, so only score the images.
563
+ const imageAnswers = q.answerType === 'image/video' ?
564
+ ( q.userAnswer || [] ).filter( ( ua ) => ua?.answerType === 'image' ) :
565
+ ( q.userAnswer || [] );
566
+
567
+ const matchValues = imageAnswers.flatMap( ( ua ) => getRunAIMatchValues( ua?.runAIData ) );
568
+
569
+ if ( matchValues.length ) {
570
+ // All images must be matched ('True'); if any one is not, treat as not matched.
571
+ const allMatched = matchValues.every( ( value ) => value === 'True' );
572
+ score = allMatched ? ( q?.answers?.[0]?.matchedCount ?? 0 ) : ( q?.answers?.[0]?.notMatchedCount ?? 0 );
573
+ }
574
+ }
575
+
576
+
577
+ // image / image-video aggregate their runAI verdict across all uploaded images
578
+ // (any 'Not Matched' → whole question 'Not Matched'). Every other type keeps
579
+ // each image's own verdict, so this stays '' for them.
580
+ let aggregateDetectionStatus = '';
581
+ if ( AGGREGATE_DETECTION_TYPES.includes( q.answerType ) ) {
582
+ const aiAnswers = q.answerType === 'image/video' ?
583
+ ( q.userAnswer || [] ).filter( ( a ) => a?.answerType === 'image' ) :
584
+ ( q.userAnswer || [] );
585
+ const matchValues = aiAnswers.flatMap( ( a ) => getRunAIMatchValues( a?.runAIData ) );
586
+ if ( matchValues.length ) {
587
+ aggregateDetectionStatus = matchValues.every( ( v ) => v === 'True' || v === true ) ?
588
+ 'Matched' : 'Not Matched';
258
589
  }
259
590
  }
260
591
 
@@ -316,6 +647,8 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
316
647
 
317
648
  answerDisplay: isYes ? 'Yes' : ( isNo ? 'No' : ( answerText && answerText.startsWith( 'http' ) ? 'Image' : ( answerText || '—' ) ) ),
318
649
 
650
+ mediaItems: buildQuestionMediaItems( userAnswersWithRef, aggregateDetectionStatus ),
651
+
319
652
  userAnswer: userAnswersWithRef.length ? userAnswersWithRef : [ {
320
653
  answer: '',
321
654
  answerType: 'text',
@@ -339,15 +672,15 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
339
672
 
340
673
  sectionName: section.sectionName || `Section ${sectionIdx + 1}`,
341
674
 
342
- targetScore: sectionMax,
675
+ targetScore: sectionMax ? sectionMax : '--',
343
676
 
344
- actualScore: sectionScore,
677
+ actualScore: sectionMax ? sectionScore : '--',
345
678
 
346
679
  questionsCount,
347
680
 
348
681
  passedCount,
349
682
 
350
- percentage: sectionMax > 0 ? Math.round( ( sectionScore / sectionMax ) * 100 ) : 0,
683
+ percentage: sectionMax > 0 ? Math.round( ( sectionScore / sectionMax ) * 100 ) : '--',
351
684
 
352
685
  } );
353
686
 
@@ -373,7 +706,7 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
373
706
  const numQuestions = questionAnswer.reduce( ( sum, s ) => sum + ( s.questions?.length || 0 ), 0 );
374
707
 
375
708
 
376
- return { totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions };
709
+ return { totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions, hasSopFlag };
377
710
  }
378
711
 
379
712
 
@@ -395,7 +728,7 @@ export function buildVisitChecklistTemplateDataFromProcessed( processedDoc, bran
395
728
 
396
729
  const {
397
730
 
398
- totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions,
731
+ totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions, hasSopFlag,
399
732
 
400
733
  } = mapSectionsFromQuestionAnswer( questionAnswer );
401
734
 
@@ -481,6 +814,9 @@ export function buildVisitChecklistTemplateDataFromProcessed( processedDoc, bran
481
814
 
482
815
  let referenceId = doc.coverage == 'store' ? doc?.storeName : doc?.userName;
483
816
 
817
+ const userImage = doc.userImage || '';
818
+ const userSignature = doc.userSignature || '';
819
+
484
820
  return {
485
821
 
486
822
 
@@ -506,12 +842,22 @@ export function buildVisitChecklistTemplateDataFromProcessed( processedDoc, bran
506
842
 
507
843
  numFlags: typeof doc.questionFlag === 'number' ? doc.questionFlag : flags.length,
508
844
 
509
- aiBreached: typeof doc.runAIFlag === 'number' ? doc.runAIFlag : 0,
845
+ showFlags: hasSopFlag,
846
+
847
+ runAIFlag: typeof doc.runAIFlag === 'number' ? doc.runAIFlag : 0,
848
+
849
+ showRunAIFlag: ( typeof doc.runAIQuestionCount === 'number' ? doc.runAIQuestionCount : 0 ) > 0,
510
850
 
511
851
  submittedBy: doc.userName || '--',
512
852
 
513
853
  country: doc.country || '--',
514
854
 
855
+ userImage,
856
+
857
+ userSignature,
858
+
859
+ showUserVerification: Boolean( userImage || userSignature ),
860
+
515
861
  hasCompliancePage,
516
862
 
517
863
  detailPageStart,
@@ -567,7 +913,7 @@ function buildFromViewChecklistApi( getchecklistData, viewchecklistData, brandIn
567
913
 
568
914
  const {
569
915
 
570
- totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions,
916
+ totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions, hasSopFlag,
571
917
 
572
918
  } = mapSectionsFromQuestionAnswer( questionAnswer );
573
919
 
@@ -622,6 +968,9 @@ function buildFromViewChecklistApi( getchecklistData, viewchecklistData, brandIn
622
968
  } );
623
969
  }
624
970
 
971
+ const userImage = checklistAnswer?.userImage || checklistInfo?.userImage || '';
972
+ const userSignature = checklistAnswer?.userSignature || checklistInfo?.userSignature || '';
973
+
625
974
  return {
626
975
 
627
976
  brandLogo: brandInfo.brandLogo || '',
@@ -650,12 +999,22 @@ function buildFromViewChecklistApi( getchecklistData, viewchecklistData, brandIn
650
999
 
651
1000
  numFlags: checklistAnswer?.flagCount ?? flags.length,
652
1001
 
653
- aiBreached: checklistAnswer?.aiBreachedCount ?? 0,
1002
+ showFlags: hasSopFlag,
1003
+
1004
+ runAIFlag: checklistAnswer?.runAIFlag ?? checklistInfo?.runAIFlag ?? checklistAnswer?.aiBreachedCount ?? 0,
1005
+
1006
+ showRunAIFlag: ( checklistInfo?.runAIQuestionCount ?? checklistAnswer?.runAIQuestionCount ?? 0 ) > 0,
654
1007
 
655
1008
  submittedBy: checklistInfo?.submittedBy || storeProfile?.userName || '--',
656
1009
 
657
1010
  country: storeProfile?.Country || '--',
658
1011
 
1012
+ userImage,
1013
+
1014
+ userSignature,
1015
+
1016
+ showUserVerification: Boolean( userImage || userSignature ),
1017
+
659
1018
  hasCompliancePage,
660
1019
 
661
1020
  detailPageStart,
@@ -782,6 +1141,11 @@ export function createImageCache() {
782
1141
  }
783
1142
 
784
1143
 
1144
+ if ( resolvedData.userImage && resolvedData.userImage.startsWith( 'http' ) ) {
1145
+ urls.add( resolvedData.userImage );
1146
+ }
1147
+
1148
+
785
1149
  const collectFromSection = ( section ) => {
786
1150
  section.questions?.forEach( ( q ) => {
787
1151
  if ( q.questionReferenceImage && q.questionReferenceImage.startsWith( 'http' ) ) urls.add( q.questionReferenceImage );
@@ -805,6 +1169,14 @@ export function createImageCache() {
805
1169
  if ( typeof u === 'string' && u.startsWith( 'http' ) ) urls.add( u );
806
1170
  } );
807
1171
  }
1172
+
1173
+ ua.answerMedia?.forEach( ( m ) => {
1174
+ if ( typeof m.url === 'string' && m.url.startsWith( 'http' ) ) urls.add( m.url );
1175
+ } );
1176
+ } );
1177
+
1178
+ q.mediaItems?.forEach( ( m ) => {
1179
+ if ( typeof m.url === 'string' && m.url.startsWith( 'http' ) ) urls.add( m.url );
808
1180
  } );
809
1181
  } );
810
1182
  };
@@ -842,6 +1214,11 @@ export function createImageCache() {
842
1214
  }
843
1215
 
844
1216
 
1217
+ if ( resolvedData.userImage ) {
1218
+ resolvedData.userImage = cache.get( resolvedData.userImage ) || resolvedData.userImage;
1219
+ }
1220
+
1221
+
845
1222
  const replaceInSection = ( section ) => {
846
1223
  section.questions?.forEach( ( q ) => {
847
1224
  if ( q.questionReferenceImage && cache.has( q.questionReferenceImage ) ) q.questionReferenceImage = cache.get( q.questionReferenceImage );
@@ -863,7 +1240,19 @@ export function createImageCache() {
863
1240
  if ( ua.validationDisplayType === 'multiImage' && ua.validationImage?.length ) {
864
1241
  ua.validationImage = ua.validationImage.map( ( u ) => ( cache.has( u ) ? cache.get( u ) : u ) );
865
1242
  }
1243
+
1244
+ if ( ua.answerMedia?.length ) {
1245
+ ua.answerMedia.forEach( ( m ) => {
1246
+ if ( cache.has( m.url ) ) m.url = cache.get( m.url );
1247
+ } );
1248
+ }
866
1249
  } );
1250
+
1251
+ if ( q.mediaItems?.length ) {
1252
+ q.mediaItems.forEach( ( m ) => {
1253
+ if ( cache.has( m.url ) ) m.url = cache.get( m.url );
1254
+ } );
1255
+ }
867
1256
  } );
868
1257
  };
869
1258
 
@@ -936,11 +1325,27 @@ export function resolveTemplateUrls( templateData, baseUrl = 'https://d1r0hc2ssk
936
1325
  ua.validationImage = ua.validationImage.map( ( ele ) => resolveUrl( ele ) );
937
1326
  }
938
1327
 
1328
+ if ( ua?.validationVideo?.length ) {
1329
+ ua.validationVideo = ua.validationVideo.map( ( ele ) => resolveUrl( ele ) );
1330
+ }
1331
+
939
1332
 
940
1333
  if ( ua.answer && ua.answerType !== 'text' ) {
941
1334
  ua.answer = resolveUrl( ua.answer );
942
1335
  }
1336
+
1337
+ if ( ua.answerMedia?.length ) {
1338
+ ua.answerMedia.forEach( ( m ) => {
1339
+ m.url = resolveUrl( m.url );
1340
+ } );
1341
+ }
943
1342
  } );
1343
+
1344
+ if ( q.mediaItems?.length ) {
1345
+ q.mediaItems.forEach( ( m ) => {
1346
+ m.url = resolveUrl( m.url );
1347
+ } );
1348
+ }
944
1349
  } );
945
1350
  };
946
1351
 
@@ -954,11 +1359,14 @@ export function resolveTemplateUrls( templateData, baseUrl = 'https://d1r0hc2ssk
954
1359
 
955
1360
  resolvedData.sections?.forEach( resolveQuestionMedia );
956
1361
 
957
-
958
- if ( resolvedData.brandLogo && !resolvedData.brandLogo.startsWith( 'http' ) ) {
1362
+ if ( resolvedData?.brandLogo && !resolvedData?.brandLogo?.startsWith( 'http' ) ) {
959
1363
  resolvedData.brandLogo = resolveUrl( resolvedData.brandLogo );
960
1364
  }
961
1365
 
1366
+ if ( resolvedData?.userImage && !resolvedData.userImage.startsWith( 'http' ) && !resolvedData.userImage.startsWith( 'data:' ) ) {
1367
+ resolvedData.userImage = resolveUrl( resolvedData.userImage );
1368
+ }
1369
+
962
1370
 
963
1371
  return resolvedData;
964
1372
  }
@@ -1070,10 +1478,26 @@ export async function generateVisitChecklistPDF( templateData, baseUrl = 'https:
1070
1478
  ua.validationImage = ua.validationImage.map( ( ele ) => resolveUrl( ele ) );
1071
1479
  }
1072
1480
 
1481
+ if ( ua.validationVideo?.length ) {
1482
+ ua.validationVideo = ua.validationVideo.map( ( ele ) => resolveUrl( ele ) );
1483
+ }
1484
+
1073
1485
  if ( ua.answer && ua.answerType !== 'text' ) {
1074
1486
  ua.answer = resolveUrl( ua.answer );
1075
1487
  }
1488
+
1489
+ if ( ua.answerMedia?.length ) {
1490
+ ua.answerMedia.forEach( ( m ) => {
1491
+ m.url = resolveUrl( m.url );
1492
+ } );
1493
+ }
1076
1494
  } );
1495
+
1496
+ if ( q.mediaItems?.length ) {
1497
+ q.mediaItems.forEach( ( m ) => {
1498
+ m.url = resolveUrl( m.url );
1499
+ } );
1500
+ }
1077
1501
  } );
1078
1502
  };
1079
1503
 
@@ -1087,6 +1511,10 @@ export async function generateVisitChecklistPDF( templateData, baseUrl = 'https:
1087
1511
  resolvedData.brandLogo = resolveUrl( resolvedData.brandLogo );
1088
1512
  }
1089
1513
 
1514
+ if ( resolvedData.userImage && !resolvedData.userImage.startsWith( 'http' ) && !resolvedData.userImage.startsWith( 'data:' ) ) {
1515
+ resolvedData.userImage = resolveUrl( resolvedData.userImage );
1516
+ }
1517
+
1090
1518
 
1091
1519
  const html = template( resolvedData );
1092
1520