@adobe/spacecat-shared-data-access 2.99.0 → 2.100.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [@adobe/spacecat-shared-data-access-v2.100.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v2.99.0...@adobe/spacecat-shared-data-access-v2.100.0) (2026-02-02)
2
+
3
+
4
+ ### Features
5
+
6
+ * **data-access:** add type-specific data schemas for suggestions [SITES-39183] ([#1289](https://github.com/adobe/spacecat-shared/issues/1289)) ([9824b22](https://github.com/adobe/spacecat-shared/commit/9824b22bc494467849a2ae903ce902ed3f9fde80))
7
+
1
8
  # [@adobe/spacecat-shared-data-access-v2.99.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v2.98.0...@adobe/spacecat-shared-data-access-v2.99.0) (2026-01-30)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "2.99.0",
3
+ "version": "2.100.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -17,3 +17,6 @@ export {
17
17
  Suggestion,
18
18
  SuggestionCollection,
19
19
  };
20
+
21
+ // Export DATA_SCHEMAS for api-service to reference
22
+ export const { DATA_SCHEMAS } = Suggestion;
@@ -0,0 +1,541 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /**
14
+ * @fileoverview Type-specific data schemas for suggestion opportunity types.
15
+ * Defines validation schemas and projection configurations.
16
+ *
17
+ * Validation schemas should be used in audit-worker when creating/updating suggestions
18
+ * to ensure data structure consistency before writing to the database.
19
+ */
20
+
21
+ import Joi from 'joi';
22
+ import { OPPORTUNITY_TYPES } from '@adobe/spacecat-shared-utils';
23
+
24
+ /**
25
+ * Data schemas configuration per opportunity type.
26
+ *
27
+ * @typedef {Object} OpportunityTypeSchema
28
+ * @property {import('joi').Schema} schema - Joi validation schema for the data field
29
+ * @property {Object} projections - Projection configurations
30
+ * @property {Object} projections.minimal - Minimal view configuration
31
+ * @property {string[]} projections.minimal.fields - Fields to include in minimal view
32
+ * @property {Object<string, string>} projections.minimal.transformers - Field transformers to apply
33
+ *
34
+ * @type {Object<string, OpportunityTypeSchema>}
35
+ *
36
+ * @example Adding a new opportunity type
37
+ * [OPPORTUNITY_TYPES.YOUR_TYPE]: {
38
+ * schema: Joi.object({
39
+ * url: Joi.string().uri().required(), // Required - in minimal view
40
+ * customField: Joi.string().required(), // Required - in minimal view
41
+ * optionalField: Joi.string().optional() // Optional - not in minimal view
42
+ * }).unknown(true),
43
+ * projections: {
44
+ * minimal: {
45
+ * fields: ['url', 'customField'],
46
+ * transformers: { customField: 'myTransformerName' }
47
+ * }
48
+ * }
49
+ * }
50
+ */
51
+ export const DATA_SCHEMAS = {
52
+ [OPPORTUNITY_TYPES.STRUCTURED_DATA]: {
53
+ schema: Joi.object({
54
+ url: Joi.string().uri().required(),
55
+ type: Joi.string().optional(),
56
+ errors: Joi.array().items(Joi.object()).optional(),
57
+ }).unknown(true),
58
+ projections: {
59
+ minimal: {
60
+ fields: ['url'],
61
+ transformers: {},
62
+ },
63
+ },
64
+ },
65
+ [OPPORTUNITY_TYPES.COLOR_CONTRAST]: {
66
+ schema: Joi.object({
67
+ type: Joi.string().optional(),
68
+ url: Joi.string().uri().required(),
69
+ issues: Joi.array().items(
70
+ Joi.object({
71
+ wcagLevel: Joi.string().optional(),
72
+ severity: Joi.string().optional(),
73
+ occurrences: Joi.number().optional(),
74
+ htmlWithIssues: Joi.array().items(Joi.object()).optional(),
75
+ failureSummary: Joi.string().optional(),
76
+ wcagRule: Joi.string().optional(),
77
+ description: Joi.string().optional(),
78
+ type: Joi.string().optional(),
79
+ }).unknown(true),
80
+ ).required(),
81
+ jiraLink: Joi.string().uri().allow(null).optional(),
82
+ aggregationKey: Joi.string().optional(),
83
+ }).unknown(true),
84
+ projections: {
85
+ minimal: {
86
+ fields: ['url', 'issues'],
87
+ transformers: {
88
+ issues: 'filterIssuesOccurrences',
89
+ },
90
+ },
91
+ },
92
+ },
93
+ [OPPORTUNITY_TYPES.A11Y_ASSISTIVE]: {
94
+ schema: Joi.object({
95
+ type: Joi.string().optional(),
96
+ url: Joi.string().uri().required(),
97
+ issues: Joi.array().items(
98
+ Joi.object({
99
+ wcagLevel: Joi.string().optional(),
100
+ severity: Joi.string().optional(),
101
+ occurrences: Joi.number().optional(),
102
+ htmlWithIssues: Joi.array().items(Joi.object()).optional(),
103
+ failureSummary: Joi.string().optional(),
104
+ wcagRule: Joi.string().optional(),
105
+ description: Joi.string().optional(),
106
+ type: Joi.string().optional(),
107
+ }).unknown(true),
108
+ ).required(),
109
+ jiraLink: Joi.string().uri().allow(null).optional(),
110
+ aggregationKey: Joi.string().optional(),
111
+ }).unknown(true),
112
+ projections: {
113
+ minimal: {
114
+ fields: ['url', 'issues'],
115
+ transformers: {
116
+ issues: 'filterIssuesOccurrences',
117
+ },
118
+ },
119
+ },
120
+ },
121
+ [OPPORTUNITY_TYPES.CWV]: {
122
+ schema: Joi.object({
123
+ type: Joi.string().required(),
124
+ url: Joi.string().uri().required(),
125
+ pageviews: Joi.number().optional(),
126
+ organic: Joi.number().optional(),
127
+ metrics: Joi.array().items(
128
+ Joi.object({
129
+ deviceType: Joi.string().optional(),
130
+ pageviews: Joi.number().optional(),
131
+ clsCount: Joi.number().optional(),
132
+ ttfbCount: Joi.number().optional(),
133
+ lcp: Joi.number().optional(),
134
+ inpCount: Joi.number().optional(),
135
+ inp: Joi.number().optional(),
136
+ ttfb: Joi.number().optional(),
137
+ cls: Joi.number().optional(),
138
+ lcpCount: Joi.number().optional(),
139
+ organic: Joi.number().optional(),
140
+ }).unknown(true),
141
+ ).required(),
142
+ issues: Joi.array().items(Joi.object()).required(),
143
+ aggregationKey: Joi.string().allow(null).optional(),
144
+ }).unknown(true),
145
+ projections: {
146
+ minimal: {
147
+ fields: ['url', 'type', 'metrics', 'issues'],
148
+ transformers: {
149
+ metrics: 'filterCwvMetrics',
150
+ },
151
+ },
152
+ },
153
+ },
154
+ [OPPORTUNITY_TYPES.ALT_TEXT]: {
155
+ schema: Joi.object({
156
+ recommendations: Joi.array().items(
157
+ Joi.object({
158
+ isAppropriate: Joi.boolean().optional(),
159
+ isDecorative: Joi.boolean().optional(),
160
+ xpath: Joi.string().optional(),
161
+ altText: Joi.string().optional(),
162
+ imageUrl: Joi.string().uri().optional(),
163
+ pageUrl: Joi.string().uri().optional(),
164
+ language: Joi.string().optional(),
165
+ id: Joi.string().optional(),
166
+ }).unknown(true),
167
+ ).required(),
168
+ aggregationKey: Joi.string().allow(null).optional(),
169
+ }).unknown(true),
170
+ projections: {
171
+ minimal: {
172
+ fields: ['recommendations'],
173
+ transformers: {
174
+ recommendations: 'extractPageUrlFromRecommendations',
175
+ },
176
+ },
177
+ },
178
+ },
179
+ [OPPORTUNITY_TYPES.SECURITY_PERMISSIONS]: {
180
+ schema: Joi.object({
181
+ principal: Joi.string().optional(),
182
+ path: Joi.string().required(),
183
+ issue: Joi.string().optional(),
184
+ permissions: Joi.array().items(Joi.string()).optional(),
185
+ recommended_permissions: Joi.array().items(Joi.string()).optional(),
186
+ acl: Joi.array().items(Joi.string()).optional(),
187
+ otherPermissions: Joi.array().optional(),
188
+ rationale: Joi.string().optional(),
189
+ aggregationKey: Joi.string().allow(null).optional(),
190
+ }).unknown(true),
191
+ projections: {
192
+ minimal: {
193
+ fields: ['path'],
194
+ transformers: {},
195
+ },
196
+ },
197
+ },
198
+ [OPPORTUNITY_TYPES.SECURITY_VULNERABILITIES]: {
199
+ schema: Joi.object({
200
+ current_version: Joi.string().optional(),
201
+ library: Joi.string().optional(),
202
+ recommended_version: Joi.string().allow(null).optional(),
203
+ cves: Joi.array().items(
204
+ Joi.object({
205
+ summary: Joi.string().optional(),
206
+ score: Joi.number().optional(),
207
+ score_text: Joi.string().optional(),
208
+ cve_id: Joi.string().optional(),
209
+ url: Joi.string().uri().optional(),
210
+ }).unknown(true),
211
+ ).required(),
212
+ aggregationKey: Joi.string().allow(null).optional(),
213
+ }).unknown(true),
214
+ projections: {
215
+ minimal: {
216
+ fields: ['cves'],
217
+ transformers: {
218
+ cves: 'extractCveUrls',
219
+ },
220
+ },
221
+ },
222
+ },
223
+ [OPPORTUNITY_TYPES.FORM_ACCESSIBILITY]: {
224
+ schema: Joi.object({
225
+ source: Joi.string().required(),
226
+ type: Joi.string().optional(),
227
+ aiGenerated: Joi.boolean().optional(),
228
+ url: Joi.string().uri().required(),
229
+ issues: Joi.array().items(
230
+ Joi.object({
231
+ wcagLevel: Joi.string().optional(),
232
+ severity: Joi.string().optional(),
233
+ occurrences: Joi.number().optional(),
234
+ htmlWithIssues: Joi.array().items(Joi.object()).optional(),
235
+ failureSummary: Joi.string().optional(),
236
+ wcagRule: Joi.string().optional(),
237
+ understandingUrl: Joi.string().uri().optional(),
238
+ description: Joi.string().optional(),
239
+ type: Joi.string().optional(),
240
+ }).unknown(true),
241
+ ).required(),
242
+ jiraLink: Joi.string().uri().allow(null).optional(),
243
+ aggregationKey: Joi.string().optional(),
244
+ }).unknown(true),
245
+ projections: {
246
+ minimal: {
247
+ fields: ['url', 'source', 'issues'],
248
+ transformers: {
249
+ issues: 'filterIssuesOccurrences',
250
+ },
251
+ },
252
+ },
253
+ },
254
+ [OPPORTUNITY_TYPES.CANONICAL]: {
255
+ schema: Joi.object({
256
+ url: Joi.string().uri().required(),
257
+ checkType: Joi.string().optional(),
258
+ type: Joi.string().optional(),
259
+ suggestion: Joi.string().optional(),
260
+ recommendedAction: Joi.string().optional(),
261
+ explanation: Joi.string().optional(),
262
+ aggregationKey: Joi.string().allow(null).optional(),
263
+ }).unknown(true),
264
+ projections: {
265
+ minimal: {
266
+ fields: ['url'],
267
+ transformers: {},
268
+ },
269
+ },
270
+ },
271
+ [OPPORTUNITY_TYPES.HEADINGS]: {
272
+ schema: Joi.object({
273
+ url: Joi.string().uri().required(),
274
+ type: Joi.string().optional(),
275
+ checkType: Joi.string().optional(),
276
+ explanation: Joi.string().optional(),
277
+ recommendedAction: Joi.string().optional(),
278
+ checkTitle: Joi.string().optional(),
279
+ isAISuggested: Joi.boolean().optional(),
280
+ transformRules: Joi.object().optional(),
281
+ aggregationKey: Joi.string().allow(null).optional(),
282
+ }).unknown(true),
283
+ projections: {
284
+ minimal: {
285
+ fields: ['url'],
286
+ transformers: {},
287
+ },
288
+ },
289
+ },
290
+ [OPPORTUNITY_TYPES.HREFLANG]: {
291
+ schema: Joi.object({
292
+ url: Joi.string().uri().required(),
293
+ type: Joi.string().optional(),
294
+ checkType: Joi.string().optional(),
295
+ explanation: Joi.string().optional(),
296
+ recommendedAction: Joi.string().optional(),
297
+ suggestion: Joi.string().allow(null).optional(),
298
+ aggregationKey: Joi.string().allow(null).optional(),
299
+ }).unknown(true),
300
+ projections: {
301
+ minimal: {
302
+ fields: ['url'],
303
+ transformers: {},
304
+ },
305
+ },
306
+ },
307
+ [OPPORTUNITY_TYPES.INVALID_OR_MISSING_METADATA]: {
308
+ schema: Joi.object({
309
+ url: Joi.string().uri().required(),
310
+ tagName: Joi.string().optional(),
311
+ issue: Joi.string().optional(),
312
+ tagContent: Joi.string().allow('', null).optional(),
313
+ rank: Joi.number().optional(),
314
+ seoRecommendation: Joi.string().optional(),
315
+ issueDetails: Joi.string().optional(),
316
+ seoImpact: Joi.string().optional(),
317
+ aiRationale: Joi.string().optional(),
318
+ aiSuggestion: Joi.string().optional(),
319
+ aggregationKey: Joi.string().allow(null).optional(),
320
+ }).unknown(true),
321
+ projections: {
322
+ minimal: {
323
+ fields: ['url'],
324
+ transformers: {},
325
+ },
326
+ },
327
+ },
328
+ [OPPORTUNITY_TYPES.SITEMAP]: {
329
+ schema: Joi.object({
330
+ sitemapUrl: Joi.string().uri().required(),
331
+ pageUrl: Joi.string().uri().required(),
332
+ type: Joi.string().valid('url', 'error').optional(),
333
+ statusCode: Joi.number().optional(),
334
+ urlsSuggested: Joi.string().uri().optional(),
335
+ recommendedAction: Joi.string().optional(),
336
+ error: Joi.string().optional(),
337
+ aggregationKey: Joi.string().allow(null).optional(),
338
+ }).unknown(true),
339
+ projections: {
340
+ minimal: {
341
+ fields: ['sitemapUrl', 'pageUrl'],
342
+ transformers: {},
343
+ },
344
+ },
345
+ },
346
+ [OPPORTUNITY_TYPES.BROKEN_BACKLINKS]: {
347
+ schema: Joi.object({
348
+ url_from: Joi.string().uri().required(),
349
+ url_to: Joi.string().uri().required(),
350
+ title: Joi.string().optional(),
351
+ traffic_domain: Joi.number().optional(),
352
+ aiRationale: Joi.string().optional(),
353
+ urlsSuggested: Joi.array().items(Joi.string().uri()).optional(),
354
+ aggregationKey: Joi.string().allow(null).optional(),
355
+ }).unknown(true),
356
+ projections: {
357
+ minimal: {
358
+ fields: ['url_from', 'url_to'],
359
+ transformers: {},
360
+ },
361
+ },
362
+ },
363
+ [OPPORTUNITY_TYPES.BROKEN_INTERNAL_LINKS]: {
364
+ schema: Joi.object({
365
+ urlFrom: Joi.string().uri().required(),
366
+ urlTo: Joi.string().uri().required(),
367
+ title: Joi.string().optional(),
368
+ urlsSuggested: Joi.array().items(Joi.string().uri()).optional(),
369
+ aiRationale: Joi.string().optional(),
370
+ trafficDomain: Joi.number().optional(),
371
+ priority: Joi.string().optional(),
372
+ aggregationKey: Joi.string().allow(null).optional(),
373
+ }).unknown(true),
374
+ projections: {
375
+ minimal: {
376
+ fields: ['urlFrom', 'urlTo'],
377
+ transformers: {},
378
+ },
379
+ },
380
+ },
381
+ [OPPORTUNITY_TYPES.PRERENDER]: {
382
+ schema: Joi.object({
383
+ url: Joi.string().uri().required(),
384
+ contentGainRatio: Joi.number().optional(),
385
+ wordCountBefore: Joi.number().optional(),
386
+ wordCountAfter: Joi.number().optional(),
387
+ originalHtmlKey: Joi.string().optional(),
388
+ prerenderedHtmlKey: Joi.string().optional(),
389
+ organicTraffic: Joi.number().optional(),
390
+ aggregationKey: Joi.string().allow(null).optional(),
391
+ }).unknown(true),
392
+ projections: {
393
+ minimal: {
394
+ fields: ['url'],
395
+ transformers: {},
396
+ },
397
+ },
398
+ },
399
+ [OPPORTUNITY_TYPES.HIGH_ORGANIC_LOW_CTR]: {
400
+ schema: Joi.object({
401
+ url: Joi.string().uri().optional(),
402
+ clicks: Joi.number().optional(),
403
+ impressions: Joi.number().optional(),
404
+ ctr: Joi.number().optional(),
405
+ position: Joi.number().optional(),
406
+ variations: Joi.array().items(
407
+ Joi.object({
408
+ id: Joi.string().optional(),
409
+ name: Joi.string().optional(),
410
+ screenshotUrl: Joi.string().uri().optional(),
411
+ variationPageUrl: Joi.string().uri().optional(),
412
+ variationEditPageUrl: Joi.string().uri().allow(null).optional(),
413
+ variationMdPageUrl: Joi.string().uri().allow(null).optional(),
414
+ previewImage: Joi.string().uri().optional(),
415
+ explanation: Joi.string().allow(null).optional(),
416
+ projectedImpact: Joi.number().optional(),
417
+ changes: Joi.array().optional(),
418
+ variationChanges: Joi.object({
419
+ changes: Joi.object({
420
+ type: Joi.string().optional(),
421
+ mdUrl: Joi.string().uri().optional(),
422
+ md: Joi.string().optional(),
423
+ }).unknown(true).optional(),
424
+ }).unknown(true).optional(),
425
+ }).unknown(true),
426
+ ).optional(),
427
+ aggregationKey: Joi.string().allow(null).optional(),
428
+ }).unknown(true),
429
+ projections: {
430
+ minimal: {
431
+ fields: [],
432
+ transformers: {},
433
+ },
434
+ },
435
+ },
436
+ [OPPORTUNITY_TYPES.LLM_BLOCKED]: {
437
+ schema: Joi.object({
438
+ affectedUserAgents: Joi.array().items(Joi.string()).optional(),
439
+ lineNumber: Joi.number().optional(),
440
+ items: Joi.array().items(
441
+ Joi.object({
442
+ url: Joi.string().uri().optional(),
443
+ agent: Joi.string().optional(),
444
+ }).unknown(true),
445
+ ).optional(),
446
+ robotsTxtHash: Joi.string().optional(),
447
+ url: Joi.string().uri().optional(),
448
+ pattern: Joi.string().optional(),
449
+ aggregationKey: Joi.string().allow(null).optional(),
450
+ }).unknown(true),
451
+ projections: {
452
+ minimal: {
453
+ fields: [],
454
+ transformers: {},
455
+ },
456
+ },
457
+ },
458
+ // ========== SCHEMAS NEED VALIDATION ==========
459
+ // The following schemas exist (taken from audit-worker structure) but have NOT been
460
+ // validated against actual suggestion data yet.
461
+ //
462
+ // TODO: When these opportunity types generate suggestions:
463
+ // 1. Validate the schema against real suggestion data
464
+ // 2. Update minimal projection fields to match actual requirements
465
+ // 3. Add Suggestion.validateData() call in audit-worker when creating suggestions
466
+ // 4. Make required fields properly marked based on minimal projection
467
+ //
468
+ // Schemas needing validation:
469
+ // - SITEMAP_PRODUCT_COVERAGE
470
+ // - REDIRECT_CHAINS
471
+ [OPPORTUNITY_TYPES.SITEMAP_PRODUCT_COVERAGE]: {
472
+ schema: Joi.object({
473
+ locale: Joi.string().optional(),
474
+ url: Joi.string().uri().optional(),
475
+ recommendedAction: Joi.string().optional(),
476
+ aggregationKey: Joi.string().allow(null).optional(),
477
+ }).unknown(true),
478
+ projections: {
479
+ minimal: {
480
+ fields: ['url'],
481
+ transformers: {},
482
+ },
483
+ },
484
+ },
485
+ [OPPORTUNITY_TYPES.REDIRECT_CHAINS]: {
486
+ schema: Joi.object({
487
+ key: Joi.string().optional(),
488
+ fixType: Joi.string().optional(),
489
+ fix: Joi.string().optional(),
490
+ canApplyFixAutomatically: Joi.boolean().optional(),
491
+ redirectsFile: Joi.string().optional(),
492
+ redirectCount: Joi.number().optional(),
493
+ httpStatusCode: Joi.number().optional(),
494
+ sourceUrl: Joi.string().uri().optional(),
495
+ sourceUrlFull: Joi.string().uri().optional(),
496
+ destinationUrl: Joi.string().uri().optional(),
497
+ destinationUrlFull: Joi.string().uri().optional(),
498
+ finalUrl: Joi.string().uri().optional(),
499
+ finalUrlFull: Joi.string().uri().optional(),
500
+ ordinalDuplicate: Joi.number().optional(),
501
+ redirectChain: Joi.array().items(Joi.object()).optional(),
502
+ errorMsg: Joi.string().optional(),
503
+ aggregationKey: Joi.string().allow(null).optional(),
504
+ }).unknown(true),
505
+ projections: {
506
+ minimal: {
507
+ fields: ['sourceUrl', 'destinationUrl', 'finalUrl'],
508
+ transformers: {},
509
+ },
510
+ },
511
+ },
512
+
513
+ // ========== SCHEMAS TO BE ADDED ==========
514
+ // TODO: The following opportunity types need schemas to be added.
515
+ // Research actual suggestion data for these types and add schemas following the pattern:
516
+ // 1. Get real suggestion data examples
517
+ // 2. Define schema with proper field types
518
+ // 3. Make minimal projection fields required (urls used for filtering on ASO UI)
519
+ // 4. Validate against actual data
520
+ //
521
+ // Opportunity types pending schema implementation:
522
+ // - ACCESSIBILITY (parent type - may use A11Y_ASSISTIVE and COLOR_CONTRAST schemas)
523
+ // - NOTFOUND (404 pages)
524
+ // - RAGECLICK
525
+ // - HIGH_INORGANIC_HIGH_BOUNCE_RATE
526
+ // - HIGH_FORM_VIEWS_LOW_CONVERSIONS
527
+ // - HIGH_PAGE_VIEWS_LOW_FORM_NAV
528
+ // - HIGH_PAGE_VIEWS_LOW_FORM_VIEWS
529
+ // - DETECT_GEO_BRAND_PRESENCE
530
+ // - DETECT_GEO_BRAND_PRESENCE_DAILY
531
+ // - GEO_BRAND_PRESENCE_TRIGGER_REFRESH
532
+ // - GUIDANCE_GEO_FAQ
533
+ // - SECURITY_CSP (data.findings[].url - for URL filtering)
534
+ // - SECURITY_XSS (data.link - for URL filtering,
535
+ // may use SECURITY_CSP schema or need separate schema)
536
+ // - SECURITY_PERMISSIONS_REDUNDANT (may use SECURITY_PERMISSIONS schema
537
+ // or need separate schema)
538
+ // - GENERIC_OPPORTUNITY
539
+ // - PAID_COOKIE_CONSENT
540
+ // - WIKIPEDIA_ANALYSIS
541
+ };
@@ -11,6 +11,8 @@
11
11
  */
12
12
 
13
13
  import BaseModel from '../base/base.model.js';
14
+ import { DATA_SCHEMAS } from './suggestion.data-schemas.js';
15
+ import { FIELD_TRANSFORMERS, FALLBACK_PROJECTION } from './suggestion.projection-utils.js';
14
16
 
15
17
  /**
16
18
  * Suggestion - A class representing a Suggestion entity.
@@ -44,6 +46,71 @@ class Suggestion extends BaseModel {
44
46
  CONFIG_UPDATE: 'CONFIG_UPDATE',
45
47
  };
46
48
 
49
+ // Import schemas from external file for maintainability
50
+ static FIELD_TRANSFORMERS = FIELD_TRANSFORMERS;
51
+
52
+ static DATA_SCHEMAS = DATA_SCHEMAS;
53
+
54
+ static FALLBACK_PROJECTION = FALLBACK_PROJECTION;
55
+
56
+ /**
57
+ * Gets the projection configuration for a given opportunity type and view.
58
+ * Falls back to FALLBACK_PROJECTION if no schema is defined for the type.
59
+ *
60
+ * @param {string} opportunityType - The opportunity type from OPPORTUNITY_TYPES enum
61
+ * @param {string} [viewName='minimal'] - The view name (e.g., 'minimal', 'summary')
62
+ * @returns {Object} Projection configuration with fields and transformers
63
+ *
64
+ * @example
65
+ * const projection = Suggestion.getProjection('cwv', 'minimal');
66
+ * // Returns: { fields: ['url', 'type', 'metrics', 'issues'],
67
+ * // transformers: { metrics: 'filterCwvMetrics' } }
68
+ */
69
+ static getProjection(opportunityType, viewName = 'minimal') {
70
+ const schemaConfig = this.DATA_SCHEMAS[opportunityType];
71
+
72
+ if (schemaConfig?.projections?.[viewName]) {
73
+ return schemaConfig.projections[viewName];
74
+ }
75
+
76
+ // Fallback for unknown types
77
+ return this.FALLBACK_PROJECTION[viewName] || this.FALLBACK_PROJECTION.minimal;
78
+ }
79
+
80
+ /**
81
+ * Validates suggestion data against the Joi schema for the given opportunity type.
82
+ * If no schema is defined, validation is skipped (graceful fallback).
83
+ *
84
+ * **Usage:** Call this in audit-worker before creating/updating suggestions to ensure
85
+ * data structure consistency across services.
86
+ *
87
+ * @param {Object} data - Suggestion data to validate
88
+ * @param {string} opportunityType - The opportunity type from OPPORTUNITY_TYPES enum
89
+ * @throws {Error} If validation fails with details
90
+ *
91
+ * @example
92
+ * // In audit-worker before creating a suggestion:
93
+ * try {
94
+ * Suggestion.validateData({ url: 'https://example.com' }, 'structured-data');
95
+ * // Proceed with creating suggestion
96
+ * } catch (error) {
97
+ * log.error('Invalid suggestion data:', error.message);
98
+ * }
99
+ */
100
+ static validateData(data, opportunityType) {
101
+ const schemaConfig = this.DATA_SCHEMAS[opportunityType];
102
+
103
+ if (!schemaConfig?.schema) {
104
+ // No schema defined, skip validation
105
+ return;
106
+ }
107
+
108
+ const { error } = schemaConfig.schema.validate(data);
109
+ if (error) {
110
+ throw new Error(`Invalid data for opportunity type ${opportunityType}: ${error.message}`);
111
+ }
112
+ }
113
+
47
114
  // add your customized method here
48
115
  }
