tango-app-api-trax 3.9.35 → 3.9.37

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.35",
3
+ "version": "3.9.37",
4
4
  "description": "Trax",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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 = [];
@@ -2689,16 +2768,16 @@ export async function countUpdateRunAI( req, res ) {
2689
2768
  if ( !req.body.id ) {
2690
2769
  return res.sendError( 'Checklist id is required', 400 );
2691
2770
  }
2692
- if ( !req.body.runAICount ) {
2693
- return res.sendError( 'runAICount is required', 400 );
2694
- }
2771
+ // if ( !req.body.runAICount ) {
2772
+ // return res.sendError( 'runAICount is required', 400 );
2773
+ // }
2695
2774
  let getDetails = await processedchecklist.findOne( { _id: req.body.id } );
2696
2775
  if ( !getDetails ) {
2697
2776
  return res.sendError( 'No data found', 204 );
2698
2777
  }
2699
2778
 
2700
2779
  let updateData = {
2701
- runAIFlag: req.body.runAICount,
2780
+ runAIFlag: req.body?.runAICount ?? 0,
2702
2781
  };
2703
2782
 
2704
2783
  // complianceCount is already populated for the other answer types; add the image/video scores on top.
@@ -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,
@@ -4044,7 +4044,7 @@ async function insertPCBulkV4( getCLconfig, checklistId, currentdate, updatedche
4044
4044
  }, { userId: 1, store_id: 1 } );
4045
4045
 
