@adobe/spacecat-shared-data-access 1.55.0 → 1.57.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,17 @@
1
+ # [@adobe/spacecat-shared-data-access-v1.57.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.56.0...@adobe/spacecat-shared-data-access-v1.57.0) (2024-11-22)
2
+
3
+
4
+ ### Features
5
+
6
+ * batch create/update items & open oppty type ([#450](https://github.com/adobe/spacecat-shared/issues/450)) ([642beaf](https://github.com/adobe/spacecat-shared/commit/642beaf3ab1ef9494f00c2148241d3986cca7fe7))
7
+
8
+ # [@adobe/spacecat-shared-data-access-v1.56.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.55.0...@adobe/spacecat-shared-data-access-v1.56.0) (2024-11-21)
9
+
10
+
11
+ ### Features
12
+
13
+ * introduce missing audit id property ([#452](https://github.com/adobe/spacecat-shared/issues/452)) ([c17e447](https://github.com/adobe/spacecat-shared/commit/c17e447b275d9788d587dc44f3043fb60e83c51b))
14
+
1
15
  # [@adobe/spacecat-shared-data-access-v1.55.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v1.54.0...@adobe/spacecat-shared-data-access-v1.55.0) (2024-11-20)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "1.55.0",
3
+ "version": "1.57.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -34,10 +34,10 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@adobe/spacecat-shared-dynamo": "1.3.47",
38
- "@adobe/spacecat-shared-utils": "1.22.4",
39
- "@aws-sdk/client-dynamodb": "3.693.0",
40
- "@aws-sdk/lib-dynamodb": "3.693.0",
37
+ "@adobe/spacecat-shared-dynamo": "1.3.49",
38
+ "@adobe/spacecat-shared-utils": "1.23.0",
39
+ "@aws-sdk/client-dynamodb": "3.696.0",
40
+ "@aws-sdk/lib-dynamodb": "3.696.0",
41
41
  "@types/joi": "17.2.3",
42
42
  "aws-xray-sdk": "3.10.2",
43
43
  "electrodb": "3.0.1",
package/src/dto/audit.js CHANGED
@@ -43,6 +43,7 @@ export const AuditDto = {
43
43
  } : {};
44
44
 
45
45
  return {
46
+ id: audit.getId(),
46
47
  siteId: audit.getSiteId(),
47
48
  auditedAt: audit.getAuditedAt(),
48
49
  auditResult: audit.getAuditResult(),
@@ -62,6 +63,7 @@ export const AuditDto = {
62
63
  */
63
64
  fromDynamoItem: (dynamoItem) => {
64
65
  const auditData = {
66
+ id: dynamoItem.id,
65
67
  siteId: dynamoItem.siteId,
66
68
  auditedAt: dynamoItem.auditedAt,
67
69
  auditResult: dynamoItem.auditResult,
package/src/index.d.ts CHANGED
@@ -33,6 +33,12 @@ export declare const ImportUrlStatus: {
33
33
  * Represents an individual audit of a site.
34
34
  */
35
35
  export interface Audit {
36
+
37
+ /**
38
+ * Retrieves the ID of the audit.
39
+ * @returns {string} The audit ID.
40
+ */
41
+ getId: () => string;
36
42
  /**
37
43
  * Retrieves the site ID associated with this audit.
38
44
  * @returns {string} The site ID.
@@ -12,6 +12,9 @@
12
12
 
13
13
  import { isNonEmptyObject } from '@adobe/spacecat-shared-utils';
14
14
 
15
+ import { ElectroValidationError } from 'electrodb';
16
+
17
+ import ValidationError from '../errors/validation.error.js';
15
18
  import { guardId } from '../util/guards.js';
16
19
 
17
20
  /**
@@ -75,6 +78,10 @@ class BaseCollection {
75
78
  return records.data.map((record) => this._createInstance({ data: record }));
76
79
  }
77
80
 
81
+ _getEnumValues(fieldName) {
82
+ return this.entity.model.schema.attributes[fieldName]?.enumArray;
83
+ }
84
+
78
85
  /**
79
86
  * Finds an entity by its ID.
80
87
  * @async
@@ -92,27 +99,130 @@ class BaseCollection {
92
99
  }
93
100
 
94
101
  /**
95
- * Creates a new entity in the collection.
102
+ * Creates a new entity in the collection and directly persists it to the database.
103
+ * There is no need to call the save method (which is for updates only) after creating
104
+ * the entity.
96
105
  * @async
97
- * @param {Object} data - The data for the entity to be created.
106
+ * @param {Object} item - The data for the entity to be created.
98
107
  * @returns {Promise<BaseModel>} - A promise that resolves to the created model instance.
99
108
  * @throws {Error} - Throws an error if the data is invalid or if the creation process fails.
100
109
  */
101
- async create(data) {
102
- if (!isNonEmptyObject(data)) {
103
- this.log.error(`Failed to create [${this.entityName}]: data is required`);
104
- throw new Error(`Failed to create [${this.entityName}]: data is required`);
110
+ async create(item) {
111
+ if (!isNonEmptyObject(item)) {
112
+ const message = `Failed to create [${this.entityName}]: data is required`;
113
+ this.log.error(message);
114
+ throw new Error(message);
105
115
  }
106
116
 
107
117
  try {
118
+ // todo: catch ElectroDB validation errors and re-throws as ValidationError
108
119
  // todo: validate associations
109
- const record = await this.entity.create(data).go();
120
+ const record = await this.entity.create(item).go();
110
121
  return this._createInstance(record);
111
122
  } catch (error) {
112
123
  this.log.error(`Failed to create [${this.entityName}]`, error);
113
124
  throw error;
114
125
  }
115
126
  }
127
+
128
+ /**
129
+ * Creates multiple entities in the collection and directly persists them to the database in
130
+ * a batch write operation. Batches are written in parallel and are limited to 25 items per batch.
131
+ *
132
+ * @async
133
+ * @param {Array<Object>} newItems - An array of data for the entities to be created.
134
+ * @return {Promise<{ createdItems: BaseModel[],
135
+ * errorItems: { item: Object, error: ElectroValidationError }[] }>} - A promise that resolves to
136
+ * an object containing the created items and any items that failed validation.
137
+ * @throws {ValidationError} - Throws a validation error if any of the items has validation
138
+ * failures.
139
+ */
140
+ async createMany(newItems) {
141
+ if (!Array.isArray(newItems) || newItems.length === 0) {
142
+ const message = `Failed to create many [${this.entityName}]: items must be a non-empty array`;
143
+ this.log.error(message);
144
+ throw new Error(message);
145
+ }
146
+
147
+ try {
148
+ const validatedItems = [];
149
+ const errorItems = [];
150
+ const createdItems = [];
151
+
152
+ newItems.forEach((item) => {
153
+ try {
154
+ this.entity.put(item).params();
155
+ validatedItems.push(item);
156
+ } catch (error) {
157
+ if (error instanceof ElectroValidationError) {
158
+ errorItems.push({ item, error: new ValidationError(error) });
159
+ }
160
+ }
161
+ });
162
+
163
+ /**
164
+ * ElectroDB does not return the created items in the response for batch write operations.
165
+ * This listener intercepts the batch write requests and extracts the items before they
166
+ * are stored in the database.
167
+ * @param {Object} result - The result of the operation.
168
+ */
169
+ const requestItemsListener = (result) => {
170
+ if (result?.type !== 'query' || result?.method !== 'batchWrite') {
171
+ return;
172
+ }
173
+
174
+ result.params?.RequestItems[this.entity.model.table].forEach((putRequest) => {
175
+ createdItems.push(putRequest.PutRequest.Item);
176
+ });
177
+ };
178
+
179
+ let records = [];
180
+ if (validatedItems.length > 0) {
181
+ const response = await this.entity.put(validatedItems).go(
182
+ { listeners: [requestItemsListener] },
183
+ );
184
+ records = this._createInstances({ data: createdItems });
185
+
186
+ if (Array.isArray(response.unprocessed) && response.unprocessed.length > 0) {
187
+ this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`);
188
+ }
189
+ }
190
+
191
+ return { createdItems: records, errorItems };
192
+ } catch (error) {
193
+ this.log.error(`Failed to create many [${this.entityName}]`, error);
194
+ throw error;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Updates a collection of entities in the database using a batch write (put) operation.
200
+ *
201
+ * @async
202
+ * @param {Array<BaseModel>} items - An array of model instances to be updated.
203
+ * @return {Promise<void>} - A promise that resolves when the update operation is complete.
204
+ * @throws {Error} - Throws an error if the update operation fails.
205
+ * @protected
206
+ */
207
+ async _saveMany(items) {
208
+ if (!Array.isArray(items) || items.length === 0) {
209
+ const message = `Failed to save many [${this.entityName}]: items must be a non-empty array`;
210
+ this.log.error(message);
211
+ throw new Error(message);
212
+ }
213
+
214
+ try {
215
+ const updates = items.map((item) => item.record);
216
+ const response = await this.entity.put(updates).go();
217
+
218
+ if (response.unprocessed) {
219
+ this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`);
220
+ }
221
+ } catch (error) {
222
+ this.log.error(`Failed to save many [${this.entityName}]`, error);
223
+ throw error;
224
+ }
225
+ }
116
226
  }
117
227
 
118
228
  export default BaseCollection;
@@ -25,6 +25,8 @@ export interface BaseModel {
25
25
  * Interface representing an Opportunity model, extending BaseModel.
26
26
  */
27
27
  export interface Opportunity extends BaseModel {
28
+ // eslint-disable-next-line no-use-before-define
29
+ addSuggestions(suggestions: object[]): Promise<Suggestion[]>;
28
30
  // eslint-disable-next-line no-use-before-define
29
31
  getSuggestions(): Promise<Suggestion[]>;
30
32
  getSiteId(): string;
@@ -73,7 +75,8 @@ export interface Suggestion extends BaseModel {
73
75
  */
74
76
  export interface BaseCollection<T extends BaseModel> {
75
77
  findById(id: string): Promise<T>;
76
- create(data: object): Promise<T>;
78
+ create(item: object): Promise<T>;
79
+ createMany(items: object[]): Promise<T[]>;
77
80
  }
78
81
 
79
82
  /**
@@ -90,6 +93,7 @@ export interface OpportunityCollection extends BaseCollection<Opportunity> {
90
93
  export interface SuggestionCollection extends BaseCollection<Suggestion> {
91
94
  allByOpportunityId(opportunityId: string): Promise<Suggestion[]>;
92
95
  allByOpportunityIdAndStatus(opportunityId: string, status: string): Promise<Suggestion[]>;
96
+ bulkUpdateStatus(suggestions: Suggestion[], status: string): Promise<Suggestion[]>;
93
97
  }
94
98
 
95
99
  /**
@@ -22,6 +22,25 @@ import BaseModel from './base.model.js';
22
22
  */
23
23
 
24
24
  class Opportunity extends BaseModel {
25
+ /**
26
+ * Adds the given suggestions to this Opportunity. Sets this opportunity as the parent
27
+ * of each suggestion, as such the opportunity ID does not need to be provided.
28
+ *
29
+ * @async
30
+ * @param {Array<Object>} suggestions - An array of suggestion objects to add.
31
+ * @return {Promise<{ createdItems: BaseModel[],
32
+ * errorItems: { item: Object, error: ValidationError }[] }>} - A promise that
33
+ * resolves to an object containing the created suggestion items and any
34
+ * errors that occurred.
35
+ */
36
+ async addSuggestions(suggestions) {
37
+ const childSuggestions = suggestions.map((suggestion) => ({
38
+ ...suggestion,
39
+ [this.idName]: this.getId(),
40
+ }));
41
+ return this._getAssociation('SuggestionCollection', 'createMany', childSuggestions);
42
+ }
43
+
25
44
  /**
26
45
  * Retrieves all Suggestion entities associated to this Opportunity.
27
46
  * @async
@@ -38,7 +38,7 @@ class SuggestionCollection extends BaseCollection {
38
38
  * Retrieves all Suggestion entities by their associated Opportunity ID.
39
39
  * @async
40
40
  * @param {string} opportunityId - The unique identifier of the associated Opportunity.
41
- * @returns {Promise<Array<Suggestion>>} - A promise that resolves to an array of Suggestion
41
+ * @returns {Promise<Suggestion[]>} - A promise that resolves to an array of Suggestion
42
42
  * instances related to the given Opportunity ID.
43
43
  * @throws {Error} - Throws an error if the opportunityId is not provided or if the query fails.
44
44
  */
@@ -75,6 +75,37 @@ class SuggestionCollection extends BaseCollection {
75
75
 
76
76
  return this._createInstances(records);
77
77
  }
78
+
79
+ /**
80
+ * Updates the status of multiple given suggestions. The given status must conform
81
+ * to the status enum defined in the Suggestion schema.
82
+ * Saves the updated suggestions to the database automatically.
83
+ * You don't need to call save() on the suggestions after calling this method.
84
+ * @async
85
+ * @param {Suggestion[]} suggestions - An array of Suggestion instances to update.
86
+ * @param {string} status - The new status to set for the suggestions.
87
+ * @return {Promise<*>} - A promise that resolves to the updated suggestions.
88
+ * @throws {Error} - Throws an error if the suggestions are not provided
89
+ * or if the status is invalid.
90
+ */
91
+ async bulkUpdateStatus(suggestions, status) {
92
+ if (!Array.isArray(suggestions)) {
93
+ throw new Error('Suggestions must be an array');
94
+ }
95
+
96
+ const validStatuses = this._getEnumValues('status');
97
+ if (!validStatuses?.includes(status)) {
98
+ throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
99
+ }
100
+
101
+ suggestions.forEach((suggestion) => {
102
+ suggestion.setStatus(status);
103
+ });
104
+
105
+ await this._saveMany(suggestions);
106
+
107
+ return suggestions;
108
+ }
78
109
  }
79
110
 
80
111
  export default SuggestionCollection;
@@ -53,7 +53,7 @@ const OpportunitySchema = {
53
53
  validation: (value) => !isValidUrl(value),
54
54
  },
55
55
  type: {
56
- type: ['broken-backlinks', 'broken-internal-links'],
56
+ type: 'string',
57
57
  readOnly: true,
58
58
  required: true,
59
59
  },
@@ -45,6 +45,7 @@ class Patcher {
45
45
  this.model = entity.model;
46
46
  this.idName = `${this.model.name.toLowerCase()}Id`;
47
47
  this.record = record;
48
+ this.updates = {};
48
49
 
49
50
  this.patchRecord = null;
50
51
  }
@@ -104,6 +105,7 @@ class Patcher {
104
105
  [propertyName]: value,
105
106
  });
106
107
  this.record[propertyName] = value;
108
+ this.updates[propertyName] = value;
107
109
  }
108
110
 
109
111
  /**
@@ -175,9 +177,20 @@ class Patcher {
175
177
  * @throws {Error} - Throws an error if the save operation fails.
176
178
  */
177
179
  async save() {
180
+ if (!this.hasUpdates()) {
181
+ return;
182
+ }
178
183
  await this.#getPatchRecord().go();
179
184
  this.record.updatedAt = new Date().getTime();
180
185
  }
186
+
187
+ getUpdates() {
188
+ return this.updates;
189
+ }
190
+
191
+ hasUpdates() {
192
+ return Object.keys(this.updates).length > 0;
193
+ }
181
194
  }
182
195
 
183
196
  export default Patcher;