@adobe/spacecat-shared-tokowaka-client 1.1.0 → 1.2.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/src/index.js CHANGED
@@ -15,7 +15,7 @@ import { hasText, isNonEmptyObject } from '@adobe/spacecat-shared-utils';
15
15
  import MapperRegistry from './mappers/mapper-registry.js';
16
16
  import CdnClientRegistry from './cdn/cdn-client-registry.js';
17
17
  import { mergePatches } from './utils/patch-utils.js';
18
- import { getTokowakaConfigS3Path } from './utils/s3-utils.js';
18
+ import { getTokowakaConfigS3Path, getTokowakaMetaconfigS3Path } from './utils/s3-utils.js';
19
19
  import { groupSuggestionsByUrlPath, filterEligibleSuggestions } from './utils/suggestion-utils.js';
20
20
  import { getEffectiveBaseURL } from './utils/site-utils.js';
21
21
  import { fetchHtmlWithWarmup } from './utils/custom-html-utils.js';
@@ -93,16 +93,14 @@ class TokowakaClient {
93
93
  }
94
94
 
95
95
  /**
96
- * Generates Tokowaka site configuration from suggestions
97
- * @param {Object} site - Site entity
96
+ * Generates Tokowaka site configuration from suggestions for a specific URL
97
+ * @param {string} url - Full URL for which to generate config
98
98
  * @param {Object} opportunity - Opportunity entity
99
99
  * @param {Array} suggestionsToDeploy - Array of suggestion entities to deploy
100
- * @returns {Object} - Tokowaka configuration object
100
+ * @returns {Object} - Tokowaka configuration object for the URL
101
101
  */