49
116
 
@@ -0,0 +1,110 @@
1
+ /*
2
+ * Copyright 2024 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /**
14
+ * @fileoverview Utility functions and configurations for suggestion data projections.
15
+ */
16
+
17
+ /**
18
+ * Reusable field transformation functions for projecting suggestion data.
19
+ * Referenced by name in DATA_SCHEMAS transformer configurations.
20
+ *
21
+ * @type {Object<string, Function>}
22
+ *
23
+ * @example Usage in DATA_SCHEMAS
24
+ * projections: {
25
+ * minimal: {
26
+ * fields: ['issues'],
27
+ * transformers: {
28
+ * // References FIELD_TRANSFORMERS['filterIssuesOccurrences']
29
+ * issues: 'filterIssuesOccurrences'
30
+ * }
31
+ * }
32
+ * }
33
+ */
34
+ export const FIELD_TRANSFORMERS = {
35
+ /**
36
+ * Filters issues array to only include occurrences count.
37
+ * Used for accessibility-related opportunity types.
38
+ */
39
+ filterIssuesOccurrences: (issues) => {
40
+ if (!Array.isArray(issues)) return issues;
41
+ return issues.map((issue) => ({
42
+ occurrences: issue.occurrences,
43
+ }));
44
+ },
45
+ /**
46
+ * Filters metrics array to only include essential CWV fields.
47
+ * Used for Core Web Vitals opportunity type.
48
+ */
49
+ filterCwvMetrics: (metrics) => {
50
+ if (!Array.isArray(metrics)) return metrics;
51
+ return metrics.map((metric) => ({
52
+ deviceType: metric.deviceType,
53
+ lcp: metric.lcp,
54
+ inp: metric.inp,
55
+ cls: metric.cls,
56
+ }));
57
+ },
58
+ /**
59
+ * Extracts pageUrl from recommendations array.
60
+ * Used for alt-text opportunity type.
61
+ */
62
+ extractPageUrlFromRecommendations: (recommendations) => {
63
+ if (!Array.isArray(recommendations)) return recommendations;
64
+ return recommendations.map((rec) => ({
65
+ pageUrl: rec.pageUrl,
66
+ }));
67
+ },
68
+ /**
69
+ * Extracts URLs from CVEs array.
70
+ * Used for security vulnerability opportunity types.
71
+ */
72
+ extractCveUrls: (cves) => {
73
+ if (!Array.isArray(cves)) return cves;
74
+ return cves.map((cve) => ({
75
+ url: cve.url,
76
+ }));
77
+ },
78
+ };
79
+
80
+ /**
81
+ * Default projection configuration for opportunity types without explicit schemas.
82
+ * Conservative fallback that only includes common URL-related fields.
83
+ * Forces engineers to define explicit schemas if they need specific data fields.
84
+ *
85
+ * @type {Object}
86
+ * @property {Object} minimal - Minimal view configuration
87
+ * @property {string[]} minimal.fields - Common URL fields only (13 fields)
88
+ * @property {Object} minimal.transformers - No transformers applied by default
89
+ */
90
+ export const FALLBACK_PROJECTION = {
91
+ minimal: {
92
+ fields: [
93
+ 'url',
94
+ 'urls',
95
+ 'urlFrom',
96
+ 'urlTo',
97
+ 'url_from',
98
+ 'url_to',
99
+ 'urlsSuggested',
100
+ 'pageUrl',
101
+ 'sitemapUrl',
102
+ 'pattern',
103
+ 'link',
104
+ 'path',
105
+ 'sourceUrl',
106
+ 'destinationUrl',
107
+ ],
108
+ transformers: {},
109
+ },
110
+ };