tango-app-api-trax 3.9.34 → 3.9.36

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tango-app-api-trax",
3
- "version": "3.9.34",
3
+ "version": "3.9.36",
4
4
  "description": "Trax",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -18,7 +18,7 @@ import timeZone from 'dayjs/plugin/timezone.js';
18
18
  import utc from 'dayjs/plugin/utc.js';
19
19
  import { logger } from 'tango-app-api-middleware';
20
20
  import mongoose from 'mongoose';
21
- import { sendPushNotification, sendAiPushNotification, sendEmailWithSES, signedUrl, fileUpload, getOpenSearchData } from 'tango-app-api-middleware';
21
+ import { sendPushNotification, sendAiPushNotification, sendEmailWithSES, signedUrl, fileUpload } from 'tango-app-api-middleware';
22
22
  // import * as planoService from '../services/planogram.service.js';
23
23
  import * as clusterServices from '../services/cluster.service.js';
24
24
  import * as teamsServices from '../services/teams.service.js';
@@ -1703,6 +1703,85 @@ export async function saleUpdateCollection( req, res ) {
1703
1703
  }
1704
1704
  };
1705
1705
 
1706
+ /**
1707
+ * One-off migration: in checklistquestionsconfigs, when a question's answerType
1708
+ * is 'multiplechoicesingle' and one of its answers has validationType
1709
+ * 'Capture Image', replace it with 'Capture Multiple Image with description'.
1710
+ *
1711
+ * Document shape: { question: [ { answerType, answers: [ { validationType } ] } ] }
1712
+ * Defaults to a dry run; pass apply:true (body) or ?apply=true to write changes.
1713
+ * @param {Object} req Express request — body.apply / query.apply toggles writing
1714
+ * @param {Object} res Express response
1715
+ * @return {Promise<void>}
1716
+ */
1717
+ export async function updateMultipleChoiceSingleValidationType( req, res ) {
1718
+ try {
1719
+ const TARGET_ANSWER_TYPE = 'multiplechoicesingle';
1720
+ const FROM_VALIDATION_TYPE = 'Capture Image';
1721
+ const TO_VALIDATION_TYPE = 'Capture Multiple Image with description';
1722
+
1723
+ const apply = req.body?.apply === true || req.query?.apply === 'true';
1724
+
1725
+ // Only load docs that actually contain a matching question + answer.
1726
+ const query = {
1727
+ question: {
1728
+ $elemMatch: {
1729
+ answerType: TARGET_ANSWER_TYPE,
1730
+ answers: { $elemMatch: { validationType: FROM_VALIDATION_TYPE } },
1731
+ },
1732
+ },
1733
+ };
1734
+
1735
+ const docs = await CLquestions.find( query );
1736
+
1737
+ let documentsUpdated = 0;
1738
+ let answersUpdated = 0;
1739
+ const updatedIds = [];
1740
+
1741
+ for ( const doc of docs ) {
1742
+ const questions = Array.isArray( doc.question ) ? doc.question : [];
1743
+ let changedInDoc = 0;
1744
+
1745
+ for ( const q of questions ) {
1746
+ if ( !q || q.answerType !== TARGET_ANSWER_TYPE ) continue;
1747
+
1748
+ const answers = Array.isArray( q.answers ) ? q.answers : [];
1749
+ for ( const a of answers ) {
1750
+ if ( a && a.validationType === FROM_VALIDATION_TYPE ) {
1751
+ a.validationType = TO_VALIDATION_TYPE;
1752
+ changedInDoc++;
1753
+ }
1754
+ }
1755
+ }
1756
+
1757
+ if ( changedInDoc > 0 ) {
1758
+ documentsUpdated++;
1759
+ answersUpdated += changedInDoc;
1760
+ updatedIds.push( doc._id );
1761
+
1762
+ // `question` is a Mixed array, so write the whole rebuilt array.
1763
+ if ( apply ) {
1764
+ await CLquestions.updateMany( { _id: doc._id }, { question: questions } );
1765
+ }
1766
+ }
1767
+ }
1768
+
1769
+ return res.sendSuccess( {
1770
+ message: apply ?
1771
+ 'Validation type updated successfully' :
1772
+ 'Dry run — pass apply:true to write these changes',
1773
+ apply,
1774
+ matchedDocuments: docs.length,
1775
+ documentsUpdated,
1776
+ answersUpdated,
1777
+ updatedIds,
1778
+ } );
1779
+ } catch ( error ) {
1780
+ logger.error( { function: 'updateMultipleChoiceSingleValidationType', error: error } );
1781
+ return res.sendError( error, 500 );
1782
+ }
1783
+ };
1784
+
1706
1785
  export async function getUserStoreList( req, res ) {
1707
1786
  try {
1708
1787
  let details = [];
@@ -3601,7 +3680,7 @@ export async function runAIFlag( req, res ) {
3601
3680
  runAIFlag: store?.runAIFlag,
3602
3681
  questionFlag: store?.questionFlag,
3603
3682
  checklistName: store.checkListName?.trim(),
3604
- submittedBy: store?.userName,
3683
+ submittedBy: store.timeFlag ? '--' : store?.userName,
3605
3684
  time: store?.submitTime_string ?? '--',
3606
3685
  domain: `${JSON.parse( process.env.URL ).domain}/manage/trax/flags?date=${dayjs().format( 'YYYY-MM-DD' )}`,
3607
3686
  status: store.checklistStatus,
@@ -3667,31 +3746,36 @@ export const downloadInsertPdf = async ( req, res ) => {
3667
3746
  const safeName = ( str ) =>
3668
3747
  ( str || '' ).toString().replace( /[<>:"/\\|?*]+/g, '_' );
3669
3748
 
3670
- const query = {
3671
- query: {
3672
- bool: {
3673
- must: [
3674
- {
3675
- term: {
3676
- _id: req.body.checklistId,
3677
- },
3678
- },
3679
- ],
3680
- },
3681
- },
3682
- };
3749
+ // const query = {
3750
+ // query: {
3751
+ // bool: {
3752
+ // must: [
3753
+ // {
3754
+ // term: {
3755
+ // _id: req.body.checklistId,
3756
+ // },
3757
+ // },
3758
+ // ],
3759
+ // },
3760
+ // },
3761
+ // };
3762
+
3683
3763
 
3684
3764
  // 1) Launch browser page + fetch OpenSearch data in parallel
3685
3765
  const [ browser, aiDetails ] = await Promise.all( [
3686
3766
  getBrowserInstance(),
3687
- getOpenSearchData( JSON.parse( process.env.OPENSEARCH ).traxIndex, query ),
3767
+ // getOpenSearchData( JSON.parse( process.env.OPENSEARCH ).traxIndex, query ),
3768
+ processedchecklist.findOne( { _id: req.body.checklistId } ),
3688
3769
  ] );
3770
+ console.log( aiDetails );
3689
3771
 
3690
- if ( aiDetails?.statusCode != 200 || !aiDetails?.body?.hits?.hits.length ) {
3772
+ if ( !aiDetails ) {
3691
3773
  return res.sendError( 'Checklist not found', 404 );
3692
3774
  }
3693
3775
 
3694
- const doc = { ...aiDetails.body.hits.hits[0]._source };
3776
+ const doc = { ...aiDetails?.toObject() };
3777
+
3778
+ console.log( doc );
3695
3779
 
3696
3780
  // 2) Fetch brandInfo + compliance data in parallel
3697
3781
  const complianceURL = JSON.parse( process.env.LAMBDAURL ).complianceHistory;
@@ -428,14 +428,19 @@ export const checklistPerformance = async ( req, res ) => {
428
428
  $cond: [
429
429
  { $gt: [ '$$divisor', 0 ] },
430
430
  {
431
- $round: [
431
+ $max: [
432
+ 0,
432
433
  {
433
- $multiply: [
434
- { $divide: [ '$userComplianceCountTotal', '$$divisor' ] },
435
- 100,
434
+ $round: [
435
+ {
436
+ $multiply: [
437
+ { $divide: [ '$userComplianceCountTotal', '$$divisor' ] },
438
+ 100,
439
+ ],
440
+ },
441
+ 0,
436
442
  ],
437
443
  },
438
- 0,
439
444
  ],
440
445
  },
441
446
  0,
@@ -463,7 +468,7 @@ export const checklistPerformance = async ( req, res ) => {
463
468
  },
464
469
  } );
465
470
  let getChecklistPerformanceData = await processedchecklistService.aggregate( findQuery );
466
- if ( !getChecklistPerformanceData[0].data.length ) {
471
+ if ( !getChecklistPerformanceData[0].count[0].total ) {
467
472
  return res.sendError( 'no data found', 204 );
468
473
  }
469
474
 
@@ -56,14 +56,16 @@
56
56
  .dp-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;padding-bottom:12px;border-bottom:2px solid #00AEEF}
57
57
  .dp-header h2{font-size:18px;font-weight:700;color:#1a1a2e}
58
58
  .dp-score{font-size:15px;font-weight:700;color:#000000}
59
- .q-row{display:flex;gap:12px;margin-bottom:14px;padding:12px;border-radius:8px;background:transparent}
60
- .q-num{font-size:12px;font-weight:700;color:#000;min-width:22px}
61
- .q-body{flex:1}
59
+ .q-row{position:relative;margin-bottom:14px;padding:12px 12px 12px 40px;border-radius:8px;background:transparent}
60
+ .q-num{position:absolute;left:12px;top:12px;font-size:12px;font-weight:700;color:#000}
62
61
  .q-text{font-size:13px;color:#444;margin-bottom:4px}
63
62
  .q-ans{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;padding:2px 8px;border-radius:12px}
64
63
  .ans-yes{background:#e1f5ee;color:#0f6e56}
65
64
  .ans-no{background:#fcebeb;color:#a32d2d}
66
65
  .flag-badge{background:#faeeda;color:#854f0b;font-size:10px;font-weight:600;padding:1px 6px;border-radius:8px;margin-left:6px}
66
+ .det-badge{display:inline-block;font-size:10px;font-weight:600;padding:2px 8px;border-radius:10px;white-space:nowrap}
67
+ .det-matched{background:#e1f5ee;color:#0f6e56}
68
+ .det-notmatched{background:#fcebeb;color:#a32d2d}
67
69
  .q-answer-list{margin-top:6px}
68
70
  .q-answer-item{margin-top:6px;padding:0;background:transparent;border:none;border-radius:0}
69
71
  .q-answer-text{font-size:12px;color:#1a1a1a;line-height:1.5}
@@ -72,6 +74,11 @@
72
74
  .q-answer-media img,.q-answer-media video,.q-answer-item td img{display:block;width:200px;height:180px;object-fit:cover;border-radius:6px;margin-bottom:6px}
73
75
  .img-grid{display:flex;flex-wrap:wrap;gap:8px}
74
76
  .img-grid img{margin-bottom:0}
77
+ .answer-media-grid{margin-top:8px;font-size:0}
78
+ .answer-media-cell{display:inline-block;width:calc(50% - 6px);vertical-align:top;margin-bottom:12px;break-inside:avoid}
79
+ .answer-media-cell:nth-child(2n+1){margin-right:12px}
80
+ .answer-media-cell .q-answer-caption{margin-bottom:4px}
81
+ .answer-media-cell img{display:block;width:100%;height:200px;object-fit:fill;border-radius:6px;margin-bottom:0}
75
82
  .q-answer-link{font-size:12px;color:#0085D2;text-decoration:underline;word-break:break-all}
76
83
  .q-answer-caption{font-size:11px;color:#666;margin-bottom:4px}
77
84
  .q-answer-remarks{font-size:11px;color:#666;margin-top:6px;white-space:pre-line}
@@ -182,10 +189,10 @@
182
189
  <div class="q-row">
183
190
  <span class="q-num">{{this.qno}}</span>
184
191
  <div class="q-body">
185
- <div class="q-text" style="display:flex;justify-content:space-between">
192
+ <div class="q-text" style="display:flex;justify-content:space-between;align-items:center;gap:8px">
186
193
  <div>{{this.qname}}</div>
187
194
  {{#if this.compliance}}
188
- <div>Score:{{this.score}}</div>
195
+ <div style="flex-shrink:0">Score:{{this.score}}</div>
189
196
  {{/if}}
190
197
  </div>
191
198
  {{#if this.questionReferenceImage}}
@@ -211,7 +218,7 @@
211
218
  <div class="q-answer-item">
212
219
  {{#eq this.answerType 'text'}}
213
220
  {{#if this.answer}}
214
- <div class="q-answer-text {{#if this.sopFlag}}flagged{{/if}}">{{this.answer}}</div>
221
+ <div class="q-answer-text {{#if this.sopFlag}}flagged{{/if}}">{{this.answer}}{{#if this.showDetectionStatus}} <span class="det-badge {{#eq this.detectionStatus 'Matched'}}det-matched{{else}}det-notmatched{{/eq}}">{{this.detectionStatus}}</span>{{/if}}</div>
215
222
  {{/if}}
216
223
  {{/eq}}
217
224
  {{#eq this.answerType 'video'}}
@@ -222,6 +229,20 @@
222
229
  </div>
223
230
  {{/if}}
224
231
  {{/eq}}
232
+ {{!-- This answer's own images (reference / uploaded / validation), grouped with its label, two per row --}}
233
+ {{#if this.answerMedia.length}}
234
+ <div class="answer-media-grid">
235
+ {{#each this.answerMedia}}
236
+ <div class="answer-media-cell">
237
+ <div class="q-answer-caption" style="display:flex;justify-content:space-between;align-items:center;gap:6px">
238
+ <span>{{this.label}}</span>
239
+ {{#if this.detectionStatus}}<span class="det-badge {{#eq this.detectionStatus 'Matched'}}det-matched{{else}}det-notmatched{{/eq}}">{{this.detectionStatus}}</span>{{/if}}
240
+ </div>
241
+ <img src="{{this.url}}" alt="{{this.label}}" />
242
+ </div>
243
+ {{/each}}
244
+ </div>
245
+ {{/if}}
225
246
  {{#eq this.validationDisplayType 'text'}}
226
247
  {{#if this.validationAnswer}}
227
248
  <div class="q-answer-text {{#if this.sopFlag}}flagged{{/if}}">validation Answer: {{this.validationAnswer}}</div>
@@ -234,127 +255,36 @@
234
255
  {{/if}}
235
256
  {{/eq}}
236
257
 
237
- <table style="width:100%;margin-top:8px;table-layout:fixed"><tr>
238
- <td style="width:50%;vertical-align:top;padding-right:8px">
239
- {{#neq ../answerType 'image/video'}}
240
- {{#neq ../answerType 'multipleImage'}}
241
- {{#if this.multiReferenceImage.length}}
242
- <div class="q-answer-media">
243
- <div class="q-answer-caption">Reference Images</div>
244
- {{#each this.multiReferenceImage}}
245
- <img src="{{this}}" alt="Reference Image" />
246
- {{/each}}
247
- </div>
248
- {{else}}
249
- {{#if this.referenceImage}}
250
- <div class="q-answer-media">
251
- <div class="q-answer-caption">Reference Image</div>
252
- <img src="{{this.referenceImage}}" alt="Reference Image" />
253
- </div>
254
- {{else}}
255
- {{#eq this.answerType 'image'}}
256
- {{#if this.answer}}
257
- <div class="q-answer-media">
258
- <div class="q-answer-caption">Uploaded Image</div>
259
- <img src="{{this.answer}}" alt="Uploaded Image" />
260
- </div>
261
- {{/if}}
262
- {{/eq}}
263
- {{/if}}
264
- {{/if}}
265
- {{else}}
266
- {{#eq this.answerType 'image'}}
267
- {{#if this.answer}}
268
- <div class="q-answer-media">
269
- <div class="q-answer-caption">Uploaded Image</div>
270
- <img src="{{this.answer}}" alt="Uploaded Image" />
271
- </div>
272
- {{/if}}
273
- {{/eq}}
274
- {{/neq}}
275
- {{else}}
276
- {{#eq this.answerType 'image'}}
277
- {{#if this.answer}}
278
- <div class="q-answer-media">
279
- <div class="q-answer-caption">Uploaded Image</div>
280
- <img src="{{this.answer}}" alt="Uploaded Image" />
281
- </div>
282
- {{/if}}
283
- {{/eq}}
284
- {{/neq}}
285
- {{#unless this.hasReferenceImage}}
286
- {{#if this.validation}}
287
- {{#eq this.validationDisplayType 'image'}}
288
- {{#if this.validationAnswer}}
289
- <div class="q-answer-caption">Validation Image</div>
290
- <img src="{{this.validationAnswer}}" alt="Validation Image" />
291
- {{/if}}
292
- {{/eq}}
293
- {{#eq this.validationDisplayType 'multiImage'}}
294
- {{#if this.validationImage.length}}
295
- <div class="q-answer-caption">Validation Image</div>
296
- {{#each this.validationImage}}
297
- <img src="{{this}}" alt="Validation Image" />
298
- {{/each}}
299
- {{/if}}
300
- {{#if this.validationVideo.length}}
301
- <div class="q-answer-caption">Validation Video</div>
302
- {{#each this.validationVideo}}
303
- <a class="q-answer-link" href="{{this}}" target="_blank">{{this}}</a>
304
- {{/each}}
305
- {{/if}}
306
- {{/eq}}
307
- {{/if}}
308
- {{/unless}}
309
- </td>
310
- <td style="width:50%;vertical-align:top;padding-left:8px">
311
- {{#eq this.answerType 'image'}}
312
- {{#if this.answer}}
313
- {{#neq ../answerType 'image/video'}}
314
- {{#neq ../answerType 'multipleImage'}}
315
- {{#if this.multiReferenceImage.length}}
316
- <div class="q-answer-caption">Uploaded Image</div>
317
- <img src="{{this.answer}}" alt="Uploaded Image" />
318
- {{else}}
319
- {{#if this.referenceImage}}
320
- <div class="q-answer-caption">Uploaded Image</div>
321
- <img src="{{this.answer}}" alt="Uploaded Image" />
322
- {{/if}}
323
- {{/if}}
324
- {{/neq}}
325
- {{/neq}}
326
- {{/if}}
327
- {{/eq}}
328
- {{#if this.hasReferenceImage}}
329
- {{#if this.validation}}
330
- {{#eq this.validationDisplayType 'image'}}
331
- {{#if this.validationAnswer}}
332
- <div class="q-answer-caption">Validation Image</div>
333
- <img src="{{this.validationAnswer}}" alt="Validation Image" />
334
- {{/if}}
335
- {{/eq}}
336
- {{#eq this.validationDisplayType 'multiImage'}}
337
- {{#if this.validationImage.length}}
338
- <div class="q-answer-caption">Validation Image</div>
339
- {{#each this.validationImage}}
340
- <img src="{{this}}" alt="Validation Image" />
341
- {{/each}}
342
- {{/if}}
343
- {{#if this.validationVideo.length}}
344
- <div class="q-answer-caption">Validation Video</div>
345
- {{#each this.validationVideo}}
346
- <a class="q-answer-link" href="{{this}}" target="_blank">{{this}}</a>
347
- {{/each}}
348
- {{/if}}
349
- {{/eq}}
350
- {{/if}}
351
- {{/if}}
352
- </td>
353
- </tr></table>
258
+ {{!-- Validation videos captured with multi-image validation (shown as links) --}}
259
+ {{#eq this.validationDisplayType 'multiImage'}}
260
+ {{#if this.validationVideo.length}}
261
+ <div class="q-answer-media">
262
+ <div class="q-answer-caption">Validation Video</div>
263
+ {{#each this.validationVideo}}
264
+ <a class="q-answer-link" href="{{this}}" target="_blank">{{this}}</a>
265
+ {{/each}}
266
+ </div>
267
+ {{/if}}
268
+ {{/eq}}
269
+
354
270
  </div>
355
271
  {{/each}}
356
272
  </div>
357
273
  {{/if}}
274
+ {{!-- All images for this question (reference / uploaded / validation), two per row --}}
275
+ {{#if this.mediaItems.length}}
276
+ <div class="answer-media-grid">
277
+ {{#each this.mediaItems}}
278
+ <div class="answer-media-cell">
279
+ <div class="q-answer-caption" style="display:flex;justify-content:space-between;align-items:center;gap:6px">
280
+ <span>{{this.label}}</span>
281
+ {{#if this.detectionStatus}}<span class="det-badge {{#eq this.detectionStatus 'Matched'}}det-matched{{else}}det-notmatched{{/eq}}">{{this.detectionStatus}}</span>{{/if}}
282
+ </div>
283
+ <img src="{{this.url}}" alt="{{this.label}}" />
284
+ </div>
285
+ {{/each}}
286
+ </div>
287
+ {{/if}}
358
288
  {{#if this.remarks}}
359
289
  <div class="q-answer-remarks">Remarks: {{this.remarks}}</div>
360
290
  {{/if}}
@@ -370,7 +300,7 @@
370
300
  <img class="user-verify-photo" src="{{userImage}}" alt="Submitted by photo" />
371
301
  {{/if}}
372
302
  {{#if submittedBy}}
373
- <div class="user-verify-name">{{submittedBy}}</div>
303
+ <div class="user-verify-name">{{userSignature}}</div>
374
304
  {{/if}}
375
305
  </div>
376
306
  {{/if}}
@@ -13,6 +13,7 @@ internalTraxRouter
13
13
  .get( '/list', isAllowedInternalAPIHandler, internalController.list )
14
14
  .post( '/updateAomDetails', isAllowedInternalAPIHandler, internalController.aomupdateCollection )
15
15
  .post( '/updateSaleManagerDetails', isAllowedInternalAPIHandler, internalController.saleUpdateCollection )
16
+ .post( '/updateValidationType', isAllowedInternalAPIHandler, internalController.updateMultipleChoiceSingleValidationType )
16
17
  .get( '/profile', isAllowedMobileSessionHandler, internalController.getUserStoreList )
17
18
  .post( '/pushNotification', isAllowedInternalAPIHandler, internalController.pushNotification )
18
19
  .post( '/taskPushNotification', isAllowedInternalAPIHandler, internalController.taskPushNotification )
@@ -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 = [];
@@ -190,7 +238,21 @@ function buildQuestionAnswerEntries( question ) {
190
238
  question?.answerType !== 'multipleImage' &&
191
239
  Boolean( referenceImage || multiReferenceImage.length );
192
240
 
193
- return {
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 = {
194
256
  answer: userAnswer?.answer || '',
195
257
  answerType: getMediaDisplayType( question?.answerType, userAnswer ),
196
258
  referenceImage,
@@ -198,6 +260,9 @@ function buildQuestionAnswerEntries( question ) {
198
260
  multiReferenceImage,
199
261
  remarks: userAnswer?.remarks || '',
200
262
  sopFlag: userAnswer?.sopFlag ?? matchedAnswer?.sopFlag ?? false,
263
+ detectionStatus,
264
+ imageStatus,
265
+ runAIValues,
201
266
  validation,
202
267
  validationType,
203
268
  validationAnswer,
@@ -205,7 +270,90 @@ function buildQuestionAnswerEntries( question ) {
205
270
  validationVideo,
206
271
  validationDisplayType: getValidationDisplayType( validationType ),
207
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
+ }
208
354
  } );
355
+
356
+ return items;
209
357
  }
210
358
 
211
359
 
@@ -236,6 +384,125 @@ function getRunAIMatchValues( runAIData ) {
236
384
  }
237
385
 
238
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';
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';
503
+ }
504
+
505
+
239
506
  /**
240
507
 
241
508
  * Map section array { sectionName, questions[] } (view API or processed checklist) to scores / template sections.
@@ -257,6 +524,8 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
257
524
 
258
525
  const flags = [];
259
526
 
527
+ let hasSopFlag = false;
528
+
260
529
 
261
530
  ( questionAnswer || [] ).forEach( ( section, sectionIdx ) => {
262
531
  let sectionScore = 0;
@@ -278,6 +547,10 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
278
547
 
279
548
  const ua = userAnswersWithRef[0];
280
549
 
550
+ if ( !hasSopFlag && userAnswersWithRef.some( ( entry ) => entry.sopFlag === true ) ) {
551
+ hasSopFlag = true;
552
+ }
553
+
281
554
 
282
555
  const max = q.compliance ? Math.max( ...q?.answers.map( ( o ) => o?.complianceScore ?? Math.max( o?.matchedCount ?? 0, o?.notMatchedCount ?? 0 ) ) ) : 0;
283
556
 
@@ -301,6 +574,22 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
301
574
  }
302
575
 
303
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';
589
+ }
590
+ }
591
+
592
+
304
593
  sectionScore += score;
305
594
 
306
595
  sectionMax += max;
@@ -358,6 +647,8 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
358
647
 
359
648
  answerDisplay: isYes ? 'Yes' : ( isNo ? 'No' : ( answerText && answerText.startsWith( 'http' ) ? 'Image' : ( answerText || '—' ) ) ),
360
649
 
650
+ mediaItems: buildQuestionMediaItems( userAnswersWithRef, aggregateDetectionStatus ),
651
+
361
652
  userAnswer: userAnswersWithRef.length ? userAnswersWithRef : [ {
362
653
  answer: '',
363
654
  answerType: 'text',
@@ -381,15 +672,15 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
381
672
 
382
673
  sectionName: section.sectionName || `Section ${sectionIdx + 1}`,
383
674
 
384
- targetScore: sectionMax,
675
+ targetScore: sectionMax ? sectionMax : '--',
385
676
 
386
- actualScore: sectionScore,
677
+ actualScore: sectionMax ? sectionScore : '--',
387
678
 
388
679
  questionsCount,
389
680
 
390
681
  passedCount,
391
682
 
392
- percentage: sectionMax > 0 ? Math.round( ( sectionScore / sectionMax ) * 100 ) : 0,
683
+ percentage: sectionMax > 0 ? Math.round( ( sectionScore / sectionMax ) * 100 ) : '--',
393
684
 
394
685
  } );
395
686
 
@@ -415,7 +706,7 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
415
706
  const numQuestions = questionAnswer.reduce( ( sum, s ) => sum + ( s.questions?.length || 0 ), 0 );
416
707
 
417
708
 
418
- return { totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions };
709
+ return { totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions, hasSopFlag };
419
710
  }
420
711
 
421
712
 
@@ -437,7 +728,7 @@ export function buildVisitChecklistTemplateDataFromProcessed( processedDoc, bran
437
728
 
438
729
  const {
439
730
 
440
- totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions,
731
+ totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions, hasSopFlag,
441
732
 
442
733
  } = mapSectionsFromQuestionAnswer( questionAnswer );
443
734
 
@@ -551,7 +842,11 @@ export function buildVisitChecklistTemplateDataFromProcessed( processedDoc, bran
551
842
 
552
843
  numFlags: typeof doc.questionFlag === 'number' ? doc.questionFlag : flags.length,
553
844
 
554
- 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,
555
850
 
556
851
  submittedBy: doc.userName || '--',
557
852
 
@@ -618,7 +913,7 @@ function buildFromViewChecklistApi( getchecklistData, viewchecklistData, brandIn
618
913
 
619
914
  const {
620
915
 
621
- totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions,
916
+ totalScore, maxScore, sectionInsights, questionAnswers, flags, numQuestions, hasSopFlag,
622
917
 
623
918
  } = mapSectionsFromQuestionAnswer( questionAnswer );
624
919
 
@@ -704,7 +999,11 @@ function buildFromViewChecklistApi( getchecklistData, viewchecklistData, brandIn
704
999
 
705
1000
  numFlags: checklistAnswer?.flagCount ?? flags.length,
706
1001
 
707
- 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,
708
1007
 
709
1008
  submittedBy: checklistInfo?.submittedBy || storeProfile?.userName || '--',
710
1009
 
@@ -847,11 +1146,6 @@ export function createImageCache() {
847
1146
  }
848
1147
 
849
1148
 
850
- if ( resolvedData.userSignature && resolvedData.userSignature.startsWith( 'http' ) ) {
851
- urls.add( resolvedData.userSignature );
852
- }
853
-
854
-
855
1149
  const collectFromSection = ( section ) => {
856
1150
  section.questions?.forEach( ( q ) => {
857
1151
  if ( q.questionReferenceImage && q.questionReferenceImage.startsWith( 'http' ) ) urls.add( q.questionReferenceImage );
@@ -875,6 +1169,14 @@ export function createImageCache() {
875
1169
  if ( typeof u === 'string' && u.startsWith( 'http' ) ) urls.add( u );
876
1170
  } );
877
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 );
878
1180
  } );
879
1181
  } );
880
1182
  };
@@ -917,11 +1219,6 @@ export function createImageCache() {
917
1219
  }
918
1220
 
919
1221
 
920
- if ( resolvedData.userSignature ) {
921
- resolvedData.userSignature = cache.get( resolvedData.userSignature ) || resolvedData.userSignature;
922
- }
923
-
924
-
925
1222
  const replaceInSection = ( section ) => {
926
1223
  section.questions?.forEach( ( q ) => {
927
1224
  if ( q.questionReferenceImage && cache.has( q.questionReferenceImage ) ) q.questionReferenceImage = cache.get( q.questionReferenceImage );
@@ -943,7 +1240,19 @@ export function createImageCache() {
943
1240
  if ( ua.validationDisplayType === 'multiImage' && ua.validationImage?.length ) {
944
1241
  ua.validationImage = ua.validationImage.map( ( u ) => ( cache.has( u ) ? cache.get( u ) : u ) );
945
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
+ }
946
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
+ }
947
1256
  } );
948
1257
  };
949
1258
 
@@ -1024,7 +1333,19 @@ export function resolveTemplateUrls( templateData, baseUrl = 'https://d1r0hc2ssk
1024
1333
  if ( ua.answer && ua.answerType !== 'text' ) {
1025
1334
  ua.answer = resolveUrl( ua.answer );
1026
1335
  }
1336
+
1337
+ if ( ua.answerMedia?.length ) {
1338
+ ua.answerMedia.forEach( ( m ) => {
1339
+ m.url = resolveUrl( m.url );
1340
+ } );
1341
+ }
1027
1342
  } );
1343
+
1344
+ if ( q.mediaItems?.length ) {
1345
+ q.mediaItems.forEach( ( m ) => {
1346
+ m.url = resolveUrl( m.url );
1347
+ } );
1348
+ }
1028
1349
  } );
1029
1350
  };
1030
1351
 
@@ -1046,10 +1367,6 @@ export function resolveTemplateUrls( templateData, baseUrl = 'https://d1r0hc2ssk
1046
1367
  resolvedData.userImage = resolveUrl( resolvedData.userImage );
1047
1368
  }
1048
1369
 
1049
- if ( resolvedData?.userSignature && !resolvedData.userSignature.startsWith( 'http' ) && !resolvedData.userSignature.startsWith( 'data:' ) ) {
1050
- resolvedData.userSignature = resolveUrl( resolvedData.userSignature );
1051
- }
1052
-
1053
1370
 
1054
1371
  return resolvedData;
1055
1372
  }
@@ -1168,7 +1485,19 @@ export async function generateVisitChecklistPDF( templateData, baseUrl = 'https:
1168
1485
  if ( ua.answer && ua.answerType !== 'text' ) {
1169
1486
  ua.answer = resolveUrl( ua.answer );
1170
1487
  }
1488
+
1489
+ if ( ua.answerMedia?.length ) {
1490
+ ua.answerMedia.forEach( ( m ) => {
1491
+ m.url = resolveUrl( m.url );
1492
+ } );
1493
+ }
1171
1494
  } );
1495
+
1496
+ if ( q.mediaItems?.length ) {
1497
+ q.mediaItems.forEach( ( m ) => {
1498
+ m.url = resolveUrl( m.url );
1499
+ } );
1500
+ }
1172
1501
  } );
1173
1502
  };
1174
1503
 
@@ -1186,10 +1515,6 @@ export async function generateVisitChecklistPDF( templateData, baseUrl = 'https:
1186
1515
  resolvedData.userImage = resolveUrl( resolvedData.userImage );
1187
1516
  }
1188
1517
 
1189
- if ( resolvedData.userSignature && !resolvedData.userSignature.startsWith( 'http' ) && !resolvedData.userSignature.startsWith( 'data:' ) ) {
1190
- resolvedData.userSignature = resolveUrl( resolvedData.userSignature );
1191
- }
1192
-
1193
1518
 
1194
1519
  const html = template( resolvedData );
1195
1520