102
- generateConfig(site, opportunity, suggestionsToDeploy) {
102
+ generateConfig(url, opportunity, suggestionsToDeploy) {
103
103
  const opportunityType = opportunity.getType();
104
- const siteId = site.getId();
105
- const baseURL = getEffectiveBaseURL(site);
106
104
 
107
105
  const mapper = this.mapperRegistry.getMapper(opportunityType);
108
106
  if (!mapper) {
@@ -113,33 +111,27 @@ class TokowakaClient {
113
111
  );
114
112
  }
115
113
 
116
- // Group suggestions by URL
117
- const suggestionsByUrl = groupSuggestionsByUrlPath(suggestionsToDeploy, baseURL, this.log);
118
-
119
- // Generate patches for each URL using the mapper
120
- const tokowakaOptimizations = {};
114
+ // Extract URL path from the full URL
115
+ const urlObj = new URL(url);
116
+ const urlPath = urlObj.pathname;
121
117
 
122
- Object.entries(suggestionsByUrl).forEach(([urlPath, urlSuggestions]) => {
123
- const patches = mapper.suggestionsToPatches(
124
- urlPath,
125
- urlSuggestions,
126
- opportunity.getId(),
127
- );
118
+ // Generate patches for the URL using the mapper
119
+ const patches = mapper.suggestionsToPatches(
120
+ urlPath,
121
+ suggestionsToDeploy,
122
+ opportunity.getId(),
123
+ );
128
124
 
129
- if (patches.length > 0) {
130
- tokowakaOptimizations[urlPath] = {
131
- prerender: mapper.requiresPrerender(),
132
- patches,
133
- };
134
- }
135
- });
125
+ if (patches.length === 0) {
126
+ return null;
127
+ }
136
128
 
137
129
  return {
138
- siteId,
139
- baseURL,
130
+ url,
140
131
  version: '1.0',
141
- tokowakaForceFail: false,
142
- tokowakaOptimizations,
132
+ forceFail: false,
133
+ prerender: mapper.requiresPrerender(),
134
+ patches,
143
135
  };
144
136
  }
145
137
 
@@ -160,20 +152,98 @@ class TokowakaClient {
160
152
  }
161
153
 
162
154
  /**
163
- * Fetches existing Tokowaka configuration from S3
164
- * @param {string} siteTokowakaKey - Tokowaka API key (used as S3 key prefix)
155
+ * Fetches domain-level metaconfig from S3
156
+ * @param {string} url - Full URL (used to extract domain)
157
+ * @param {boolean} isPreview - Whether to fetch from preview path (default: false)
158
+ * @returns {Promise<Object|null>} - Metaconfig object or null if not found
159
+ */
160
+ async fetchMetaconfig(url, isPreview = false) {
161
+ if (!hasText(url)) {
162
+ throw this.#createError('URL is required', HTTP_BAD_REQUEST);
163
+ }
164
+
165
+ const s3Path = getTokowakaMetaconfigS3Path(url, this.log, isPreview);
166
+ const bucketName = isPreview ? this.previewBucketName : this.deployBucketName;
167
+
168
+ try {
169
+ const command = new GetObjectCommand({
170
+ Bucket: bucketName,
171
+ Key: s3Path,
172
+ });
173
+
174
+ const response = await this.s3Client.send(command);
175
+ const bodyContents = await response.Body.transformToString();
176
+ const metaconfig = JSON.parse(bodyContents);
177
+
178
+ this.log.debug(`Successfully fetched metaconfig from s3://${bucketName}/${s3Path}`);
179
+ return metaconfig;
180
+ } catch (error) {
181
+ // If metaconfig doesn't exist (NoSuchKey), return null
182
+ if (error.name === 'NoSuchKey' || error.Code === 'NoSuchKey') {
183
+ this.log.debug(`No metaconfig found at s3://${bucketName}/${s3Path}`);
184
+ return null;
185
+ }
186
+
187
+ // For other errors, log and throw
188
+ this.log.error(`Failed to fetch metaconfig from S3: ${error.message}`, error);
189
+ throw this.#createError(`S3 fetch failed: ${error.message}`, HTTP_INTERNAL_SERVER_ERROR);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Uploads domain-level metaconfig to S3
195
+ * @param {string} url - Full URL (used to extract domain)
196
+ * @param {Object} metaconfig - Metaconfig object (siteId, prerender)
197
+ * @param {boolean} isPreview - Whether to upload to preview path (default: false)
198
+ * @returns {Promise<string>} - S3 key of uploaded metaconfig
199
+ */
200
+ async uploadMetaconfig(url, metaconfig, isPreview = false) {
201
+ if (!hasText(url)) {
202
+ throw this.#createError('URL is required', HTTP_BAD_REQUEST);
203
+ }
204
+
205
+ if (!isNonEmptyObject(metaconfig)) {
206
+ throw this.#createError('Metaconfig object is required', HTTP_BAD_REQUEST);
207
+ }
208
+
209
+ const s3Path = getTokowakaMetaconfigS3Path(url, this.log, isPreview);
210
+ const bucketName = isPreview ? this.previewBucketName : this.deployBucketName;
211
+
212
+ try {
213
+ const command = new PutObjectCommand({
214
+ Bucket: bucketName,
215
+ Key: s3Path,
216
+ Body: JSON.stringify(metaconfig, null, 2),
217
+ ContentType: 'application/json',
218
+ });
219
+
220
+ await this.s3Client.send(command);
221
+ this.log.info(`Successfully uploaded metaconfig to s3://${bucketName}/${s3Path}`);
222
+
223
+ return s3Path;
224
+ } catch (error) {
225
+ this.log.error(`Failed to upload metaconfig to S3: ${error.message}`, error);
226
+ throw this.#createError(`S3 upload failed: ${error.message}`, HTTP_INTERNAL_SERVER_ERROR);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Fetches existing Tokowaka configuration from S3 for a specific URL
232
+ * @param {string} url - Full URL (e.g., 'https://www.example.com/products/item')
233
+ * @param {boolean} isPreview - Whether to fetch from preview path (default: false)
165
234
  * @returns {Promise<Object|null>} - Existing configuration object or null if not found
166
235
  */
167
- async fetchConfig(siteTokowakaKey) {
168
- if (!hasText(siteTokowakaKey)) {
169
- throw this.#createError('Tokowaka API key is required', HTTP_BAD_REQUEST);
236
+ async fetchConfig(url, isPreview = false) {
237
+ if (!hasText(url)) {
238
+ throw this.#createError('URL is required', HTTP_BAD_REQUEST);
170
239
  }
171
240
 
172
- const s3Path = getTokowakaConfigS3Path(siteTokowakaKey);
241
+ const s3Path = getTokowakaConfigS3Path(url, this.log, isPreview);
242
+ const bucketName = isPreview ? this.previewBucketName : this.deployBucketName;
173
243
 
174
244
  try {
175
245
  const command = new GetObjectCommand({
176
- Bucket: this.deployBucketName,
246
+ Bucket: bucketName,
177
247
  Key: s3Path,
178
248
  });
179
249
 
@@ -181,12 +251,12 @@ class TokowakaClient {
181
251
  const bodyContents = await response.Body.transformToString();
182
252
  const config = JSON.parse(bodyContents);
183
253
 
184
- this.log.debug(`Successfully fetched existing Tokowaka config from s3://${this.deployBucketName}/${s3Path}`);
254
+ this.log.debug(`Successfully fetched existing Tokowaka config from s3://${bucketName}/${s3Path}`);
185
255
  return config;
186
256
  } catch (error) {
187
257
  // If config doesn't exist (NoSuchKey), return null
188
258
  if (error.name === 'NoSuchKey' || error.Code === 'NoSuchKey') {
189
- this.log.debug(`No existing Tokowaka config found at s3://${this.deployBucketName}/${s3Path}`);
259
+ this.log.debug(`No existing Tokowaka config found at s3://${bucketName}/${s3Path}`);
190
260
  return null;
191
261
  }
192
262
 
@@ -198,9 +268,9 @@ class TokowakaClient {
198
268
 
199
269
  /**
200
270
  * Merges existing configuration with new configuration
201
- * For each URL path, checks patch key:
271
+ * Checks patch key:
202
272
  * - Patches are identified by opportunityId+suggestionId
203
- * - Heading patches (no suggestionId) are identified by opportunityId:heading
273
+ * - Heading patches (no suggestionId) are identified by opportunityId
204
274
  * - If exists: updates the patch
205
275
  * - If not exists: adds new patch to the array
206
276
  * @param {Object} existingConfig - Existing configuration from S3
@@ -212,73 +282,55 @@ class TokowakaClient {
212
282
  return newConfig;
213
283
  }
214
284
 
215
- // Start with existing config structure
216
- const mergedConfig = {
285
+ const existingPatches = existingConfig.patches || [];
286
+ const newPatches = newConfig.patches || [];
287
+
288
+ const { patches: mergedPatches, updateCount, addCount } = mergePatches(
289
+ existingPatches,
290
+ newPatches,
291
+ );
292
+
293
+ this.log.debug(`Merged patches: ${updateCount} updated, ${addCount} added`);
294
+
295
+ return {
217
296
  ...existingConfig,
218
- baseURL: newConfig.baseURL,
297
+ url: newConfig.url,
219
298
  version: newConfig.version,
220
- tokowakaForceFail: newConfig.tokowakaForceFail,
299
+ forceFail: newConfig.forceFail,
300
+ prerender: newConfig.prerender,
301
+ patches: mergedPatches,
221
302
  };
222
-
223
- // Merge optimizations for each URL path
224
- Object.entries(newConfig.tokowakaOptimizations).forEach(([urlPath, newOptimization]) => {
225
- const existingOptimization = mergedConfig.tokowakaOptimizations[urlPath];
226
-
227
- if (!existingOptimization) {
228
- // URL path doesn't exist in existing config, add it entirely
229
- mergedConfig.tokowakaOptimizations[urlPath] = newOptimization;
230
- this.log.debug(`Added new URL path: ${urlPath}`);
231
- } else {
232
- // URL path exists, merge patches
233
- const existingPatches = existingOptimization.patches || [];
234
- const newPatches = newOptimization.patches || [];
235
-
236
- const { patches: mergedPatches, updateCount, addCount } = mergePatches(
237
- existingPatches,
238
- newPatches,
239
- );
240
-
241
- mergedConfig.tokowakaOptimizations[urlPath] = {
242
- ...existingOptimization,
243
- prerender: newOptimization.prerender,
244
- patches: mergedPatches,
245
- };
246
-
247
- this.log.debug(`Merged patches for ${urlPath}: ${updateCount} updated, ${addCount} added`);
248
- }
249
- });
250
-
251
- return mergedConfig;
252
303
  }
253
304
 
254
305
  /**
255
- * Uploads Tokowaka configuration to S3
256
- * @param {string} siteTokowakaKey - Tokowaka API key (used as S3 key prefix)
306
+ * Uploads Tokowaka configuration to S3 for a specific URL
307
+ * @param {string} url - Full URL (e.g., 'https://www.example.com/products/item')
257
308
  * @param {Object} config - Tokowaka configuration object
258
309
  * @param {boolean} isPreview - Whether to upload to preview path (default: false)
259
310
  * @returns {Promise<string>} - S3 key of uploaded config
260
311
  */
261
- async uploadConfig(siteTokowakaKey, config, isPreview = false) {
262
- if (!hasText(siteTokowakaKey)) {
263
- throw this.#createError('Tokowaka API key is required', HTTP_BAD_REQUEST);
312
+ async uploadConfig(url, config, isPreview = false) {
313
+ if (!hasText(url)) {
314
+ throw this.#createError('URL is required', HTTP_BAD_REQUEST);
264
315
  }
265
316
 
266
317
  if (!isNonEmptyObject(config)) {
267
318
  throw this.#createError('Config object is required', HTTP_BAD_REQUEST);
268
319
  }
269
320
 
270
- const s3Path = getTokowakaConfigS3Path(siteTokowakaKey, isPreview);
321
+ const s3Path = getTokowakaConfigS3Path(url, this.log, isPreview);
322
+ const bucketName = isPreview ? this.previewBucketName : this.deployBucketName;
271
323
 
272
324
  try {
273
325
  const command = new PutObjectCommand({
274
- Bucket: isPreview ? this.previewBucketName : this.deployBucketName,
326
+ Bucket: bucketName,
275
327
  Key: s3Path,
276
328
  Body: JSON.stringify(config, null, 2),
277
329
  ContentType: 'application/json',
278
330
  });
279
331
 
280
332
  await this.s3Client.send(command);
281
- this.log.info(`Successfully uploaded Tokowaka config to s3://${this.deployBucketName}/${s3Path}`);
333
+ this.log.info(`Successfully uploaded Tokowaka config to s3://${bucketName}/${s3Path}`);
282
334
 
283
335
  return s3Path;
284
336
  } catch (error) {
@@ -288,19 +340,19 @@ class TokowakaClient {
288
340
  }
289
341
 
290
342
  /**
291
- * Invalidates CDN cache for the Tokowaka config
343
+ * Invalidates CDN cache for the Tokowaka config for a specific URL
292
344
  * Currently supports CloudFront only
293
- * @param {string} apiKey - Tokowaka API key
345
+ * @param {string} url - Full URL (e.g., 'https://www.example.com/products/item')
294
346
  * @param {string} provider - CDN provider name (default: 'cloudfront')
295
347
  * @param {boolean} isPreview - Whether to invalidate preview path (default: false)
296
348
  * @returns {Promise<Object|null>} - CDN invalidation result or null if skipped
297
349
  */
298
- async invalidateCdnCache(apiKey, provider, isPreview = false) {
299
- if (!hasText(apiKey) || !hasText(provider)) {
300
- throw this.#createError('Tokowaka API key and provider are required', HTTP_BAD_REQUEST);
350
+ async invalidateCdnCache(url, provider, isPreview = false) {
351
+ if (!hasText(url) || !hasText(provider)) {
352
+ throw this.#createError('URL and provider are required', HTTP_BAD_REQUEST);
301
353
  }
302
354
  try {
303
- const pathsToInvalidate = [`/${getTokowakaConfigS3Path(apiKey, isPreview)}`];
355
+ const pathsToInvalidate = [`/${getTokowakaConfigS3Path(url, this.log, isPreview)}`];
304
356
  this.log.debug(`Invalidating CDN cache for ${pathsToInvalidate.length} paths via ${provider}`);
305
357
  const cdnClient = this.cdnClientRegistry.getClient(provider);
306
358
  if (!cdnClient) {
@@ -321,23 +373,17 @@ class TokowakaClient {
321
373
 
322
374
  /**
323
375
  * Deploys suggestions to Tokowaka by generating config and uploading to S3
376
+ * Now creates one file per URL instead of a single file with all URLs
377
+ * Also creates/updates domain-level metadata if needed
324
378
  * @param {Object} site - Site entity
325
379
  * @param {Object} opportunity - Opportunity entity
326
380
  * @param {Array} suggestions - Array of suggestion entities to deploy
327
381
  * @returns {Promise<Object>} - Deployment result with succeeded/failed suggestions
328
382
  */
329
383
  async deploySuggestions(site, opportunity, suggestions) {
330
- // Get site's Tokowaka API key
331
- const { apiKey } = site.getConfig()?.getTokowakaConfig() || {};
332
-
333
- if (!hasText(apiKey)) {
334
- throw this.#createError(
335
- 'Site does not have a Tokowaka API key configured. Please onboard the site to Tokowaka first.',
336
- HTTP_BAD_REQUEST,
337
- );
338
- }
339
-
340
384
  const opportunityType = opportunity.getType();
385
+ const baseURL = getEffectiveBaseURL(site);
386
+ const siteId = site.getId();
341
387
  const mapper = this.mapperRegistry.getMapper(opportunityType);
342
388
  if (!mapper) {
343
389
  throw this.#createError(
@@ -366,42 +412,67 @@ class TokowakaClient {
366
412
  };
367
413
  }
368
414
 
369
- // Fetch existing configuration from S3
370
- this.log.debug(`Fetching existing Tokowaka config for site ${site.getId()}`);
371
- const existingConfig = await this.fetchConfig(apiKey);
415
+ // Group suggestions by URL
416
+ const suggestionsByUrl = groupSuggestionsByUrlPath(eligibleSuggestions, baseURL, this.log);
372
417
 
373
- // Generate configuration with eligible suggestions only
374
- this.log.debug(`Generating Tokowaka config for site ${site.getId()}, opportunity ${opportunity.getId()}`);
375
- const newConfig = this.generateConfig(
376
- site,
377
- opportunity,
378
- eligibleSuggestions,
379
- );
418
+ // Check/create domain-level metaconfig (only need to do this once per deployment)
419
+ const firstUrl = new URL(Object.keys(suggestionsByUrl)[0], baseURL).toString();
420
+ let metaconfig = await this.fetchMetaconfig(firstUrl);
380
421
 
381
- if (Object.keys(newConfig.tokowakaOptimizations).length === 0) {
382
- this.log.warn('No eligible suggestions to deploy');
383
- return {
384
- succeededSuggestions: [],
385
- failedSuggestions: suggestions,
422
+ if (!metaconfig) {
423
+ this.log.info('Creating domain-level metaconfig');
424
+ metaconfig = {
425
+ siteId,
426
+ prerender: mapper.requiresPrerender(),
386
427
  };
428
+ await this.uploadMetaconfig(firstUrl, metaconfig);
429
+ } else {
430
+ this.log.debug('Domain-level metaconfig already exists');
387
431
  }
388
432
 
389
- // Merge with existing config
390
- const config = this.mergeConfigs(existingConfig, newConfig);
433
+ // Process each URL separately
434
+ const s3Paths = [];
435
+ const cdnInvalidations = [];
391
436
 
392
- // Upload to S3
393
- this.log.info(`Uploading Tokowaka config for ${eligibleSuggestions.length} suggestions`);
394
- const s3Path = await this.uploadConfig(apiKey, config);
437
+ for (const [urlPath, urlSuggestions] of Object.entries(suggestionsByUrl)) {
438
+ const fullUrl = new URL(urlPath, baseURL).toString();
439
+ this.log.debug(`Processing ${urlSuggestions.length} suggestions for URL: ${fullUrl}`);
395
440
 
396
- // Invalidate CDN cache (non-blocking, failures are logged but don't fail deployment)
397
- const cdnInvalidationResult = await this.invalidateCdnCache(
398
- apiKey,
399
- this.env.TOKOWAKA_CDN_PROVIDER,
400
- );
441
+ // Fetch existing configuration for this URL from S3
442
+ // eslint-disable-next-line no-await-in-loop
443
+ const existingConfig = await this.fetchConfig(fullUrl);
444
+
445
+ // Generate configuration for this URL with eligible suggestions only
446
+ const newConfig = this.generateConfig(fullUrl, opportunity, urlSuggestions);
447
+
448
+ if (!newConfig || !newConfig.patches || newConfig.patches.length === 0) {
449
+ this.log.warn(`No eligible suggestions to deploy for URL: ${fullUrl}`);
450
+ // eslint-disable-next-line no-continue
451
+ continue;
452
+ }
453
+
454
+ // Merge with existing config for this URL
455
+ const config = this.mergeConfigs(existingConfig, newConfig);
456
+
457
+ // Upload to S3
458
+ // eslint-disable-next-line no-await-in-loop
459
+ const s3Path = await this.uploadConfig(fullUrl, config);
460
+ s3Paths.push(s3Path);
461
+
462
+ // Invalidate CDN cache (non-blocking, failures are logged but don't fail deployment)
463
+ // eslint-disable-next-line no-await-in-loop
464
+ const cdnInvalidationResult = await this.invalidateCdnCache(
465
+ fullUrl,
466
+ this.env.TOKOWAKA_CDN_PROVIDER,
467
+ );
468
+ cdnInvalidations.push(cdnInvalidationResult);
469
+ }
470
+
471
+ this.log.info(`Uploaded Tokowaka configs for ${s3Paths.length} URLs`);
401
472
 
402
473
  return {
403
- s3Path,
404
- cdnInvalidation: cdnInvalidationResult,
474
+ s3Paths,
475
+ cdnInvalidations,
405
476
  succeededSuggestions: eligibleSuggestions,
406
477
  failedSuggestions: ineligibleSuggestions,
407
478
  };
@@ -409,23 +480,15 @@ class TokowakaClient {
409
480
 
410
481
  /**
411
482
  * Rolls back deployed suggestions by removing their patches from the configuration
483
+ * Now updates one file per URL instead of a single file with all URLs
412
484
  * @param {Object} site - Site entity
413
485
  * @param {Object} opportunity - Opportunity entity
414
486
  * @param {Array} suggestions - Array of suggestion entities to rollback
415
487
  * @returns {Promise<Object>} - Rollback result with succeeded/failed suggestions
416
488
  */
417
489
  async rollbackSuggestions(site, opportunity, suggestions) {
418
- // Get site's Tokowaka API key
419
- const { apiKey } = site.getConfig()?.getTokowakaConfig() || {};
420
-
421
- if (!hasText(apiKey)) {
422
- throw this.#createError(
423
- 'Site does not have a Tokowaka API key configured. Please onboard the site to Tokowaka first.',
424
- HTTP_BAD_REQUEST,
425
- );
426
- }
427
-
428
490
  const opportunityType = opportunity.getType();
491
+ const baseURL = getEffectiveBaseURL(site);
429
492
  const mapper = this.mapperRegistry.getMapper(opportunityType);
430
493
  if (!mapper) {
431
494
  throw this.#createError(
@@ -455,78 +518,89 @@ class TokowakaClient {
455
518
  };
456
519
  }
457
520
 
458
- // Fetch existing configuration from S3
459
- this.log.debug(`Fetching existing Tokowaka config for site ${site.getId()}`);
460
- const existingConfig = await this.fetchConfig(apiKey);
521
+ // Group suggestions by URL
522
+ const suggestionsByUrl = groupSuggestionsByUrlPath(eligibleSuggestions, baseURL, this.log);
461
523
 
462
- if (!existingConfig || !existingConfig.tokowakaOptimizations) {
463
- this.log.warn('No existing configuration found to rollback from');
464
- return {
465
- succeededSuggestions: [],
466
- failedSuggestions: eligibleSuggestions.map((suggestion) => ({
467
- suggestion,
468
- reason: 'No existing configuration found',
469
- })),
470
- };
471
- }
524
+ // Process each URL separately
525
+ const s3Paths = [];
526
+ const cdnInvalidations = [];
527
+ let totalRemovedCount = 0;
472
528
 
473
- // Extract suggestion IDs to remove
474
- const suggestionIdsToRemove = eligibleSuggestions.map((s) => s.getId());
475
- const updatedConfig = mapper.rollbackPatches(
476
- existingConfig,
477
- suggestionIdsToRemove,
478
- opportunity.getId(),
479
- );
529
+ for (const [urlPath, urlSuggestions] of Object.entries(suggestionsByUrl)) {
530
+ const fullUrl = new URL(urlPath, baseURL).toString();
531
+ this.log.debug(`Rolling back ${urlSuggestions.length} suggestions for URL: ${fullUrl}`);
480
532
 
481
- if (updatedConfig.removedCount === 0) {
482
- this.log.warn('No patches found matching the provided suggestion IDs');
483
- return {
484
- succeededSuggestions: [],
485
- failedSuggestions: eligibleSuggestions.map((suggestion) => ({
486
- suggestion,
487
- reason: 'No patches found for this suggestion in the current configuration',
488
- })),
489
- };
490
- }
533
+ // Fetch existing configuration for this URL from S3
534
+ // eslint-disable-next-line no-await-in-loop
535
+ const existingConfig = await this.fetchConfig(fullUrl);
491
536
 
492
- this.log.info(`Removed ${updatedConfig.removedCount} patches from configuration`);
537
+ if (!existingConfig || !existingConfig.patches) {
538
+ this.log.warn(`No existing configuration found for URL: ${fullUrl}`);
539
+ // eslint-disable-next-line no-continue
540
+ continue;
541
+ }
493
542
 
494
- // Remove the removedCount property before uploading
495
- const { removedCount, ...configToUpload } = updatedConfig;
543
+ // Extract suggestion IDs to remove for this URL
544
+ const suggestionIdsToRemove = urlSuggestions.map((s) => s.getId());
496
545
 
497
- // Upload updated config to S3
498
- this.log.info(`Uploading updated Tokowaka config after rolling back ${eligibleSuggestions.length} suggestions`);
499
- const s3Path = await this.uploadConfig(apiKey, configToUpload);
546
+ // Use mapper to remove patches
547
+ const updatedConfig = mapper.rollbackPatches(
548
+ existingConfig,
549
+ suggestionIdsToRemove,
550
+ opportunity.getId(),
551
+ );
500
552
 
501
- // Invalidate CDN cache (non-blocking, failures are logged but don't fail rollback)
502
- const cdnInvalidationResult = await this.invalidateCdnCache(
503
- apiKey,
504
- this.env.TOKOWAKA_CDN_PROVIDER,
505
- );
553
+ if (updatedConfig.removedCount === 0) {
554
+ this.log.warn(`No patches found for URL: ${fullUrl}`);
555
+ // eslint-disable-next-line no-continue
556
+ continue;
557
+ }
558
+
559
+ this.log.info(`Removed ${updatedConfig.removedCount} patches for URL: ${fullUrl}`);
560
+ totalRemovedCount += updatedConfig.removedCount;
561
+
562
+ // Remove the removedCount property before uploading
563
+ delete updatedConfig.removedCount;
564
+
565
+ // Upload updated config to S3 for this URL
566
+ // eslint-disable-next-line no-await-in-loop
567
+ const s3Path = await this.uploadConfig(fullUrl, updatedConfig);
568
+ s3Paths.push(s3Path);
569
+
570
+ // Invalidate CDN cache (non-blocking, failures are logged but don't fail rollback)
571
+ // eslint-disable-next-line no-await-in-loop
572
+ const cdnInvalidationResult = await this.invalidateCdnCache(
573
+ fullUrl,
574
+ this.env.TOKOWAKA_CDN_PROVIDER,
575
+ );
576
+ cdnInvalidations.push(cdnInvalidationResult);
577
+ }
578
+
579
+ this.log.info(`Updated Tokowaka configs for ${s3Paths.length} URLs, removed ${totalRemovedCount} patches total`);
506
580
 
507
581
  return {
508
- s3Path,
509
- cdnInvalidation: cdnInvalidationResult,
582
+ s3Paths,
583
+ cdnInvalidations,
510
584
  succeededSuggestions: eligibleSuggestions,
511
585
  failedSuggestions: ineligibleSuggestions,
512
- removedPatchesCount: removedCount,
586
+ removedPatchesCount: totalRemovedCount,
513
587
  };
514
588
  }
515
589
 
516
590
  /**
517
591
  * Previews suggestions by generating config and uploading to preview path
518
- * Unlike deploySuggestions, this does NOT merge with existing config
592
+ * All suggestions must belong to the same URL
519
593
  * @param {Object} site - Site entity
520
594
  * @param {Object} opportunity - Opportunity entity
521
- * @param {Array} suggestions - Array of suggestion entities to preview
595
+ * @param {Array} suggestions - Array of suggestion entities to preview (must be same URL)
522
596
  * @param {Object} options - Optional configuration for HTML fetching
523
597
  * @returns {Promise<Object>} - Preview result with config and succeeded/failed suggestions
524
598
  */
525
599
  async previewSuggestions(site, opportunity, suggestions, options = {}) {
526
- // Get site's Tokowaka API key
527
- const { apiKey, forwardedHost } = site.getConfig()?.getTokowakaConfig() || {};
600
+ // Get site's forwarded host for preview
601
+ const { forwardedHost, apiKey } = site.getConfig()?.getTokowakaConfig() || {};
528
602
 
529
- if (!hasText(apiKey) || !hasText(forwardedHost)) {
603
+ if (!hasText(forwardedHost) || !hasText(apiKey)) {
530
604
  throw this.#createError(
531
605
  'Site does not have a Tokowaka API key or forwarded host configured. '
532
606
  + 'Please onboard the site to Tokowaka first.',
@@ -573,19 +647,21 @@ class TokowakaClient {
573
647
  };
574
648
  }
575
649
 
576
- // Fetch existing deployed configuration from production S3
577
- this.log.debug(`Fetching existing deployed Tokowaka config for site ${site.getId()}`);
578
- const existingConfig = await this.fetchConfig(apiKey, false);
650
+ // Get the preview URL from the first suggestion
651
+ const previewUrl = eligibleSuggestions[0].getData()?.url;
652
+ if (!hasText(previewUrl)) {
653
+ throw this.#createError('Preview URL not found in suggestion data', HTTP_BAD_REQUEST);
654
+ }
655
+
656
+ // Fetch existing deployed configuration for this URL from production S3
657
+ this.log.debug(`Fetching existing deployed Tokowaka config for URL: ${previewUrl}`);
658
+ const existingConfig = await this.fetchConfig(previewUrl, false);
579
659
 
580
660
  // Generate configuration with eligible preview suggestions
581
- this.log.debug(`Generating preview Tokowaka config for site ${site.getId()}, opportunity ${opportunity.getId()}`);
582
- const newConfig = this.generateConfig(
583
- site,
584
- opportunity,
585
- eligibleSuggestions,
586
- );
661
+ this.log.debug(`Generating preview Tokowaka config for opportunity ${opportunity.getId()}`);
662
+ const newConfig = this.generateConfig(previewUrl, opportunity, eligibleSuggestions);
587
663
 
588
- if (Object.keys(newConfig.tokowakaOptimizations).length === 0) {
664
+ if (!newConfig || !newConfig.patches || newConfig.patches.length === 0) {
589
665
  this.log.warn('No eligible suggestions to preview');
590
666
  return {
591
667
  config: null,
@@ -594,50 +670,30 @@ class TokowakaClient {
594
670
  };
595
671
  }
596
672
 
597
- // Get the preview URL from the first suggestion
598
- const previewUrl = eligibleSuggestions[0].getData()?.url;
599
-
600
673
  // Merge with existing deployed config to include already-deployed patches for this URL
601
674
  let config = newConfig;
602
- if (existingConfig && previewUrl) {
603
- // Extract the URL path from the preview URL
604
- const urlPath = new URL(previewUrl).pathname;
605
-
606
- // Check if there are already deployed patches for this URL
607
- const existingUrlOptimization = existingConfig.tokowakaOptimizations?.[urlPath];
608
-
609
- if (existingUrlOptimization && existingUrlOptimization.patches?.length > 0) {
610
- this.log.info(
611
- `Found ${existingUrlOptimization.patches.length} deployed patches for ${urlPath}, `
612
- + 'merging with preview suggestions',
613
- );
614
-
615
- // Create a filtered existing config with only the URL being previewed
616
- const filteredExistingConfig = {
617
- ...existingConfig,
618
- tokowakaOptimizations: {
619
- [urlPath]: existingUrlOptimization,
620
- },
621
- };
622
-
623
- // Merge the existing deployed patches with new preview suggestions
624
- config = this.mergeConfigs(filteredExistingConfig, newConfig);
625
-
626
- this.log.debug(
627
- `Preview config now has ${config.tokowakaOptimizations[urlPath].patches.length} total patches`,
628
- );
629
- } else {
630
- this.log.info(`No deployed patches found for ${urlPath}, using only preview suggestions`);
631
- }
675
+ if (existingConfig && existingConfig.patches?.length > 0) {
676
+ this.log.info(
677
+ `Found ${existingConfig.patches.length} deployed patches, merging with preview suggestions`,
678
+ );
679
+
680
+ // Merge the existing deployed patches with new preview suggestions
681
+ config = this.mergeConfigs(existingConfig, newConfig);
682
+
683
+ this.log.debug(
684
+ `Preview config now has ${config.patches.length} total patches`,
685
+ );
686
+ } else {
687
+ this.log.info('No deployed patches found, using only preview suggestions');
632
688
  }
633
689
 
634
- // Upload to preview S3 path (replaces any existing preview config)
690
+ // Upload to preview S3 path for this URL
635
691
  this.log.info(`Uploading preview Tokowaka config with ${eligibleSuggestions.length} new suggestions`);
636
- const s3Path = await this.uploadConfig(apiKey, config, true);
692
+ const s3Path = await this.uploadConfig(previewUrl, config, true);
637
693
 
638
694
  // Invalidate CDN cache for preview path
639
695
  const cdnInvalidationResult = await this.invalidateCdnCache(
640
- apiKey,
696
+ previewUrl,
641
697
  this.env.TOKOWAKA_CDN_PROVIDER,
642
698
  true,
643
699
  );
@@ -646,36 +702,34 @@ class TokowakaClient {
646
702
  let originalHtml = null;
647
703
  let optimizedHtml = null;
648
704
 
649
- if (previewUrl) {
650
- try {
651
- // Fetch original HTML
652
- originalHtml = await fetchHtmlWithWarmup(
653
- previewUrl,
654
- apiKey,
655
- forwardedHost,
656
- tokowakaEdgeUrl,
657
- this.log,
658
- false,
659
- options,
660
- );
661
- // Then fetch optimized HTML
662
- optimizedHtml = await fetchHtmlWithWarmup(
663
- previewUrl,
664
- apiKey,
665
- forwardedHost,
666
- tokowakaEdgeUrl,
667
- this.log,
668
- true,
669
- options,
670
- );
671
- this.log.info('Successfully fetched both original and optimized HTML for preview');
672
- } catch (error) {
673
- this.log.error(`Failed to fetch HTML for preview: ${error.message}`);
674
- throw this.#createError(
675
- `Preview failed: Unable to fetch HTML - ${error.message}`,
676
- HTTP_INTERNAL_SERVER_ERROR,
677
- );
678
- }
705
+ try {
706
+ // Fetch original HTML (without preview)
707
+ originalHtml = await fetchHtmlWithWarmup(
708
+ previewUrl,
709
+ apiKey,
710
+ forwardedHost,
711
+ tokowakaEdgeUrl,
712
+ this.log,
713
+ false,
714
+ options,
715
+ );
716
+ // Then fetch optimized HTML (with preview)
717
+ optimizedHtml = await fetchHtmlWithWarmup(
718
+ previewUrl,
719
+ apiKey,
720
+ forwardedHost,
721
+ tokowakaEdgeUrl,
722
+ this.log,
723
+ true,
724
+ options,
725
+ );
726
+ this.log.info('Successfully fetched both original and optimized HTML for preview');
727
+ } catch (error) {
728
+ this.log.error(`Failed to fetch HTML for preview: ${error.message}`);
729
+ throw this.#createError(
730
+ `Preview failed: Unable to fetch HTML - ${error.message}`,
731
+ HTTP_INTERNAL_SERVER_ERROR,
732
+ );
679
733
  }
680
734
 
681
735
  return {