@adobe/spacecat-shared-tokowaka-client 1.0.4 → 1.0.5

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-tokowaka-client-v1.0.5](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.0.4...@adobe/spacecat-shared-tokowaka-client-v1.0.5) (2025-11-21)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * edge preview feature for Tokowaka ([#1124](https://github.com/adobe/spacecat-shared/issues/1124)) ([cd2a06f](https://github.com/adobe/spacecat-shared/commit/cd2a06f62e3ca9e8c2b87e1810d420a06a002526))
7
+
1
8
  # [@adobe/spacecat-shared-tokowaka-client-v1.0.4](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.0.3...@adobe/spacecat-shared-tokowaka-client-v1.0.4) (2025-11-15)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ import { mergePatches } from './utils/patch-utils.js';
18
18
  import { getTokowakaConfigS3Path } 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
+ import { fetchHtmlWithWarmup } from './utils/custom-html-utils.js';
21
22
 
22
23
  const HTTP_BAD_REQUEST = 400;
23
24
  const HTTP_INTERNAL_SERVER_ERROR = 500;
@@ -34,7 +35,10 @@ class TokowakaClient {
34
35
  */
35
36
  static createFrom(context) {
36
37
  const { env, log = console, s3 } = context;
37
- const { TOKOWAKA_SITE_CONFIG_BUCKET: bucketName } = env;
38
+ const {
39
+ TOKOWAKA_SITE_CONFIG_BUCKET: bucketName,
40
+ TOKOWAKA_PREVIEW_BUCKET: previewBucketName,
41
+ } = env;
38
42
 
39
43
  if (context.tokowakaClient) {
40
44
  return context.tokowakaClient;
@@ -43,6 +47,7 @@ class TokowakaClient {
43
47
  // s3ClientWrapper puts s3Client at context.s3.s3Client, so check both locations
44
48
  const client = new TokowakaClient({
45
49
  bucketName,
50
+ previewBucketName,
46
51
  s3Client: s3?.s3Client,
47
52
  env,
48
53
  }, log);
@@ -54,11 +59,14 @@ class TokowakaClient {
54
59
  * Constructor
55
60
  * @param {Object} config - Configuration object
56
61
  * @param {string} config.bucketName - S3 bucket name for configs
62
+ * @param {string} config.previewBucketName - S3 bucket name for preview configs
57
63
  * @param {Object} config.s3Client - AWS S3 client
58
64
  * @param {Object} config.env - Environment variables (for CDN credentials)
59
65
  * @param {Object} log - Logger instance
60
66
  */
61
- constructor({ bucketName, s3Client, env = {} }, log) {
67
+ constructor({
68
+ bucketName, previewBucketName, s3Client, env = {},
69
+ }, log) {
62
70
  this.log = log;
63
71
 
64
72
  if (!hasText(bucketName)) {
@@ -69,7 +77,8 @@ class TokowakaClient {
69
77
  throw this.#createError('S3 client is required', HTTP_BAD_REQUEST);
70
78
  }
71
79
 
72
- this.bucketName = bucketName;
80
+ this.deployBucketName = bucketName;
81
+ this.previewBucketName = previewBucketName;
73
82
  this.s3Client = s3Client;
74
83
  this.env = env;
75
84
 
@@ -164,7 +173,7 @@ class TokowakaClient {
164
173
 
165
174
  try {
166
175
  const command = new GetObjectCommand({
167
- Bucket: this.bucketName,
176
+ Bucket: this.deployBucketName,
168
177
  Key: s3Path,
169
178
  });
170
179
 
@@ -172,12 +181,12 @@ class TokowakaClient {
172
181
  const bodyContents = await response.Body.transformToString();
173
182
  const config = JSON.parse(bodyContents);
174
183
 
175
- this.log.debug(`Successfully fetched existing Tokowaka config from s3://${this.bucketName}/${s3Path}`);
184
+ this.log.debug(`Successfully fetched existing Tokowaka config from s3://${this.deployBucketName}/${s3Path}`);
176
185
  return config;
177
186
  } catch (error) {
178
187
  // If config doesn't exist (NoSuchKey), return null
179
188
  if (error.name === 'NoSuchKey' || error.Code === 'NoSuchKey') {
180
- this.log.debug(`No existing Tokowaka config found at s3://${this.bucketName}/${s3Path}`);
189
+ this.log.debug(`No existing Tokowaka config found at s3://${this.deployBucketName}/${s3Path}`);
181
190
  return null;
182
191
  }
183
192
 
@@ -246,9 +255,10 @@ class TokowakaClient {
246
255
  * Uploads Tokowaka configuration to S3
247
256
  * @param {string} siteTokowakaKey - Tokowaka API key (used as S3 key prefix)
248
257
  * @param {Object} config - Tokowaka configuration object
258
+ * @param {boolean} isPreview - Whether to upload to preview path (default: false)
249
259
  * @returns {Promise<string>} - S3 key of uploaded config
250
260
  */
251
- async uploadConfig(siteTokowakaKey, config) {
261
+ async uploadConfig(siteTokowakaKey, config, isPreview = false) {
252
262
  if (!hasText(siteTokowakaKey)) {
253
263
  throw this.#createError('Tokowaka API key is required', HTTP_BAD_REQUEST);
254
264
  }
@@ -257,18 +267,18 @@ class TokowakaClient {
257
267
  throw this.#createError('Config object is required', HTTP_BAD_REQUEST);
258
268
  }
259
269
 
260
- const s3Path = getTokowakaConfigS3Path(siteTokowakaKey);
270
+ const s3Path = getTokowakaConfigS3Path(siteTokowakaKey, isPreview);
261
271
 
262
272
  try {
263
273
  const command = new PutObjectCommand({
264
- Bucket: this.bucketName,
274
+ Bucket: isPreview ? this.previewBucketName : this.deployBucketName,
265
275
  Key: s3Path,
266
276
  Body: JSON.stringify(config, null, 2),
267
277
  ContentType: 'application/json',
268
278
  });
269
279
 
270
280
  await this.s3Client.send(command);
271
- this.log.info(`Successfully uploaded Tokowaka config to s3://${this.bucketName}/${s3Path}`);
281
+ this.log.info(`Successfully uploaded Tokowaka config to s3://${this.deployBucketName}/${s3Path}`);
272
282
 
273
283
  return s3Path;
274
284
  } catch (error) {
@@ -282,14 +292,15 @@ class TokowakaClient {
282
292
  * Currently supports CloudFront only
283
293
  * @param {string} apiKey - Tokowaka API key
284
294
  * @param {string} provider - CDN provider name (default: 'cloudfront')
295
+ * @param {boolean} isPreview - Whether to invalidate preview path (default: false)
285
296
  * @returns {Promise<Object|null>} - CDN invalidation result or null if skipped
286
297
  */
287
- async invalidateCdnCache(apiKey, provider) {
298
+ async invalidateCdnCache(apiKey, provider, isPreview = false) {
288
299
  if (!hasText(apiKey) || !hasText(provider)) {
289
300
  throw this.#createError('Tokowaka API key and provider are required', HTTP_BAD_REQUEST);
290
301
  }
291
302
  try {
292
- const pathsToInvalidate = [`/${getTokowakaConfigS3Path(apiKey)}`];
303
+ const pathsToInvalidate = [`/${getTokowakaConfigS3Path(apiKey, isPreview)}`];
293
304
  this.log.debug(`Invalidating CDN cache for ${pathsToInvalidate.length} paths via ${provider}`);
294
305
  const cdnClient = this.cdnClientRegistry.getClient(provider);
295
306
  if (!cdnClient) {
@@ -395,6 +406,185 @@ class TokowakaClient {
395
406
  failedSuggestions: ineligibleSuggestions,
396
407
  };
397
408
  }
409
+
410
+ /**
411
+ * Previews suggestions by generating config and uploading to preview path
412
+ * Unlike deploySuggestions, this does NOT merge with existing config
413
+ * @param {Object} site - Site entity
414
+ * @param {Object} opportunity - Opportunity entity
415
+ * @param {Array} suggestions - Array of suggestion entities to preview
416
+ * @param {Object} options - Optional configuration for HTML fetching
417
+ * @returns {Promise<Object>} - Preview result with config and succeeded/failed suggestions
418
+ */
419
+ async previewSuggestions(site, opportunity, suggestions, options = {}) {
420
+ // Get site's Tokowaka API key
421
+ const { apiKey, forwardedHost } = site.getConfig()?.getTokowakaConfig() || {};
422
+
423
+ if (!hasText(apiKey) || !hasText(forwardedHost)) {
424
+ throw this.#createError(
425
+ 'Site does not have a Tokowaka API key or forwarded host configured. '
426
+ + 'Please onboard the site to Tokowaka first.',
427
+ HTTP_BAD_REQUEST,
428
+ );
429
+ }
430
+
431
+ const opportunityType = opportunity.getType();
432
+ const mapper = this.mapperRegistry.getMapper(opportunityType);
433
+ if (!mapper) {
434
+ throw this.#createError(
435
+ `No mapper found for opportunity type: ${opportunityType}. `
436
+ + `Supported types: ${this.mapperRegistry.getSupportedOpportunityTypes().join(', ')}`,
437
+ HTTP_NOT_IMPLEMENTED,
438
+ );
439
+ }
440
+
441
+ // TOKOWAKA_EDGE_URL is mandatory for preview
442
+ const tokowakaEdgeUrl = this.env.TOKOWAKA_EDGE_URL;
443
+ if (!hasText(tokowakaEdgeUrl)) {
444
+ throw this.#createError(
445
+ 'TOKOWAKA_EDGE_URL is required for preview functionality',
446
+ HTTP_INTERNAL_SERVER_ERROR,
447
+ );
448
+ }
449
+
450
+ // Validate which suggestions can be deployed using mapper's canDeploy method
451
+ const {
452
+ eligible: eligibleSuggestions,
453
+ ineligible: ineligibleSuggestions,
454
+ } = filterEligibleSuggestions(suggestions, mapper);
455
+
456
+ this.log.debug(
457
+ `Previewing ${eligibleSuggestions.length} eligible suggestions `
458
+ + `(${ineligibleSuggestions.length} ineligible)`,
459
+ );
460
+
461
+ if (eligibleSuggestions.length === 0) {
462
+ this.log.warn('No eligible suggestions to preview');
463
+ return {
464
+ config: null,
465
+ succeededSuggestions: [],
466
+ failedSuggestions: ineligibleSuggestions,
467
+ };
468
+ }
469
+
470
+ // Fetch existing deployed configuration from production S3
471
+ this.log.debug(`Fetching existing deployed Tokowaka config for site ${site.getId()}`);
472
+ const existingConfig = await this.fetchConfig(apiKey, false);
473
+
474
+ // Generate configuration with eligible preview suggestions
475
+ this.log.debug(`Generating preview Tokowaka config for site ${site.getId()}, opportunity ${opportunity.getId()}`);
476
+ const newConfig = this.generateConfig(
477
+ site,
478
+ opportunity,
479
+ eligibleSuggestions,
480
+ );
481
+
482
+ if (Object.keys(newConfig.tokowakaOptimizations).length === 0) {
483
+ this.log.warn('No eligible suggestions to preview');
484
+ return {
485
+ config: null,
486
+ succeededSuggestions: [],
487
+ failedSuggestions: suggestions,
488
+ };
489
+ }
490
+
491
+ // Get the preview URL from the first suggestion
492
+ const previewUrl = eligibleSuggestions[0].getData()?.url;
493
+
494
+ // Merge with existing deployed config to include already-deployed patches for this URL
495
+ let config = newConfig;
496
+ if (existingConfig && previewUrl) {
497
+ // Extract the URL path from the preview URL
498
+ const urlPath = new URL(previewUrl).pathname;
499
+
500
+ // Check if there are already deployed patches for this URL
501
+ const existingUrlOptimization = existingConfig.tokowakaOptimizations?.[urlPath];
502
+
503
+ if (existingUrlOptimization && existingUrlOptimization.patches?.length > 0) {
504
+ this.log.info(
505
+ `Found ${existingUrlOptimization.patches.length} deployed patches for ${urlPath}, `
506
+ + 'merging with preview suggestions',
507
+ );
508
+
509
+ // Create a filtered existing config with only the URL being previewed
510
+ const filteredExistingConfig = {
511
+ ...existingConfig,
512
+ tokowakaOptimizations: {
513
+ [urlPath]: existingUrlOptimization,
514
+ },
515
+ };
516
+
517
+ // Merge the existing deployed patches with new preview suggestions
518
+ config = this.mergeConfigs(filteredExistingConfig, newConfig);
519
+
520
+ this.log.debug(
521
+ `Preview config now has ${config.tokowakaOptimizations[urlPath].patches.length} total patches`,
522
+ );
523
+ } else {
524
+ this.log.info(`No deployed patches found for ${urlPath}, using only preview suggestions`);
525
+ }
526
+ }
527
+
528
+ // Upload to preview S3 path (replaces any existing preview config)
529
+ this.log.info(`Uploading preview Tokowaka config with ${eligibleSuggestions.length} new suggestions`);
530
+ const s3Path = await this.uploadConfig(apiKey, config, true);
531
+
532
+ // Invalidate CDN cache for preview path
533
+ const cdnInvalidationResult = await this.invalidateCdnCache(
534
+ apiKey,
535
+ this.env.TOKOWAKA_CDN_PROVIDER,
536
+ true,
537
+ );
538
+
539
+ // Fetch HTML content for preview
540
+ let originalHtml = null;
541
+ let optimizedHtml = null;
542
+
543
+ if (previewUrl) {
544
+ try {
545
+ // Fetch original HTML
546
+ originalHtml = await fetchHtmlWithWarmup(
547
+ previewUrl,
548
+ apiKey,
549
+ forwardedHost,
550
+ tokowakaEdgeUrl,
551
+ this.log,
552
+ false,
553
+ options,
554
+ );
555
+ // Then fetch optimized HTML
556
+ optimizedHtml = await fetchHtmlWithWarmup(
557
+ previewUrl,
558
+ apiKey,
559
+ forwardedHost,
560
+ tokowakaEdgeUrl,
561
+ this.log,
562
+ true,
563
+ options,
564
+ );
565
+ this.log.info('Successfully fetched both original and optimized HTML for preview');
566
+ } catch (error) {
567
+ this.log.error(`Failed to fetch HTML for preview: ${error.message}`);
568
+ throw this.#createError(
569
+ `Preview failed: Unable to fetch HTML - ${error.message}`,
570
+ HTTP_INTERNAL_SERVER_ERROR,
571
+ );
572
+ }
573
+ }
574
+
575
+ return {
576
+ s3Path,
577
+ config,
578
+ cdnInvalidation: cdnInvalidationResult,
579
+ succeededSuggestions: eligibleSuggestions,
580
+ failedSuggestions: ineligibleSuggestions,
581
+ html: {
582
+ url: previewUrl,
583
+ originalHtml,
584
+ optimizedHtml,
585
+ },
586
+ };
587
+ }
398
588
  }
399
589
 
400
590
  // Export the client as default and base classes for custom implementations
@@ -0,0 +1,194 @@
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 { hasText } from '@adobe/spacecat-shared-utils';
14
+
15
+ /**
16
+ * Helper function to wait for a specified duration
17
+ * @param {number} ms - Milliseconds to wait
18
+ * @returns {Promise<void>}
19
+ */
20
+ function sleep(ms) {
21
+ return new Promise((resolve) => {
22
+ setTimeout(resolve, ms);
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Makes an HTTP request with retry logic
28
+ * Retries until max retries are exhausted or x-tokowaka-cache header is present
29
+ * @param {string} url - URL to fetch
30
+ * @param {Object} options - Fetch options
31
+ * @param {number} maxRetries - Maximum number of retries
32
+ * @param {number} retryDelayMs - Delay between retries in milliseconds
33
+ * @param {Object} log - Logger instance
34
+ * @param {string} fetchType - Context for logging (e.g., "optimized" or "original")
35
+ * @returns {Promise<Response>} - Fetch response
36
+ */
37
+ async function fetchWithRetry(url, options, maxRetries, retryDelayMs, log, fetchType) {
38
+ for (let attempt = 1; attempt <= maxRetries + 1; attempt += 1) {
39
+ try {
40
+ log.debug(`Retry attempt ${attempt}/${maxRetries} for ${fetchType} HTML`);
41
+
42
+ // eslint-disable-next-line no-await-in-loop
43
+ const response = await fetch(url, options);
44
+
45
+ log.debug(`Response status (attempt ${attempt}): ${response.status} ${response.statusText}`);
46
+
47
+ if (!response.ok) {
48
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
49
+ }
50
+
51
+ // Check for x-tokowaka-cache header - if present, stop retrying
52
+ const cacheHeader = response.headers.get('x-tokowaka-cache');
53
+ if (cacheHeader) {
54
+ log.debug(`Cache header found (x-tokowaka-cache: ${cacheHeader}), stopping retry logic`);
55
+ return response;
56
+ }
57
+
58
+ // If no cache header and we haven't exhausted retries, continue
59
+ if (attempt < maxRetries + 1) {
60
+ log.debug(`No cache header found on attempt ${attempt}, will retry...`);
61
+ // Wait before retrying
62
+ log.debug(`Waiting ${retryDelayMs}ms before retry...`);
63
+ // eslint-disable-next-line no-await-in-loop
64
+ await sleep(retryDelayMs);
65
+ } else {
66
+ // Last attempt without cache header - throw error
67
+ log.error(`Max retries (${maxRetries}) exhausted without cache header`);
68
+ throw new Error(`Cache header (x-tokowaka-cache) not found after ${maxRetries} retries`);
69
+ }
70
+ } catch (error) {
71
+ log.warn(`Attempt ${attempt} failed for ${fetchType} HTML, error: ${error.message}`);
72
+
73
+ // If this was the last attempt, throw the error
74
+ if (attempt === maxRetries + 1) {
75
+ throw error;
76
+ }
77
+
78
+ // Wait before retrying
79
+ log.debug(`Waiting ${retryDelayMs}ms before retry...`);
80
+ // eslint-disable-next-line no-await-in-loop
81
+ await sleep(retryDelayMs);
82
+ }
83
+ }
84
+ throw new Error(`Failed to fetch ${fetchType} HTML after ${maxRetries} retries`);
85
+ }
86
+
87
+ /**
88
+ * Fetches HTML content from Tokowaka edge with warmup call and retry logic
89
+ * Makes an initial warmup call, waits, then makes the actual call with retries
90
+ * @param {string} url - Full URL to fetch
91
+ * @param {string} apiKey - Tokowaka API key
92
+ * @param {string} forwardedHost - Host to forward in x-forwarded-host header
93
+ * @param {string} tokowakaEdgeUrl - Tokowaka edge URL
94
+ * @param {boolean} isOptimized - Whether to fetch optimized HTML (with preview param)
95
+ * @param {Object} log - Logger instance
96
+ * @param {Object} options - Additional options
97
+ * @param {number} options.warmupDelayMs - Delay after warmup call (default: 2000ms)
98
+ * @param {number} options.maxRetries - Maximum number of retries for actual call (default: 2)
99
+ * @param {number} options.retryDelayMs - Delay between retries (default: 1000ms)
100
+ * @returns {Promise<string>} - HTML content
101
+ * @throws {Error} - If validation fails or fetch fails after retries
102
+ */
103
+ export async function fetchHtmlWithWarmup(
104
+ url,
105
+ apiKey,
106
+ forwardedHost,
107
+ tokowakaEdgeUrl,
108
+ log,
109
+ isOptimized = false,
110
+ options = {},
111
+ ) {
112
+ // Validate required parameters
113
+ if (!hasText(url)) {
114
+ throw new Error('URL is required for fetching HTML');
115
+ }
116
+
117
+ if (!hasText(apiKey)) {
118
+ throw new Error('Tokowaka API key is required for fetching HTML');
119
+ }
120
+
121
+ if (!hasText(forwardedHost)) {
122
+ throw new Error('Forwarded host is required for fetching HTML');
123
+ }
124
+
125
+ if (!hasText(tokowakaEdgeUrl)) {
126
+ throw new Error('TOKOWAKA_EDGE_URL is not configured');
127
+ }
128
+
129
+ // Default options
130
+ const {
131
+ warmupDelayMs = 2000,
132
+ maxRetries = 3,
133
+ retryDelayMs = 1000,
134
+ } = options;
135
+
136
+ const fetchType = isOptimized ? 'optimized' : 'original';
137
+
138
+ // Parse the URL to extract path and construct full URL
139
+ const urlObj = new URL(url);
140
+ const urlPath = urlObj.pathname + urlObj.search;
141
+
142
+ // Add tokowakaPreview param for optimized HTML
143
+ let fullUrl = `${tokowakaEdgeUrl}${urlPath}`;
144
+ if (isOptimized) {
145
+ const separator = urlPath.includes('?') ? '&' : '?';
146
+ fullUrl = `${fullUrl}${separator}tokowakaPreview=true`;
147
+ }
148
+
149
+ const headers = {
150
+ 'x-forwarded-host': forwardedHost,
151
+ 'x-tokowaka-api-key': apiKey,
152
+ 'x-tokowaka-url': urlPath,
153
+ };
154
+
155
+ const fetchOptions = {
156
+ method: 'GET',
157
+ headers,
158
+ };
159
+
160
+ try {
161
+ // Warmup call (no retry logic for warmup)
162
+ log.debug(`Making warmup call for ${fetchType} HTML with URL: ${fullUrl}`);
163
+
164
+ const warmupResponse = await fetch(fullUrl, fetchOptions);
165
+
166
+ log.debug(`Warmup response status: ${warmupResponse.status} ${warmupResponse.statusText}`);
167
+ // Consume the response body to free up the connection
168
+ await warmupResponse.text();
169
+ log.debug(`Warmup call completed, waiting ${warmupDelayMs}ms...`);
170
+
171
+ // Wait before actual call
172
+ await sleep(warmupDelayMs);
173
+
174
+ // Actual call with retry logic
175
+ log.debug(`Making actual call for ${fetchType} HTML (max ${maxRetries} retries) with URL: ${fullUrl}`);
176
+
177
+ const response = await fetchWithRetry(
178
+ fullUrl,
179
+ fetchOptions,
180
+ maxRetries,
181
+ retryDelayMs,
182
+ log,
183
+ fetchType,
184
+ );
185
+
186
+ const html = await response.text();
187
+ log.debug(`Successfully fetched ${fetchType} HTML (${html.length} bytes)`);
188
+ return html;
189
+ } catch (error) {
190
+ const errorMsg = `Failed to fetch ${fetchType} HTML after ${maxRetries} retries: ${error.message}`;
191
+ log.error(errorMsg);
192
+ throw new Error(errorMsg);
193
+ }
194
+ }
@@ -13,8 +13,12 @@
13
13
  /**
14
14
  * Generates S3 path for Tokowaka configuration
15
15
  * @param {string} apiKey - Tokowaka API key
16
+ * @param {boolean} isPreview - Whether this is a preview path
16
17
  * @returns {string} - S3 path
17
18
  */
18
- export function getTokowakaConfigS3Path(apiKey) {
19
+ export function getTokowakaConfigS3Path(apiKey, isPreview = false) {
20
+ if (isPreview) {
21
+ return `preview/opportunities/${apiKey}`;
22
+ }
19
23
  return `opportunities/${apiKey}`;
20
24
  }