4046
4046
  if ( inprogressData.length ) {
4047
- await processedchecklist.updateMany( { _id: { $in: inprogressData.map( ( ele ) => new ObjectId( ele._id ) ) } }, { scheduleEndTime: getCLconfig.scheduleEndTime, scheduleEndTime_iso: endTimeIso.format() } );
4047
+ await processedchecklist.updateMany( { _id: { $in: inprogressData.map( ( ele ) => new ObjectId( ele._id ) ) } }, { scheduleEndTime: getCLconfig.scheduleEndTime, scheduleEndTime_iso: endTimeIso.format(), complianceCount: getCLconfig.complianceCount, allowedOverTime: getCLconfig.allowedOverTime, allowedStoreLocation: getCLconfig.allowedStoreLocation, userVerification: getCLconfig.userVerification } );
4048
4048
  inprogressData.forEach( ( item ) => {
4049
4049
  let checkData = assignUserList.find( ( ele ) => ele.userId.toString() == item.userId.toString() && ele.store_id == item.store_id );
4050
4050
  if ( !checkData ) {
@@ -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,10 +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}
75
- .answer-media-grid{display:flex;flex-wrap:wrap;gap:12px;margin-top:8px}
76
- .answer-media-cell{width:calc(50% - 6px)}
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}
77
80
  .answer-media-cell .q-answer-caption{margin-bottom:4px}
78
- .answer-media-cell img{display:block;width:100%;height:200px;object-fit:cover;border-radius:6px;margin-bottom:0}
81
+ .answer-media-cell img{display:block;width:100%;height:200px;object-fit:fill;border-radius:6px;margin-bottom:0}
79
82
  .q-answer-link{font-size:12px;color:#0085D2;text-decoration:underline;word-break:break-all}
80
83
  .q-answer-caption{font-size:11px;color:#666;margin-bottom:4px}
81
84
  .q-answer-remarks{font-size:11px;color:#666;margin-top:6px;white-space:pre-line}
@@ -86,7 +89,6 @@
86
89
  .user-verify-name{font-size:14px;color:#1a1a1a;font-weight:600}
87
90
  .user-verify-sign-label{font-size:12px;color:#666;margin-top:16px;margin-bottom:6px;font-weight:600}
88
91
  .user-verify-signature{display:block;width:220px;height:auto;max-height:110px;object-fit:contain;border:1px solid #eee;border-radius:6px;padding:6px;background:#fff}
89
- .user-verify-signature-text{display:inline-block;min-width:180px;font-size:15px;font-weight:600;color:#1a1a1a;padding-bottom:6px;border-bottom:1px solid #d9d9d9;word-break:break-word}
90
92
  /* Footer */
91
93
  .page-footer{position:absolute;bottom:20px;left:40px;right:40px;display:flex;justify-content:space-between;align-items:center;font-size:11px;color:#999;border-top:1px solid #d9d9d9;padding-top:10px}
92
94
  .footer-brand{display:flex;align-items:center;gap:8px;font-weight:600;color:#0066CC;font-size:11px}
@@ -124,12 +126,8 @@
124
126
 
125
127
  <div class="cover-summary">
126
128
  <div class="cover-sum-row"><span class="cover-sum-label">No. of questions</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{numQuestions}}</span></div>
127
- {{#if showFlags}}
128
- <div class="cover-sum-row"><span class="cover-sum-label">Question flags</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{numFlags}}</span></div>
129
- {{/if}}
130
- {{#if showRunAIFlag}}
131
- <div class="cover-sum-row"><span class="cover-sum-label">Run AI flags</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{runAIFlag}}</span></div>
132
- {{/if}}
129
+ <div class="cover-sum-row"><span class="cover-sum-label">No. of flags</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{numFlags}}</span></div>
130
+ {{!-- <div class="cover-sum-row"><span class="cover-sum-label">AI Breached</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{aiBreached}}</span></div> --}}
133
131
  <div class="cover-sum-row"><span class="cover-sum-label">Submitted By</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{submittedBy}}</span></div>
134
132
  <div class="cover-sum-row"><span class="cover-sum-label">Country</span><span class="cover-sum-colon">:</span><span class="cover-sum-val">{{country}}</span></div>
135
133
  </div>
@@ -191,10 +189,10 @@
191
189
  <div class="q-row">
192
190
  <span class="q-num">{{this.qno}}</span>
193
191
  <div class="q-body">
194
- <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">
195
193
  <div>{{this.qname}}</div>
196
194
  {{#if this.compliance}}
197
- <div>Score:{{this.score}}</div>
195
+ <div style="flex-shrink:0">Score:{{this.score}}</div>
198
196
  {{/if}}
199
197
  </div>
200
198
  {{#if this.questionReferenceImage}}
@@ -220,7 +218,7 @@
220
218
  <div class="q-answer-item">
221
219
  {{#eq this.answerType 'text'}}
222
220
  {{#if this.answer}}
223
- <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>
224
222
  {{/if}}
225
223
  {{/eq}}
226
224
  {{#eq this.answerType 'video'}}
@@ -231,6 +229,20 @@
231
229
  </div>
232
230
  {{/if}}
233
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}}
234
246
  {{#eq this.validationDisplayType 'text'}}
235
247
  {{#if this.validationAnswer}}
236
248
  <div class="q-answer-text {{#if this.sopFlag}}flagged{{/if}}">validation Answer: {{this.validationAnswer}}</div>
@@ -243,6 +255,7 @@
243
255
  {{/if}}
244
256
  {{/eq}}
245
257
 
258
+ {{!-- Validation videos captured with multi-image validation (shown as links) --}}
246
259
  {{#eq this.validationDisplayType 'multiImage'}}
247
260
  {{#if this.validationVideo.length}}
248
261
  <div class="q-answer-media">
@@ -253,20 +266,25 @@
253
266
  </div>
254
267
  {{/if}}
255
268
  {{/eq}}
269
+
256
270
  </div>
257
271
  {{/each}}
272
+ </div>
273
+ {{/if}}
274
+ {{!-- All images for this question (reference / uploaded / validation), two per row --}}
258
275
  {{#if this.mediaItems.length}}
259
276
  <div class="answer-media-grid">
260
277
  {{#each this.mediaItems}}
261
278
  <div class="answer-media-cell">
262
- <div class="q-answer-caption">{{this.label}}</div>
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>
263
283
  <img src="{{this.url}}" alt="{{this.label}}" />
264
284
  </div>
265
285
  {{/each}}
266
286
  </div>
267
287
  {{/if}}
268
- </div>
269
- {{/if}}
270
288
  {{#if this.remarks}}
271
289
  <div class="q-answer-remarks">Remarks: {{this.remarks}}</div>
272
290
  {{/if}}
@@ -281,8 +299,8 @@
281
299
  {{#if userImage}}
282
300
  <img class="user-verify-photo" src="{{userImage}}" alt="Submitted by photo" />
283
301
  {{/if}}
284
- {{#if userSignature}}
285
- <div class="user-verify-signature-text">{{userSignature}}</div>
302
+ {{#if submittedBy}}
303
+ <div class="user-verify-name">{{userSignature}}</div>
286
304
  {{/if}}
287
305
  </div>
288
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,6 +270,19 @@ 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;
208
286
  } );
209
287
  }
210
288
 
@@ -216,21 +294,38 @@ function buildQuestionAnswerEntries( question ) {
216
294
  * collected here. When a question has no reference image, the uploaded images simply
217
295
  * fill the grid from the start (taking the reference slot).
218
296
  * @param {Array} userAnswers entries produced by buildQuestionAnswerEntries
219
- * @return {Array} list of { type, label, url } media items in render order
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
220
300
  */
221
- function buildQuestionMediaItems( userAnswers = [] ) {
301
+ function buildQuestionMediaItems( userAnswers = [], forcedStatus = '' ) {
222
302
  const items = [];
223
303
  const seen = new Set();
224
304
 
225
- const push = ( type, label, url ) => {
305
+ const push = ( type, label, url, detectionStatus = '' ) => {
226
306
  if ( !url || typeof url !== 'string' ) return;
227
307
  if ( seen.has( url ) ) return;
228
308
  seen.add( url );
229
- items.push( { type, label, url } );
309
+ items.push( { type, label, url, detectionStatus } );
230
310
  };
231
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
+
232
324
  // 1. Reference images first (deduped, so a single shared reference shows once).
233
- userAnswers.forEach( ( ua ) => {
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;
234
329
  if ( Array.isArray( ua.multiReferenceImage ) && ua.multiReferenceImage.length ) {
235
330
  ua.multiReferenceImage.forEach( ( url ) => push( 'reference', 'Reference Image', url ) );
236
331
  } else if ( ua.referenceImage ) {
@@ -238,23 +333,23 @@ function buildQuestionMediaItems( userAnswers = [] ) {
238
333
  }
239
334
  } );
240
335
 
241
- // 2. Uploaded images.
242
- userAnswers.forEach( ( ua ) => {
336
+ // 2. Uploaded images — tagged with each image's own detection status (from runAIData).
337
+ pool.forEach( ( ua ) => {
243
338
  if ( ua.answerType === 'image' && ua.answer ) {
244
- push( 'uploaded', 'Uploaded Image', ua.answer );
339
+ push( 'uploaded', 'Uploaded Image', ua.answer, statusFor( ua.answer ) );
245
340
  }
246
341
  } );
247
342
 
248
343
  // 3. Validation images (single 'Capture Image' and multi 'Capture Multiple Image').
249
- userAnswers.forEach( ( ua ) => {
344
+ pool.forEach( ( ua ) => {
250
345
  if ( !ua.validation ) return;
251
346
 
252
347
  if ( ua.validationDisplayType === 'image' && ua.validationAnswer ) {
253
- push( 'validation', 'Validation Image', ua.validationAnswer );
348
+ push( 'validation', 'Validation Image', ua.validationAnswer, statusFor( ua.validationAnswer ) );
254
349
  }
255
350
 
256
351
  if ( ua.validationDisplayType === 'multiImage' && Array.isArray( ua.validationImage ) ) {
257
- ua.validationImage.forEach( ( url ) => push( 'validation', 'Validation Image', url ) );
352
+ ua.validationImage.forEach( ( url ) => push( 'validation', 'Validation Image', url, statusFor( url ) ) );
258
353
  }
259
354
  } );
260
355
 
@@ -289,6 +384,125 @@ function getRunAIMatchValues( runAIData ) {
289
384
  }
290
385
 
291
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
+
292
506
  /**
293
507
 
294
508
  * Map section array { sectionName, questions[] } (view API or processed checklist) to scores / template sections.
@@ -360,6 +574,22 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
360
574
  }
361
575
 
362
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
+
363
593
  sectionScore += score;
364
594
 
365
595
  sectionMax += max;
@@ -417,7 +647,7 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
417
647
 
418
648
  answerDisplay: isYes ? 'Yes' : ( isNo ? 'No' : ( answerText && answerText.startsWith( 'http' ) ? 'Image' : ( answerText || '—' ) ) ),
419
649
 
420
- mediaItems: buildQuestionMediaItems( userAnswersWithRef ),
650
+ mediaItems: buildQuestionMediaItems( userAnswersWithRef, aggregateDetectionStatus ),
421
651
 
422
652
  userAnswer: userAnswersWithRef.length ? userAnswersWithRef : [ {
423
653
  answer: '',
@@ -442,15 +672,15 @@ function mapSectionsFromQuestionAnswer( questionAnswer ) {
442
672
 
443
673
  sectionName: section.sectionName || `Section ${sectionIdx + 1}`,
444
674
 
445
- targetScore: sectionMax,
675
+ targetScore: sectionMax ? sectionMax : '--',
446
676
 
447
- actualScore: sectionScore,
677
+ actualScore: sectionMax ? sectionScore : '--',
448
678
 
449
679
  questionsCount,
450
680
 
451
681
  passedCount,
452
682
 
453
- percentage: sectionMax > 0 ? Math.round( ( sectionScore / sectionMax ) * 100 ) : 0,
683
+ percentage: sectionMax > 0 ? Math.round( ( sectionScore / sectionMax ) * 100 ) : '--',
454
684
 
455
685
  } );
456
686
 
@@ -939,6 +1169,10 @@ export function createImageCache() {
939
1169
  if ( typeof u === 'string' && u.startsWith( 'http' ) ) urls.add( u );
940
1170
  } );
941
1171
  }
1172
+
1173
+ ua.answerMedia?.forEach( ( m ) => {
1174
+ if ( typeof m.url === 'string' && m.url.startsWith( 'http' ) ) urls.add( m.url );
1175
+ } );
942
1176
  } );
943
1177
 
944
1178
  q.mediaItems?.forEach( ( m ) => {
@@ -1006,6 +1240,12 @@ export function createImageCache() {
1006
1240
  if ( ua.validationDisplayType === 'multiImage' && ua.validationImage?.length ) {
1007
1241
  ua.validationImage = ua.validationImage.map( ( u ) => ( cache.has( u ) ? cache.get( u ) : u ) );
1008
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
+ }
1009
1249
  } );
1010
1250
 
1011
1251
  if ( q.mediaItems?.length ) {
@@ -1093,6 +1333,12 @@ export function resolveTemplateUrls( templateData, baseUrl = 'https://d1r0hc2ssk
1093
1333
  if ( ua.answer && ua.answerType !== 'text' ) {
1094
1334
  ua.answer = resolveUrl( ua.answer );
1095
1335
  }
1336
+
1337
+ if ( ua.answerMedia?.length ) {
1338
+ ua.answerMedia.forEach( ( m ) => {
1339
+ m.url = resolveUrl( m.url );
1340
+ } );
1341
+ }
1096
1342
  } );
1097
1343
 
1098
1344
  if ( q.mediaItems?.length ) {
@@ -1239,6 +1485,12 @@ export async function generateVisitChecklistPDF( templateData, baseUrl = 'https:
1239
1485
  if ( ua.answer && ua.answerType !== 'text' ) {
1240
1486
  ua.answer = resolveUrl( ua.answer );
1241
1487
  }
1488
+
1489
+ if ( ua.answerMedia?.length ) {
1490
+ ua.answerMedia.forEach( ( m ) => {
1491
+ m.url = resolveUrl( m.url );
1492
+ } );
1493
+ }
1242
1494
  } );
1243
1495
 
1244
1496
  if ( q.mediaItems?.length ) {