@adobe/spacecat-shared-data-access 2.71.1 → 2.72.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.72.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v2.71.1...@adobe/spacecat-shared-data-access-v2.72.0) (2025-10-16)
2
+
3
+
4
+ ### Features
5
+
6
+ * Sites 34129 adds many to many relationship between Fix Entity and Suggestions ([#993](https://github.com/adobe/spacecat-shared/issues/993)) ([8739d2b](https://github.com/adobe/spacecat-shared/commit/8739d2b891d5df6ce883ba3b319047531419100e))
7
+
1
8
  # [@adobe/spacecat-shared-data-access-v2.71.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v2.71.0...@adobe/spacecat-shared-data-access-v2.71.1) (2025-10-13)
2
9
 
3
10
 
package/README.md CHANGED
@@ -85,6 +85,38 @@ npm install @adobe/spacecat-shared-data-access
85
85
  - **status** (String): Status of the enrollment. (ACTIVE, SUSPENDED, ENDED)
86
86
  - **createdAt** (String): Timestamp of creation.
87
87
 
88
+ ### FixEntity
89
+ - **fixEntityId** (String): Unique identifier for the fix entity.
90
+ - **opportunityId** (String): ID of the associated opportunity.
91
+ - **createdAt** (String): Timestamp of creation.
92
+ - **updatedAt** (String): Timestamp of the last update.
93
+ - **type** (String): Type of the fix entity (from Suggestion.TYPES).
94
+ - **status** (String): Status of the fix entity (PENDING, DEPLOYED, PUBLISHED, FAILED, ROLLED_BACK).
95
+ - **executedBy** (String): Who executed the fix.
96
+ - **executedAt** (String): When the fix was executed.
97
+ - **publishedAt** (String): When the fix was published.
98
+ - **changeDetails** (Object): Details of the changes made.
99
+
100
+ ### Suggestion
101
+ - **suggestionId** (String): Unique identifier for the suggestion.
102
+ - **opportunityId** (String): ID of the associated opportunity.
103
+ - **createdAt** (String): Timestamp of creation.
104
+ - **updatedAt** (String): Timestamp of the last update.
105
+ - **status** (String): Status of the suggestion (NEW, APPROVED, IN_PROGRESS, SKIPPED, FIXED, ERROR, OUTDATED).
106
+ - **type** (String): Type of the suggestion (CODE_CHANGE, CONTENT_UPDATE, REDIRECT_UPDATE, METADATA_UPDATE, AI_INSIGHTS, CONFIG_UPDATE).
107
+ - **rank** (Number): Rank/priority of the suggestion.
108
+ - **data** (Object): Data payload for the suggestion.
109
+ - **kpiDeltas** (Object): KPI delta information (optional).
110
+
111
+ ### FixEntitySuggestion
112
+ - **suggestionId** (String): ID of the associated suggestion (primary partition key).
113
+ - **fixEntityId** (String): ID of the associated fix entity (primary sort key).
114
+ - **opportunityId** (String): ID of the associated opportunity.
115
+ - **fixEntityCreatedAt** (String): Creation timestamp of the fix entity.
116
+ - **fixEntityCreatedDate** (String): Date portion of fixEntityCreatedAt (auto-generated).
117
+ - **createdAt** (String): Timestamp of creation.
118
+ - **updatedAt** (String): Timestamp of the last update.
119
+
88
120
  ## DynamoDB Data Model
89
121
 
90
122
  The module is designed to work with the following DynamoDB tables:
@@ -144,6 +176,18 @@ The module provides the following DAOs:
144
176
  - `getTopPagesForSite`
145
177
  - `addSiteTopPage`
146
178
 
179
+ ### FixEntity Functions
180
+ - `getSuggestionsByFixEntityId` - Gets all suggestions associated with a specific FixEntity
181
+ - `setSuggestionsForFixEntity` - Sets suggestions for a FixEntity by managing junction table relationships
182
+
183
+ ### Suggestion Functions
184
+ - `bulkUpdateStatus` - Updates the status of multiple suggestions in bulk
185
+ - `getFixEntitiesBySuggestionId` - Gets all FixEntities associated with a specific Suggestion
186
+
187
+ ### FixEntitySuggestion Functions
188
+ - `allBySuggestionId` - Gets all junction records associated with a specific Suggestion
189
+ - `allByFixEntityId` - Gets all junction records associated with a specific FixEntity
190
+
147
191
  ## Integrating Data Access in AWS Lambda Functions
148
192
 
149
193
  Our `spacecat-shared-data-access` module includes a wrapper that can be easily integrated into AWS Lambda functions using `@adobe/helix-shared-wrap`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "2.71.1",
3
+ "version": "2.72.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -22,7 +22,7 @@ import { ElectroValidationError } from 'electrodb';
22
22
  import DataAccessError from '../../errors/data-access.error.js';
23
23
  import ValidationError from '../../errors/validation.error.js';
24
24
  import { createAccessors } from '../../util/accessor.utils.js';
25
- import { guardId } from '../../util/guards.js';
25
+ import { guardId, guardArray } from '../../util/guards.js';
26
26
  import {
27
27
  entityNameToAllPKValue,
28
28
  removeElectroProperties,
@@ -361,6 +361,53 @@ class BaseCollection {
361
361
  return isNonEmptyObject(record?.data);
362
362
  }
363
363
 
364
+ /**
365
+ * Retrieves multiple entities by their IDs in a single batch operation.
366
+ * This method is more efficient than calling findById multiple times.
367
+ *
368
+ * @async
369
+ * @param {Array<string>} ids - An array of entity IDs to retrieve.
370
+ * @param {{attributes?: string[]}} [options] - Additional options for the query.
371
+ * @returns {Promise<{data: Array<BaseModel>, unprocessed: Array<string>}>} - A promise that
372
+ * resolves
373
+ * to an object containing:
374
+ * - data: Array of found model instances
375
+ * - unprocessed: Array of IDs that couldn't be processed (due to throttling, etc.)
376
+ * @throws {DataAccessError} - Throws an error if the IDs are not provided or if the batch
377
+ * operation fails.
378
+ */
379
+ async batchGetByKeys(keys, options = {}) {
380
+ guardArray('keys', keys, this.entityName, 'any');
381
+
382
+ try {
383
+ const goOptions = {};
384
+
385
+ // Add attributes if specified
386
+ if (options.attributes !== undefined) {
387
+ goOptions.attributes = options.attributes;
388
+ }
389
+
390
+ const result = await this.entity.get(
391
+ keys,
392
+ ).go(goOptions);
393
+
394
+ // Process found entities
395
+ const data = result.data
396
+ .map((record) => this.#createInstance(record))
397
+ .filter((entity) => entity !== null);
398
+
399
+ // Extract unprocessed keys
400
+ const unprocessed = result.unprocessed
401
+ ? result.unprocessed.map((item) => item)
402
+ : [];
403
+
404
+ return { data, unprocessed };
405
+ } catch (error) {
406
+ this.log.error(`Failed to batch get by keys [${this.entityName}]`, error);
407
+ throw new DataAccessError('Failed to batch get by keys', this, error);
408
+ }
409
+ }
410
+
364
411
  /**
365
412
  * Finds a single entity by index keys.
366
413
  * @param {Object} keys - The index keys to use for the query.
@@ -550,6 +597,51 @@ class BaseCollection {
550
597
  return this.#logAndThrowError('Failed to remove by IDs', error);
551
598
  }
552
599
  }
600
+
601
+ /**
602
+ * Removes records from the collection using an array of key objects for batch deletion.
603
+ * This method is particularly useful for junction tables in many-to-many relationships
604
+ * where you need to remove multiple records based on their composite keys.
605
+ *
606
+ * Each key object in the array represents a record to be deleted, identified by its
607
+ * key attributes (typically partition key + sort key combinations).
608
+ *
609
+ * @async
610
+ * @param {Array<Object>} keys - Array of key objects to match for deletion.
611
+ * Each object should contain the key attributes that uniquely identify a record.
612
+ * @returns {Promise<void>} A promise that resolves when the deletion is complete.
613
+ * The method also invalidates the cache after successful deletion.
614
+ * @throws {DataAccessError} Throws an error if:
615
+ * - The keys parameter is not a non-empty array
616
+ * - Any key object in the array is empty or invalid
617
+ * - The database operation fails
618
+ *
619
+ * @since 2.64.1
620
+ * @memberof BaseCollection
621
+ */
622
+ async removeByIndexKeys(keys) {
623
+ if (!isNonEmptyArray(keys)) {
624
+ const message = `Failed to remove by index keys [${this.entityName}]: keys must be a non-empty array`;
625
+ this.log.error(message);
626
+ throw new DataAccessError(message);
627
+ }
628
+
629
+ keys.forEach((key) => {
630
+ if (!isNonEmptyObject(key)) {
631
+ const message = `Failed to remove by index keys [${this.entityName}]: key must be a non-empty object`;
632
+ this.log.error(message);
633
+ throw new DataAccessError(message);
634
+ }
635
+ });
636
+
637
+ try {
638
+ await this.entity.delete(keys).go();
639
+ this.log.info(`Removed ${keys.length} items for [${this.entityName}]`);
640
+ return this.#invalidateCache();
641
+ } catch (error) {
642
+ return this.#logAndThrowError('Failed to remove by index keys', error);
643
+ }
644
+ }
553
645
  }
554
646
 
555
647
  export default BaseCollection;
@@ -241,6 +241,12 @@ class BaseModel {
241
241
  return this._remove();
242
242
  }
243
243
 
244
+ generateCompositeKeys() {
245
+ return {
246
+ [this.idName]: this.getId(),
247
+ };
248
+ }
249
+
244
250
  /**
245
251
  * Internal remove method that removes the current entity from the database and its dependents.
246
252
  * This method does not check if the schema allows removal in order to be able to remove
@@ -269,7 +275,7 @@ class BaseModel {
269
275
 
270
276
  await Promise.all(removePromises);
271
277
 
272
- await this.entity.remove({ [this.idName]: this.getId() }).go();
278
+ await this.entity.remove(this.generateCompositeKeys()).go();
273
279
 
274
280
  this.#invalidateCache();
275
281
 
@@ -20,6 +20,7 @@ import ConfigurationCollection from '../configuration/configuration.collection.j
20
20
  import ExperimentCollection from '../experiment/experiment.collection.js';
21
21
  import EntitlementCollection from '../entitlement/entitlement.collection.js';
22
22
  import FixEntityCollection from '../fix-entity/fix-entity.collection.js';
23
+ import FixEntitySuggestionCollection from '../fix-entity-suggestion/fix-entity-suggestion.collection.js';
23
24
  import ImportJobCollection from '../import-job/import-job.collection.js';
24
25
  import ImportUrlCollection from '../import-url/import-url.collection.js';
25
26
  import KeyEventCollection from '../key-event/key-event.collection.js';
@@ -46,6 +47,7 @@ import AuditSchema from '../audit/audit.schema.js';
46
47
  import ConfigurationSchema from '../configuration/configuration.schema.js';
47
48
  import EntitlementSchema from '../entitlement/entitlement.schema.js';
48
49
  import FixEntitySchema from '../fix-entity/fix-entity.schema.js';
50
+ import FixEntitySuggestionSchema from '../fix-entity-suggestion/fix-entity-suggestion.schema.js';
49
51
  import ExperimentSchema from '../experiment/experiment.schema.js';
50
52
  import ImportJobSchema from '../import-job/import-job.schema.js';
51
53
  import ImportUrlSchema from '../import-url/import-url.schema.js';
@@ -142,6 +144,7 @@ EntityRegistry.registerEntity(AuditSchema, AuditCollection);
142
144
  EntityRegistry.registerEntity(ConfigurationSchema, ConfigurationCollection);
143
145
  EntityRegistry.registerEntity(EntitlementSchema, EntitlementCollection);
144
146
  EntityRegistry.registerEntity(FixEntitySchema, FixEntityCollection);
147
+ EntityRegistry.registerEntity(FixEntitySuggestionSchema, FixEntitySuggestionCollection);
145
148
  EntityRegistry.registerEntity(ExperimentSchema, ExperimentCollection);
146
149
  EntityRegistry.registerEntity(ImportJobSchema, ImportJobCollection);
147
150
  EntityRegistry.registerEntity(ImportUrlSchema, ImportUrlCollection);
@@ -43,12 +43,17 @@ export interface QueryOptions {
43
43
  fetchAllPages?: boolean;
44
44
  }
45
45
 
46
+ export interface BatchGetOptions {
47
+ attributes?: string[];
48
+ }
49
+
46
50
  export interface BaseCollection<T extends BaseModel> {
47
51
  _onCreate(item: T): void;
48
52
  _onCreateMany(items: MultiStatusCreateResult<T>): void;
49
53
  _saveMany(items: T[]): Promise<T[]>;
50
54
  all(sortKeys?: object, options?: QueryOptions): Promise<T[]>;
51
55
  allByIndexKeys(keys: object, options?: QueryOptions): Promise<T[]>;
56
+ batchGetByKeys(keys: object[], options?: BatchGetOptions): Promise<{ data: T[]; unprocessed: object[] }>;
52
57
  create(item: object): Promise<T>;
53
58
  createMany(items: object[], parent?: T): Promise<MultiStatusCreateResult<T>>;
54
59
  existsById(id: string): Promise<boolean>;
@@ -170,7 +170,9 @@ class Schema {
170
170
  const allKeys = [...(pk?.facets || []), ...(sk?.facets || [])];
171
171
 
172
172
  // check if all keys in the index are in the sort keys
173
- return subKeyNames.every((key) => allKeys.includes(key));
173
+ const pkKeys = Array.isArray(pk?.facets) ? pk.facets : [];
174
+ return pkKeys.every((key) => subKeyNames.includes(key))
175
+ && subKeyNames.every((key) => allKeys.includes(key));
174
176
  });
175
177
 
176
178
  if (isNonEmptyObject(index)) {
@@ -10,17 +10,235 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
  import BaseCollection from '../base/base.collection.js';
13
+ import DataAccessError from '../../errors/data-access.error.js';
14
+ import ValidationError from '../../errors/validation.error.js';
15
+ import { guardId, guardArray, guardString } from '../../util/guards.js';
16
+ import { resolveUpdates } from '../../util/util.js';
13
17
 
14
18
  /**
15
- * SiteCandidateCollection - A collection class responsible for managing FixEntities.
19
+ * FixEntityCollection - A collection class responsible for managing FixEntities.
16
20
  * Extends the BaseCollection to provide specific methods for interacting with
17
- * FixEntity records.
21
+ * FixEntity records and their relationships with Suggestions.
22
+ *
23
+ * This collection provides methods to:
24
+ * - Retrieve suggestions associated with a specific FixEntity
25
+ * - Set suggestions for a FixEntity by managing junction table relationships
18
26
  *
19
27
  * @class FixEntityCollection
20
28
  * @extends BaseCollection
21
29
  */
22
30
  class FixEntityCollection extends BaseCollection {
23
- // add custom methods here
31
+ /**
32
+ * Gets all suggestions associated with a specific FixEntity.
33
+ *
34
+ * @async
35
+ * @param {string} fixEntityId - The ID of the FixEntity.
36
+ * @returns {Promise<Array>} - A promise that resolves to an array of Suggestion models
37
+ * @throws {DataAccessError} - Throws an error if the fixEntityId is not provided or if the
38
+ * query fails.
39
+ */
40
+ async getSuggestionsByFixEntityId(fixEntityId) {
41
+ guardId('fixEntityId', fixEntityId, 'FixEntityCollection');
42
+
43
+ try {
44
+ const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection');
45
+
46
+ const fixEntitySuggestions = await fixEntitySuggestionCollection
47
+ .allByFixEntityId(fixEntityId);
48
+
49
+ if (fixEntitySuggestions.length === 0) {
50
+ return [];
51
+ }
52
+
53
+ const suggestionCollection = this.entityRegistry.getCollection('SuggestionCollection');
54
+ const suggestions = await suggestionCollection
55
+ .batchGetByKeys(fixEntitySuggestions
56
+ .map((record) => ({ [suggestionCollection.idName]: record.getSuggestionId() })));
57
+ return suggestions.data;
58
+ } catch (error) {
59
+ this.log.error(`Failed to get suggestions for fix entity: ${fixEntityId}`, error);
60
+ throw new DataAccessError('Failed to get suggestions for fix entity', this, error);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Sets suggestions for a specific FixEntity by replacing all existing suggestions with new ones.
66
+ * This method efficiently only removes relationships that are no longer needed and only adds
67
+ * new ones.
68
+ *
69
+ * @async
70
+ * @param {string} opportunityId - The ID of the opportunity.
71
+ * @param {FixEntity} fixEntity - The FixEntity entity.
72
+ * @param {Array<Suggestion>} suggestions - An array of Suggestion entities.
73
+ * @returns {Promise<{createdItems: Array, errorItems: Array, removedCount: number}>} - A promise
74
+ * that resolves to an object containing:
75
+ * - createdItems: Array of created FixEntitySuggestionCollection junction records
76
+ * - errorItems: Array of items that failed validation
77
+ * - removedCount: Number of existing relationships that were removed
78
+ * @throws {DataAccessError} - Throws an error if the entities are not provided or if the
79
+ * operation fails.
80
+ */
81
+ async setSuggestionsForFixEntity(opportunityId, fixEntity, suggestions) {
82
+ guardId('opportunityId', opportunityId, 'FixEntityCollection');
83
+ guardArray('suggestions', suggestions, 'FixEntityCollection', 'any');
84
+
85
+ // Simple null checks
86
+ if (!fixEntity) {
87
+ throw new ValidationError('fixEntity is required');
88
+ }
89
+
90
+ // Extract IDs and other values from entities
91
+ const fixEntityId = fixEntity.getId();
92
+ const fixEntityCreatedAt = fixEntity.getCreatedAt();
93
+ const suggestionIds = suggestions.map((suggestion) => suggestion.getId());
94
+
95
+ try {
96
+ const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection');
97
+
98
+ const existingRelationships = await fixEntitySuggestionCollection
99
+ .allByFixEntityId(fixEntityId);
100
+
101
+ // Extract existing suggestion IDs from relationship objects
102
+ const existingSuggestionIds = existingRelationships.map((rel) => rel.getSuggestionId());
103
+
104
+ const { toDelete, toCreate } = resolveUpdates(existingSuggestionIds, suggestionIds);
105
+
106
+ let removePromise;
107
+ let createPromise;
108
+
109
+ if (toDelete.length > 0) {
110
+ removePromise = fixEntitySuggestionCollection.removeByIndexKeys(
111
+ toDelete.map((suggestionId) => (
112
+ {
113
+ suggestionId,
114
+ fixEntityId,
115
+ })),
116
+ );
117
+ }
118
+
119
+ if (toCreate.length > 0) {
120
+ createPromise = fixEntitySuggestionCollection.createMany(toCreate.map((suggestionId) => (
121
+ {
122
+ opportunityId,
123
+ fixEntityCreatedAt,
124
+ fixEntityId,
125
+ suggestionId,
126
+ })));
127
+ }
128
+
129
+ const [removeResult, createResult] = await Promise.allSettled([removePromise, createPromise]);
130
+
131
+ let removedCount = 0;
132
+ let createdItems = [];
133
+ let errorItems = [];
134
+ if (removeResult.status === 'fulfilled') {
135
+ removedCount = toDelete.length;
136
+ } else {
137
+ this.log.error('Remove operation failed:', removeResult.reason);
138
+ }
139
+
140
+ if (createResult.status === 'fulfilled') {
141
+ createdItems = createResult.value?.createdItems || [];
142
+ errorItems = createResult.value?.errorItems || [];
143
+ } else {
144
+ this.log.error('Create operation failed:', createResult.reason);
145
+ }
146
+
147
+ this.log.info(`Set suggestions for fix entity ${fixEntityId}: removed ${removedCount}, `
148
+ + `added ${createdItems.length}, failed ${errorItems.length}`);
149
+
150
+ return { createdItems, errorItems, removedCount };
151
+ } catch (error) {
152
+ this.log.error('Failed to set suggestions for fix entity', error);
153
+ throw new DataAccessError('Failed to set suggestions for fix entity', this, error);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Gets all fixes with their suggestions for a specific opportunity and created date.
159
+ * This method retrieves all fix entities and their associated suggestions for a given opportunity
160
+ * and creation date.
161
+ *
162
+ * @async
163
+ * @param {string} opportunityId - The ID of the opportunity.
164
+ * @param {string} fixEntityCreatedDate - The creation date to filter by (YYYY-MM-DD format).
165
+ * @returns {Promise<Array>} - A promise that resolves to an array of objects containing:
166
+ * - fixEntity: The FixEntity model
167
+ * - suggestions: Array of associated Suggestion models
168
+ * @throws {DataAccessError} - Throws an error if the query fails.
169
+ * @throws {ValidationError} - Throws an error if opportunityId or
170
+ * fixEntityCreatedDate is not provided.
171
+ */
172
+ async getAllFixesWithSuggestionByCreatedAt(opportunityId, fixEntityCreatedDate) {
173
+ guardId('opportunityId', opportunityId, 'FixEntityCollection');
174
+ guardString('fixEntityCreatedDate', fixEntityCreatedDate, 'FixEntityCollection');
175
+
176
+ try {
177
+ const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection');
178
+ const suggestionCollection = this.entityRegistry.getCollection('SuggestionCollection');
179
+
180
+ // Query fix entity suggestions by opportunity ID and created date
181
+ const fixEntitySuggestions = await fixEntitySuggestionCollection
182
+ .allByOpportunityIdAndFixEntityCreatedDate(opportunityId, fixEntityCreatedDate);
183
+
184
+ if (fixEntitySuggestions.length === 0) {
185
+ return [];
186
+ }
187
+
188
+ // Group suggestions by fix entity ID
189
+ const suggestionsByFixEntityId = {};
190
+ const fixEntityIds = new Set();
191
+
192
+ for (const fixEntitySuggestion of fixEntitySuggestions) {
193
+ const fixEntityId = fixEntitySuggestion.getFixEntityId();
194
+ const suggestionId = fixEntitySuggestion.getSuggestionId();
195
+
196
+ fixEntityIds.add(fixEntityId);
197
+
198
+ if (!suggestionsByFixEntityId[fixEntityId]) {
199
+ suggestionsByFixEntityId[fixEntityId] = [];
200
+ }
201
+ suggestionsByFixEntityId[fixEntityId].push(suggestionId);
202
+ }
203
+
204
+ // Get all fix entities
205
+ const fixEntities = await this.batchGetByKeys(
206
+ Array.from(fixEntityIds).map((id) => ({ [this.idName]: id })),
207
+ );
208
+
209
+ // Get all suggestions
210
+ const allSuggestionIds = Object.values(suggestionsByFixEntityId).flat();
211
+ const suggestions = await suggestionCollection.batchGetByKeys(
212
+ allSuggestionIds.map((id) => ({ [suggestionCollection.idName]: id })),
213
+ );
214
+
215
+ // Create a map of suggestions by ID for quick lookup
216
+ const suggestionsById = {};
217
+ for (const suggestion of suggestions.data) {
218
+ suggestionsById[suggestion.getId()] = suggestion;
219
+ }
220
+
221
+ // Combine fix entities with their suggestions
222
+ const result = [];
223
+ for (const fixEntity of fixEntities.data) {
224
+ const fixEntityId = fixEntity.getId();
225
+ const suggestionIds = suggestionsByFixEntityId[fixEntityId] || [];
226
+ const suggestionsForFixEntity = suggestionIds
227
+ .map((id) => suggestionsById[id])
228
+ .filter(Boolean);
229
+
230
+ result.push({
231
+ fixEntity,
232
+ suggestions: suggestionsForFixEntity,
233
+ });
234
+ }
235
+
236
+ return result;
237
+ } catch (error) {
238
+ this.log.error('Failed to get all fixes with suggestions by created date', error);
239
+ throw new DataAccessError('Failed to get all fixes with suggestions by created date', this, error);
240
+ }
241
+ }
24
242
  }
25
243
 
26
244
  export default FixEntityCollection;
@@ -28,6 +28,12 @@ class FixEntity extends BaseModel {
28
28
  FAILED: 'FAILED', // failed to apply the fix
29
29
  ROLLED_BACK: 'ROLLED_BACK', // the fix has been rolled_back
30
30
  };
31
+
32
+ async getSuggestions() {
33
+ const fixEntityCollection = this.entityRegistry.getCollection('FixEntityCollection');
34
+ return fixEntityCollection
35
+ .getSuggestionsByFixEntityId(this.getId());
36
+ }
31
37
  }
32
38
 
33
39
  export default FixEntity;
@@ -15,7 +15,7 @@ import { isIsoDate, isNonEmptyObject } from '@adobe/spacecat-shared-utils';
15
15
  import SchemaBuilder from '../base/schema.builder.js';
16
16
  import FixEntity from './fix-entity.model.js';
17
17
  import FixEntityCollection from './fix-entity.collection.js';
18
- import { Suggestion } from '../suggestion/index.js';
18
+ import Suggestion from '../suggestion/suggestion.model.js';
19
19
 
20
20
  /*
21
21
  Schema Doc: https://electrodb.dev/en/modeling/schema/
@@ -24,7 +24,7 @@ Indexes Doc: https://electrodb.dev/en/modeling/indexes/
24
24
  */
25
25
 
26
26
  const schema = new SchemaBuilder(FixEntity, FixEntityCollection)
27
- .addReference('has_many', 'Suggestion', ['status'])
27
+ .addReference('has_many', 'FixEntitySuggestion', ['updatedAt'], { removeDependents: true })
28
28
  .addReference('belongs_to', 'Opportunity', ['status'])
29
29
  .addAttribute('type', {
30
30
  type: Object.values(Suggestion.TYPES),
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type {
14
- BaseCollection, BaseModel, Opportunity, Suggestion,
14
+ BaseCollection, BaseModel, Opportunity, Suggestion, FixEntitySuggestion,
15
15
  } from '../index';
16
16
 
17
17
  export interface FixEntity extends BaseModel {
@@ -28,8 +28,6 @@ export interface FixEntity extends BaseModel {
28
28
  setPublishedAt(value: string): this;
29
29
  getStatus(): string;
30
30
  setStatus(value: string): this;
31
- getSuggestions(): Promise<Suggestion[]>;
32
- getSuggestionsByUpdatedAt(updatedAt: string): Promise<Suggestion[]>;
33
31
  getType(): string;
34
32
  }
35
33
 
@@ -38,4 +36,6 @@ export interface FixEntityCollection extends BaseCollection<FixEntity> {
38
36
  allByOpportunityIdAndStatus(opportunityId: string, status: string): Promise<FixEntity[]>;
39
37
  findByOpportunityId(opportunityId: string): Promise<FixEntity | null>;
40
38
  findByOpportunityIdAndStatus(opportunityId: string, status: string): Promise<FixEntity | null>;
39
+ getSuggestionsByFixEntityId(fixEntityId: string): Promise<{data: Array<Suggestion>, unprocessed: Array<string>}>;
40
+ setSuggestionsForFixEntity(opportunityId: string, fixEntity: FixEntity, suggestions: Array<Suggestion>): Promise<{createdItems: Array<FixEntitySuggestion>, errorItems: Array<FixEntitySuggestion>, removedCount: number}>;
41
41
  }
@@ -0,0 +1,45 @@
1
+ /*
2
+ * Copyright 2025 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
+ import { guardId } from '../../util/guards.js';
14
+ import BaseCollection from '../base/base.collection.js';
15
+
16
+ /**
17
+ * FixEntitySuggestionCollection - A collection class responsible for managing
18
+ * FixEntitySuggestion junction records. This collection handles the many-to-many
19
+ * relationship between FixEntity and Suggestion entities.
20
+ *
21
+ * This collection provides methods to:
22
+ * - Retrieve junction records by Suggestion ID
23
+ * - Retrieve junction records by FixEntity ID
24
+ *
25
+ * @class FixEntitySuggestionCollection
26
+ * @extends BaseCollection
27
+ */
28
+ class FixEntitySuggestionCollection extends BaseCollection {
29
+ /**
30
+ * Gets all junction records associated with a specific Suggestion.
31
+ *
32
+ * @async
33
+ * @param {string} suggestionId - The ID of the Suggestion.
34
+ * @param {Object} options - Additional query options.
35
+ * @returns {Promise<Array>} - A promise that resolves to
36
+ * an array of FixEntitySuggestion junction records
37
+ * @throws {Error} - Throws an error if the suggestionId is not provided
38
+ */
39
+ async allBySuggestionId(suggestionId, options = {}) {
40
+ guardId('suggestionId', suggestionId, 'FixEntitySuggestionCollection');
41
+ return this.allByIndexKeys({ suggestionId }, options);
42
+ }
43
+ }
44
+
45
+ export default FixEntitySuggestionCollection;
@@ -0,0 +1,38 @@
1
+ /*
2
+ * Copyright 2025 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
+ import BaseModel from '../base/base.model.js';
14
+
15
+ /**
16
+ * FixEntitySuggestion - A junction table class representing the many-to-many relationship
17
+ * between FixEntity and Suggestion entities. This allows one fix entity to be associated
18
+ * with multiple suggestions and one suggestion to be associated with multiple fix entities.
19
+ *
20
+ * @class FixEntitySuggestion
21
+ * @extends BaseModel
22
+ */
23
+ class FixEntitySuggestion extends BaseModel {
24
+ static DEFAULT_UPDATED_BY = 'spacecat';
25
+
26
+ /**
27
+ * Generates the composite keys for the FixEntitySuggestion model.
28
+ * @returns {Object} - The composite keys.
29
+ */
30
+ generateCompositeKeys() {
31
+ return {
32
+ suggestionId: this.getSuggestionId(),
33
+ fixEntityId: this.getFixEntityId(),
34
+ };
35
+ }
36
+ }
37
+
38
+ export default FixEntitySuggestion;
@@ -0,0 +1,49 @@
1
+ /*
2
+ * Copyright 2025 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
+ import SchemaBuilder from '../base/schema.builder.js';
14
+ import FixEntitySuggestion from './fix-entity-suggestion.model.js';
15
+ import FixEntitySuggestionCollection from './fix-entity-suggestion.collection.js';
16
+
17
+ /*
18
+ Schema Doc: https://electrodb.dev/en/modeling/schema/
19
+ Attribute Doc: https://electrodb.dev/en/modeling/attributes/
20
+ Indexes Doc: https://electrodb.dev/en/modeling/indexes/
21
+ */
22
+
23
+ const schema = new SchemaBuilder(FixEntitySuggestion, FixEntitySuggestionCollection)
24
+ .withPrimaryPartitionKeys(['suggestionId'])
25
+ .withPrimarySortKeys(['fixEntityId'])
26
+ .addReference('belongs_to', 'FixEntity')
27
+ .addReference('belongs_to', 'Suggestion')
28
+ .addAttribute('opportunityId', {
29
+ type: 'string',
30
+ required: true,
31
+ readOnly: true,
32
+ })
33
+ .addAttribute('fixEntityCreatedAt', {
34
+ type: 'string',
35
+ required: true,
36
+ readOnly: true,
37
+ })
38
+ .addAttribute('fixEntityCreatedDate', {
39
+ type: 'string',
40
+ readOnly: true,
41
+ watch: ['fixEntityCreatedAt'],
42
+ set: (_, { fixEntityCreatedAt }) => (fixEntityCreatedAt ? fixEntityCreatedAt.split('T')[0] : undefined),
43
+ })
44
+ .addIndex(
45
+ { composite: ['opportunityId'] },
46
+ { composite: ['fixEntityCreatedDate', 'updatedAt'] },
47
+ );
48
+
49
+ export default schema.build();
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Copyright 2025 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
+ import type { BaseCollection, BaseModel, FixEntity, Suggestion } from '../index';
14
+
15
+ export interface FixEntitySuggestion extends BaseModel {
16
+ getFixEntity(): Promise<FixEntity>;
17
+ getSuggestion(): Promise<Suggestion>;
18
+ getFixEntityId(): string;
19
+ setFixEntityId(value: string): this;
20
+ getSuggestionId(): string;
21
+ setSuggestionId(value: string): this;
22
+ getFixEntityCreatedAt(): string;
23
+ setFixEntityCreatedAt(value: string): this;
24
+ getFixEntityCreatedDate(): string;
25
+ setFixEntityCreatedDate(value: string): this;
26
+ }
27
+
28
+ export interface FixEntitySuggestionCollection extends BaseCollection<FixEntitySuggestion> {
29
+ allBySuggestionId(suggestionId: string): Promise<FixEntitySuggestion[]>;
30
+ allByFixEntityId(fixEntityId: string): Promise<FixEntitySuggestion[]>;
31
+ allByOpportunityIdAndFixEntityCreatedDate(opportunityId: string, fixEntityCreatedDate: string): Promise<FixEntitySuggestion[]>;
32
+ }
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Copyright 2025 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
+ import FixEntitySuggestion from './fix-entity-suggestion.model.js';
14
+ import FixEntitySuggestionCollection from './fix-entity-suggestion.collection.js';
15
+
16
+ export {
17
+ FixEntitySuggestion,
18
+ FixEntitySuggestionCollection,
19
+ };
@@ -15,6 +15,7 @@ export type * from './async-job';
15
15
  export type * from './configuration';
16
16
  export type * from './base';
17
17
  export type * from './fix-entity';
18
+ export type * from './fix-entity-suggestion';
18
19
  export type * from './experiment';
19
20
  export type * from './entitlement';
20
21
  export type * from './import-job';
@@ -17,6 +17,7 @@ export * from './base/index.js';
17
17
  export * from './configuration/index.js';
18
18
  export * from './entitlement/index.js';
19
19
  export * from './fix-entity/index.js';
20
+ export * from './fix-entity-suggestion/index.js';
20
21
  export * from './experiment/index.js';
21
22
  export * from './import-job/index.js';
22
23
  export * from './import-url/index.js';
@@ -59,22 +59,151 @@ class Opportunity extends BaseModel {
59
59
  /**
60
60
  * Adds the given fixEntities to this Opportunity. Sets this opportunity as the parent
61
61
  * of each fixEntity, as such the opportunity ID does not need to be provided.
62
+ * Each fixEntity must contain a suggestions array that will be used to create
63
+ * FixEntitySuggestion records.
62
64
  *
63
65
  * @async
64
66
  * @param {Array<Object>} fixEntities - An array of fixEntities objects to add.
67
+ * Each fixEntity must have a suggestions property with at least one suggestion.
65
68
  * @return {Promise<{ createdItems: BaseModel[],
66
69
  * errorItems: { item: Object, error: ValidationError }[] }>} - A promise that
67
70
  * resolves to an object containing the created fixEntities items and any
68
71
  * errors that occurred.
69
72
  */
70
73
  async addFixEntities(fixEntities) {
71
- const childFixEntities = fixEntities.map((fixEntity) => ({
72
- ...fixEntity,
73
- [this.idName]: this.getId(),
74
- }));
75
- return this.entityRegistry
76
- .getCollection('FixEntityCollection')
77
- .createMany(childFixEntities, this);
74
+ const errorItems = [];
75
+ const opportunityId = this.getId();
76
+
77
+ // Step 1: Input validation - categorize fixEntities into valid and invalid
78
+ const validFixEntities = [];
79
+ fixEntities.forEach((fixEntity) => {
80
+ if (!fixEntity.suggestions) {
81
+ errorItems.push({
82
+ item: fixEntity,
83
+ error: new Error('fixEntity must have a suggestions property'),
84
+ });
85
+ } else if (!Array.isArray(fixEntity.suggestions)) {
86
+ errorItems.push({
87
+ item: fixEntity,
88
+ error: new Error('fixEntity.suggestions must be an array'),
89
+ });
90
+ } else if (fixEntity.suggestions.length === 0) {
91
+ errorItems.push({
92
+ item: fixEntity,
93
+ error: new Error('fixEntity.suggestions cannot be empty'),
94
+ });
95
+ } else {
96
+ validFixEntities.push(fixEntity);
97
+ }
98
+ });
99
+
100
+ // If no valid fixEntities, return early
101
+ if (validFixEntities.length === 0) {
102
+ return { createdItems: [], errorItems };
103
+ }
104
+
105
+ const fixEntityCollection = this.entityRegistry.getCollection('FixEntityCollection');
106
+ const suggestionCollection = this.entityRegistry.getCollection('SuggestionCollection');
107
+ const fixEntitySuggestionCollection = this.entityRegistry
108
+ .getCollection('FixEntitySuggestionCollection');
109
+
110
+ // Step 2: Flatten and fetch all unique suggestion IDs
111
+ const allSuggestionIds = new Set();
112
+ validFixEntities.forEach((fixEntity) => {
113
+ fixEntity.suggestions.forEach((suggestionId) => {
114
+ allSuggestionIds.add(suggestionId);
115
+ });
116
+ });
117
+
118
+ const suggestionResults = await suggestionCollection.batchGetByKeys(
119
+ Array.from(allSuggestionIds).map((suggestionId) => ({
120
+ [suggestionCollection.idName]: suggestionId,
121
+ })),
122
+ );
123
+
124
+ // Create a map of suggestionId -> suggestion entity for O(1) retrieval
125
+ const suggestionMap = new Map();
126
+ suggestionResults.data.forEach((suggestion) => {
127
+ suggestionMap.set(suggestion.getId(), suggestion);
128
+ });
129
+
130
+ // Step 3: Validate that all suggestion IDs exist and prepare fixEntities to create
131
+ const fixEntitiesToCreate = [];
132
+ validFixEntities.forEach((fixEntity) => {
133
+ const missingSuggestions = fixEntity.suggestions.filter(
134
+ (suggestionId) => !suggestionMap.has(suggestionId),
135
+ );
136
+
137
+ if (missingSuggestions.length > 0) {
138
+ errorItems.push({
139
+ item: fixEntity,
140
+ error: new Error(`Invalid suggestion IDs: ${missingSuggestions.join(', ')}`),
141
+ });
142
+ } else {
143
+ fixEntitiesToCreate.push(fixEntity);
144
+ }
145
+ });
146
+
147
+ // If no valid fixEntities to create, return early
148
+ if (fixEntitiesToCreate.length === 0) {
149
+ return { createdItems: [], errorItems };
150
+ }
151
+
152
+ // Step 4: Create FixEntity records
153
+ const fixEntityCreateResult = await fixEntityCollection.createMany(
154
+ fixEntitiesToCreate.map((fixEntity) => {
155
+ const { suggestions: _, ...fixEntityWithoutSuggestions } = fixEntity;
156
+ return {
157
+ ...fixEntityWithoutSuggestions,
158
+ [this.idName]: opportunityId,
159
+ };
160
+ }),
161
+ this,
162
+ );
163
+
164
+ // Add any errors from fix entity creation
165
+ if (fixEntityCreateResult.errorItems && fixEntityCreateResult.errorItems.length > 0) {
166
+ // Match error items back to original fixEntities with suggestions
167
+ fixEntityCreateResult.errorItems.forEach((errorItem) => {
168
+ const originalIndex = fixEntitiesToCreate.findIndex(
169
+ (fe) => fe.type === errorItem.item.type
170
+ && JSON.stringify(fe.changeDetails) === JSON.stringify(errorItem.item.changeDetails),
171
+ );
172
+ if (originalIndex !== -1) {
173
+ errorItems.push({
174
+ item: fixEntitiesToCreate[originalIndex],
175
+ error: errorItem.error,
176
+ });
177
+ }
178
+ });
179
+ }
180
+
181
+ // Step 5: Create FixEntitySuggestion junction records
182
+ const junctionRecordsToCreate = [];
183
+ fixEntityCreateResult.createdItems.forEach((createdFixEntity, index) => {
184
+ const originalFixEntity = fixEntitiesToCreate[index];
185
+ const fixEntityId = createdFixEntity.getId();
186
+ const fixEntityCreatedAt = createdFixEntity.getCreatedAt();
187
+
188
+ originalFixEntity.suggestions.forEach((suggestionId) => {
189
+ junctionRecordsToCreate.push({
190
+ opportunityId,
191
+ fixEntityId,
192
+ suggestionId,
193
+ fixEntityCreatedAt,
194
+ });
195
+ });
196
+ });
197
+
198
+ // Create all junction records at once
199
+ if (junctionRecordsToCreate.length > 0) {
200
+ await fixEntitySuggestionCollection.createMany(junctionRecordsToCreate);
201
+ }
202
+
203
+ return {
204
+ createdItems: fixEntityCreateResult.createdItems,
205
+ errorItems,
206
+ };
78
207
  }
79
208
  }
80
209
 
@@ -10,15 +10,13 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import type { BaseCollection, BaseModel, Opportunity } from '../index';
13
+ import type { BaseCollection, BaseModel, Opportunity, FixEntitySuggestion, FixEntity } from '../index';
14
14
 
15
15
  export interface Suggestion extends BaseModel {
16
16
  getData(): object;
17
17
  getKpiDeltas(): object;
18
18
  getOpportunity(): Promise<Opportunity>;
19
19
  getOpportunityId(): string;
20
- getFixEntityId(): string;
21
- getFixEntity(): Promise<object>;
22
20
  getRank(): number;
23
21
  getStatus(): string;
24
22
  getType(): string;
@@ -31,9 +29,9 @@ export interface Suggestion extends BaseModel {
31
29
 
32
30
  export interface SuggestionCollection extends BaseCollection<Suggestion> {
33
31
  allByOpportunityId(opportunityId: string): Promise<Suggestion[]>;
34
- allByFixEntityId(fixEntityId: string): Promise<Suggestion[]>;
35
32
  allByOpportunityIdAndStatus(opportunityId: string, status: string): Promise<Suggestion[]>;
36
33
  bulkUpdateStatus(suggestions: Suggestion[], status: string): Promise<Suggestion[]>;
37
34
  findByOpportunityId(opportunityId: string): Promise<Suggestion | null>;
38
35
  findByOpportunityIdAndStatus(opportunityId: string, status: string): Promise<Suggestion | null>;
36
+ getFixEntitiesBySuggestionId(suggestionId: string): Promise<{data: Array<FixEntity>, unprocessed: Array<string>}>;
39
37
  }
@@ -11,11 +11,18 @@
11
11
  */
12
12
 
13
13
  import BaseCollection from '../base/base.collection.js';
14
+ import DataAccessError from '../../errors/data-access.error.js';
14
15
  import Suggestion from './suggestion.model.js';
16
+ import { guardId } from '../../util/guards.js';
15
17
 
16
18
  /**
17
19
  * SuggestionCollection - A collection class responsible for managing Suggestion entities.
18
- * Extends the BaseCollection to provide specific methods for interacting with Suggestion records.
20
+ * Extends the BaseCollection to provide specific methods for interacting with Suggestion records
21
+ * and their relationships with FixEntities.
22
+ *
23
+ * This collection provides methods to:
24
+ * - Update the status of multiple suggestions in bulk
25
+ * - Retrieve FixEntities associated with a specific Suggestion
19
26
  *
20
27
  * @class SuggestionCollection
21
28
  * @extends BaseCollection
@@ -50,6 +57,40 @@ class SuggestionCollection extends BaseCollection {
50
57
 
51
58
  return suggestions;
52
59
  }
60
+
61
+ /**
62
+ * Gets all FixEntities associated with a specific Suggestion.
63
+ *
64
+ * @async
65
+ * @param {string} suggestionId - The ID of the Suggestion.
66
+ * @returns {Promise<Array>} - A promise that resolves to an array of FixEntity models
67
+ * @throws {DataAccessError} - Throws an error if the suggestionId is not provided or if the
68
+ * query fails.
69
+ */
70
+ async getFixEntitiesBySuggestionId(suggestionId) {
71
+ guardId('suggestionId', suggestionId, 'SuggestionCollection');
72
+
73
+ try {
74
+ const fixEntitySuggestionCollection = this.entityRegistry.getCollection('FixEntitySuggestionCollection');
75
+ const fixEntityCollection = this.entityRegistry.getCollection('FixEntityCollection');
76
+
77
+ // Get all junction records for this suggestion
78
+ const fixEntitySuggestions = await fixEntitySuggestionCollection
79
+ .allBySuggestionId(suggestionId);
80
+
81
+ if (fixEntitySuggestions.length === 0) {
82
+ return [];
83
+ }
84
+
85
+ const fixEntityIds = fixEntitySuggestions.map((record) => record.getFixEntityId());
86
+ const result = await fixEntityCollection
87
+ .batchGetByKeys(fixEntityIds.map((id) => ({ [fixEntityCollection.idName]: id })));
88
+ return result.data;
89
+ } catch (error) {
90
+ this.log.error('Failed to get fix entities for suggestion', error);
91
+ throw new DataAccessError('Failed to get fix entities for suggestion', this, error);
92
+ }
93
+ }
53
94
  }
54
95
 
55
96
  export default SuggestionCollection;
@@ -26,7 +26,7 @@ Indexes Doc: https://electrodb.dev/en/modeling/indexes/
26
26
 
27
27
  const schema = new SchemaBuilder(Suggestion, SuggestionCollection)
28
28
  .addReference('belongs_to', 'Opportunity', ['status', 'rank'])
29
- .addReference('belongs_to', 'FixEntity', ['updatedAt'], { required: false })
29
+ .addReference('has_many', 'FixEntitySuggestion', ['updatedAt'], { removeDependents: true })
30
30
  .addAttribute('type', {
31
31
  type: Object.values(Suggestion.TYPES),
32
32
  required: true,
package/src/util/util.js CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { hasText, isInteger } from '@adobe/spacecat-shared-utils';
14
14
  import pluralize from 'pluralize';
15
+ import { guardArray } from './guards.js';
15
16
 
16
17
  const capitalize = (str) => (hasText(str) ? str[0].toUpperCase() + str.slice(1) : '');
17
18
 
@@ -94,6 +95,19 @@ const zeroPad = (num, length) => {
94
95
  : '0'.repeat(length - str.length) + str;
95
96
  };
96
97
 
98
+ const resolveUpdates = (existingItems, newItems) => {
99
+ guardArray('existingItems', existingItems, 'resolveUpdates');
100
+ guardArray('newItems', newItems, 'resolveUpdates');
101
+
102
+ // Deduplicate new items
103
+ const dedupedNew = [...new Set(newItems)];
104
+
105
+ const toDelete = existingItems.filter((item) => !dedupedNew.includes(item));
106
+ const toCreate = dedupedNew.filter((item) => !existingItems.includes(item));
107
+
108
+ return { toDelete, toCreate };
109
+ };
110
+
97
111
  export {
98
112
  capitalize,
99
113
  classExtends,
@@ -113,4 +127,5 @@ export {
113
127
  sanitizeIdAndAuditFields,
114
128
  sanitizeTimestamps,
115
129
  zeroPad,
130
+ resolveUpdates,
116
131
  };