@dylandepass/helix-product-pipeline 2.8.2 → 2.9.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
+ # [2.9.0](https://github.com/adobe-rnd/helix-product-pipeline/compare/v2.8.3...v2.9.0) (2026-05-08)
2
+
3
+
4
+ ### Features
5
+
6
+ * add price-rules overrides ([#83](https://github.com/adobe-rnd/helix-product-pipeline/issues/83)) ([cbf9bd6](https://github.com/adobe-rnd/helix-product-pipeline/commit/cbf9bd613155176121f6148f1788d0b922192962))
7
+
8
+ ## [2.8.3](https://github.com/adobe-rnd/helix-product-pipeline/compare/v2.8.2...v2.8.3) (2026-04-15)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * add cache key headers to index and sitemap responses ([#82](https://github.com/adobe-rnd/helix-product-pipeline/issues/82)) ([bce6e51](https://github.com/adobe-rnd/helix-product-pipeline/commit/bce6e510c1fd55b1c3d7f57b91edd1111e7d8f22))
14
+
1
15
  ## [2.8.2](https://github.com/adobe-rnd/helix-product-pipeline/compare/v2.8.1...v2.8.2) (2026-03-30)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dylandepass/helix-product-pipeline",
3
- "version": "2.8.2",
3
+ "version": "2.9.0",
4
4
  "description": "Helix Product Pipeline",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@adobe/helix-html-pipeline": "6.27.9",
49
49
  "@adobe/helix-shared-utils": "3.0.2",
50
- "@dylandepass/helix-product-shared": "1.6.0",
50
+ "@dylandepass/helix-product-shared": "1.8.4",
51
51
  "dayjs": "1.11.19",
52
52
  "github-slugger": "2.0.0",
53
53
  "hast-util-from-html": "^2.0.3",
package/src/global.d.ts CHANGED
@@ -51,6 +51,8 @@ declare global {
51
51
  config: PipelineSiteConfig;
52
52
  info: PathInfo;
53
53
  content: PipelineContent;
54
+ catalogPriceRules?: SharedTypes.CatalogPriceRules;
55
+ stagePricing?: boolean;
54
56
  }
55
57
 
56
58
  export interface PipelineContent extends ImportedPipelineContent {
@@ -28,6 +28,8 @@ import fetch404 from './steps/fetch-404.js';
28
28
  import { setProductCacheHeaders } from './steps/set-cache-headers.js';
29
29
  import { getUnauthorizedBody } from './utils/http-response.js';
30
30
  import extractAuthoredMetadata from './steps/extract-authored-metadata.js';
31
+ import { fetchCatalogPriceRules } from './steps/fetch-price-rules.js';
32
+ import { applyProductPriceRule } from './steps/apply-price-rules.js';
31
33
 
32
34
  export async function productHTMLPipe(state, req) {
33
35
  const { log } = state;
@@ -101,6 +103,10 @@ export async function productHTMLPipe(state, req) {
101
103
  }
102
104
 
103
105
  transformImages(state);
106
+
107
+ await fetchCatalogPriceRules(state, req);
108
+ applyProductPriceRule(state, res);
109
+
104
110
  state.timer?.update('render');
105
111
  await html(state);
106
112
  await extractAuthoredMetadata(state);
@@ -16,8 +16,10 @@ import { validatePathInfo } from './utils/path.js';
16
16
  import initConfig from './steps/init-config.js';
17
17
  import fetchProductBusContent from './steps/fetch-productbus.js';
18
18
  import { setLastModified } from './utils/last-modified.js';
19
- import { set404CacheHeaders } from './steps/set-cache-headers.js';
19
+ import { set404CacheHeaders, setIndexCacheHeaders } from './steps/set-cache-headers.js';
20
20
  import { getIncludes, getPaginationParams } from './steps/utils.js';
21
+ import { fetchCatalogPriceRules } from './steps/fetch-price-rules.js';
22
+ import { applyCatalogPriceRules } from './steps/apply-price-rules.js';
21
23
 
22
24
  /**
23
25
  * Returns index as spreadsheet with pagination support.
@@ -147,9 +149,10 @@ export async function productIndexPipe(state, req) {
147
149
  throw new PipelineStatusError(res.status, res.error);
148
150
  }
149
151
 
150
- // TODO: set surrogate keys
151
- // const keys = await computeJSONSurrogateKeys(state);
152
- // setCachingHeaders(state, req, res, keys);
152
+ await setIndexCacheHeaders(state, req, res);
153
+
154
+ await fetchCatalogPriceRules(state, req);
155
+ applyCatalogPriceRules(state, res);
153
156
 
154
157
  setLastModified(state, res);
155
158
 
@@ -18,6 +18,8 @@ import fetchProductBusContent from './steps/fetch-productbus.js';
18
18
  import transformImages from './steps/transform-images.js';
19
19
  import { setLastModified } from './utils/last-modified.js';
20
20
  import { set404CacheHeaders, setProductCacheHeaders } from './steps/set-cache-headers.js';
21
+ import { fetchCatalogPriceRules } from './steps/fetch-price-rules.js';
22
+ import { applyProductPriceRule } from './steps/apply-price-rules.js';
21
23
 
22
24
  export async function productJSONPipe(state, req) {
23
25
  const { log, info } = state;
@@ -63,6 +65,9 @@ export async function productJSONPipe(state, req) {
63
65
 
64
66
  transformImages(state);
65
67
 
68
+ await fetchCatalogPriceRules(state, req);
69
+ applyProductPriceRule(state, res);
70
+
66
71
  // set surrogate keys
67
72
  await setProductCacheHeaders(state, req, res);
68
73
 
@@ -18,7 +18,7 @@ import { validatePathInfo } from './utils/path.js';
18
18
  import initConfig from './steps/init-config.js';
19
19
  import fetchProductBusContent from './steps/fetch-productbus.js';
20
20
  import { setLastModified } from './utils/last-modified.js';
21
- import { set404CacheHeaders } from './steps/set-cache-headers.js';
21
+ import { set404CacheHeaders, setSitemapCacheHeaders } from './steps/set-cache-headers.js';
22
22
 
23
23
  dayjs.extend(utc);
24
24
 
@@ -151,9 +151,7 @@ export async function productSitemapPipe(state, req) {
151
151
  throw new PipelineStatusError(res.status, res.error);
152
152
  }
153
153
 
154
- // TODO: set surrogate keys
155
- // const keys = await computeJSONSurrogateKeys(state);
156
- // setCachingHeaders(state, req, res, keys);
154
+ await setSitemapCacheHeaders(state, req, res);
157
155
 
158
156
  setLastModified(state, res);
159
157
 
@@ -0,0 +1,223 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /* eslint-disable no-continue */
14
+
15
+ import { recordLastModified } from '../utils/last-modified.js';
16
+
17
+ /**
18
+ * @param {{ start?: string, end?: string }} rule
19
+ * @param {number} now
20
+ * @returns {boolean}
21
+ */
22
+ function isActive(rule, now) {
23
+ if (rule.start && new Date(rule.start).getTime() > now) return false;
24
+ if (rule.end && new Date(rule.end).getTime() < now) return false;
25
+ return true;
26
+ }
27
+
28
+ /**
29
+ * Apply a catalog price rule to a product object.
30
+ * In product mode, mutates price.final. In index mode, mutates the flat price field.
31
+ * Variant rules are only applied in product mode (index entries are flat).
32
+ * In non-index mode, the product price is only written if the rule price is lower
33
+ * (a rule may be selected purely for variant benefit without improving the product price).
34
+ * @param {object} product
35
+ * @param {SharedTypes.CatalogPriceRule} rule
36
+ * @param {number} now
37
+ * @param {boolean} isIndex
38
+ */
39
+ function applyRuleToProduct(product, rule, now, isIndex = false) {
40
+ if (rule.price != null) {
41
+ if (isIndex) {
42
+ product.price = rule.price;
43
+ } else if (product.price && parseFloat(rule.price) < parseFloat(product.price.final)) {
44
+ product.price.final = rule.price;
45
+ }
46
+ }
47
+
48
+ // Variants may be an array (product JSON) or an object keyed by SKU (stored index)
49
+ /** @type {SharedTypes.ProductBusVariant[]} */
50
+ let variantList;
51
+ if (Array.isArray(product.variants)) {
52
+ variantList = product.variants;
53
+ } else if (product.variants) {
54
+ variantList = Object.values(product.variants);
55
+ } else {
56
+ variantList = [];
57
+ }
58
+
59
+ for (const variant of variantList) {
60
+ const currentVariantPrice = isIndex ? variant.price : variant.price?.final;
61
+ const variantRule = rule.variants?.[variant.sku];
62
+ if (variantRule && isActive(variantRule, now)) {
63
+ if (variantRule.price != null
64
+ && parseFloat(variantRule.price) < parseFloat(currentVariantPrice)) {
65
+ if (isIndex) {
66
+ variant.price = variantRule.price;
67
+ } else if (variant.price) {
68
+ variant.price.final = variantRule.price;
69
+ }
70
+ }
71
+ } else if (rule.price != null && parseFloat(rule.price) < parseFloat(currentVariantPrice)) {
72
+ // inherit parent product price only if lower than variant's current price
73
+ if (isIndex) {
74
+ variant.price = rule.price;
75
+ } else if (variant.price) {
76
+ variant.price.final = rule.price;
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Find the best active promotion rule for a product across all promotions.
84
+ * A rule qualifies if its price is lower than the product's current price OR if any of
85
+ * its active variant-specific prices are lower than the corresponding variant's current price.
86
+ * Among qualifying rules, the one with the lowest product-level price wins.
87
+ * @param {SharedTypes.CatalogPriceRules} catalogPriceRules
88
+ * @param {string} productPath
89
+ * @param {number} now
90
+ * @param {object} product - the product object (used to read current price and variant prices)
91
+ * @returns {SharedTypes.CatalogPriceRule | null}
92
+ */
93
+ function findBestRule(catalogPriceRules, productPath, now, product) {
94
+ const currentPrice = parseFloat(product.price?.final ?? 'Infinity');
95
+
96
+ const variantCurrentPrices = new Map();
97
+ const variantList = Array.isArray(product.variants)
98
+ ? product.variants
99
+ : Object.values(product.variants ?? {});
100
+ for (const v of variantList) {
101
+ if (v.sku && v.price?.final != null) {
102
+ variantCurrentPrices.set(v.sku, parseFloat(v.price.final));
103
+ }
104
+ }
105
+
106
+ let bestRule = null;
107
+ let bestRuleProductPrice = Infinity;
108
+
109
+ for (const promotion of catalogPriceRules.promotions) {
110
+ for (const rule of promotion.rules) {
111
+ if (rule.path !== productPath) continue;
112
+ if (!isActive(rule, now)) continue;
113
+
114
+ const p = parseFloat(rule.price);
115
+ const lowersProductPrice = !Number.isNaN(p) && p < currentPrice;
116
+ const lowersVariantPrice = Object.entries(rule.variants ?? {}).some(([sku, vr]) => {
117
+ if (!isActive(vr, now) || vr.price == null) return false;
118
+ const currentVPrice = variantCurrentPrices.get(sku);
119
+ return currentVPrice !== undefined && parseFloat(vr.price) < currentVPrice;
120
+ });
121
+
122
+ if (!lowersProductPrice && !lowersVariantPrice) continue;
123
+
124
+ const ruleProductPrice = Number.isNaN(p) ? Infinity : p;
125
+ if (!bestRule || ruleProductPrice < bestRuleProductPrice) {
126
+ bestRuleProductPrice = ruleProductPrice;
127
+ bestRule = rule;
128
+ }
129
+ }
130
+ }
131
+
132
+ return bestRule;
133
+ }
134
+
135
+ /**
136
+ * Apply catalog price rules to state.content.data (single product request).
137
+ * Finds the lowest active promotion price for the product path and applies it only
138
+ * if it is less than the product's current price. Also records the most recently
139
+ * started active rule's start time as a last-modified source.
140
+ * @param {PipelineState} state
141
+ * @param {PipelineResponse} [res]
142
+ */
143
+ export function applyProductPriceRule(state, res) {
144
+ const { catalogPriceRules, content, info } = state;
145
+ if (!catalogPriceRules?.promotions?.length || !content?.data) return;
146
+
147
+ const productPath = info.path.replace(/\.(json|html)$/, '');
148
+ const now = Date.now();
149
+ const rule = findBestRule(catalogPriceRules, productPath, now, content.data);
150
+ if (rule) applyRuleToProduct(content.data, rule, now, false);
151
+
152
+ if (res) {
153
+ let newestStartMs = 0;
154
+ for (const promotion of catalogPriceRules.promotions) {
155
+ for (const r of promotion.rules) {
156
+ if (r.path !== productPath || !isActive(r, now) || !r.start) continue;
157
+ const ms = new Date(r.start).getTime();
158
+ if (ms > newestStartMs) newestStartMs = ms;
159
+ }
160
+ }
161
+ if (newestStartMs) {
162
+ recordLastModified(state, res, 'price-rules', new Date(newestStartMs).toUTCString());
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Apply catalog price rules to the stored index (index request).
169
+ * For each product, finds the lowest active promotion price and applies it only
170
+ * if it is less than the product's current price. Also records the most recently
171
+ * started active rule's start time (across all paths in the index) as a last-modified source.
172
+ * @param {PipelineState} state
173
+ * @param {PipelineResponse} [res]
174
+ */
175
+ export function applyCatalogPriceRules(state, res) {
176
+ const { catalogPriceRules, content } = state;
177
+ if (!catalogPriceRules?.promotions?.length || !content?.data) return;
178
+
179
+ const now = Date.now();
180
+
181
+ // Build a path → best rule map and a path → newest start map across all promotions
182
+ /** @type {Map<string, SharedTypes.CatalogPriceRule>} */
183
+ const bestRuleByPath = new Map();
184
+ /** @type {Map<string, number>} */
185
+ const newestStartMsByPath = new Map();
186
+ for (const promotion of catalogPriceRules.promotions) {
187
+ for (const rule of promotion.rules) {
188
+ if (!isActive(rule, now)) continue;
189
+ const price = parseFloat(rule.price);
190
+ if (Number.isNaN(price)) continue;
191
+ const current = bestRuleByPath.get(rule.path);
192
+ if (!current || price < parseFloat(current.price)) {
193
+ bestRuleByPath.set(rule.path, rule);
194
+ }
195
+ if (rule.start) {
196
+ const startMs = new Date(rule.start).getTime();
197
+ if (startMs > (newestStartMsByPath.get(rule.path) ?? 0)) {
198
+ newestStartMsByPath.set(rule.path, startMs);
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ let newestStartMs = 0;
205
+ for (const entry of Object.values(content.data)) {
206
+ const product = entry?.data;
207
+ // eslint-disable-next-line no-continue
208
+ if (!product?.path) continue;
209
+ const rule = bestRuleByPath.get(product.path);
210
+ if (!rule) continue;
211
+ const rulePrice = parseFloat(rule.price);
212
+ const productPrice = parseFloat(product.price);
213
+ if (!Number.isNaN(productPrice) && rulePrice < productPrice) {
214
+ applyRuleToProduct(product, rule, now, true);
215
+ }
216
+ const startMs = newestStartMsByPath.get(product.path) ?? 0;
217
+ if (startMs > newestStartMs) newestStartMs = startMs;
218
+ }
219
+
220
+ if (newestStartMs && res) {
221
+ recordLastModified(state, res, 'price-rules', new Date(newestStartMs).toUTCString());
222
+ }
223
+ }
@@ -0,0 +1,80 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ const PRICING_BUCKET_ID = 'helix-commerce-pricing';
14
+ const PRICING_BUCKET_DEV_ID = 'helix-commerce-pricing-dev';
15
+
16
+ /**
17
+ * @param {string} org
18
+ * @param {string} site
19
+ * @returns {string}
20
+ */
21
+ function catalogRulesKey(org, site) {
22
+ return `${org}/${site}/prices/catalog/rules.json`;
23
+ }
24
+
25
+ /** @returns {SharedTypes.CatalogPriceRules} */
26
+ function emptyRules() {
27
+ return { promotions: [] };
28
+ }
29
+
30
+ /**
31
+ * Fetch all catalog price rules. Sets state.catalogPriceRules.
32
+ * When the request carries `x-env: stage`, fetches from the dev pricing bucket and sets
33
+ * state.stagePricing = true so cache headers can be suppressed downstream.
34
+ * Used for both single-product and index routes.
35
+ * @param {PipelineState} state
36
+ * @param {PipelineRequest} [req]
37
+ */
38
+ export async function fetchCatalogPriceRules(state, req) {
39
+ const { org, site, s3Loader } = state;
40
+ const useStagePricing = req?.headers?.get('x-env') === 'stage';
41
+ const bucketId = useStagePricing
42
+ ? PRICING_BUCKET_DEV_ID
43
+ : PRICING_BUCKET_ID;
44
+ if (useStagePricing) {
45
+ state.log.info('fetching catalog price rules from dev bucket');
46
+ state.stagePricing = true;
47
+ }
48
+
49
+ /** @type {PipelineResponse} */
50
+ let res;
51
+ try {
52
+ res = await s3Loader.getObject(bucketId, catalogRulesKey(org, site));
53
+ if (res.status !== 200) {
54
+ state.catalogPriceRules = emptyRules();
55
+ return;
56
+ }
57
+
58
+ /** @type {SharedTypes.CatalogPriceRules} */
59
+ const rules = JSON.parse(res.body);
60
+ if (!rules || !Array.isArray(rules.promotions)) {
61
+ state.catalogPriceRules = emptyRules();
62
+ return;
63
+ }
64
+
65
+ const now = Date.now();
66
+
67
+ // Pre-filter rules whose end is already past; remove promotions with no remaining rules
68
+ state.catalogPriceRules = {
69
+ promotions: rules.promotions
70
+ .map((promotion) => ({
71
+ ...promotion,
72
+ rules: promotion.rules.filter((rule) => !rule.end || new Date(rule.end).getTime() > now),
73
+ }))
74
+ .filter((promotion) => promotion.rules.length > 0),
75
+ };
76
+ } catch (e) {
77
+ state.log.warn(`failed to fetch catalog price rules: ${res?.status ?? 'unknown error'}`, e);
78
+ state.catalogPriceRules = emptyRules();
79
+ }
80
+ }
@@ -16,8 +16,11 @@ import {
16
16
  computeProductPathKey,
17
17
  computeSiteKey,
18
18
  compute404Key,
19
+ computePriceRulesKey,
19
20
  computeAuthoredContentKey,
20
21
  computeProductKeys,
22
+ computeIndexKeys,
23
+ computeSitemapKeys,
21
24
  } from '@dylandepass/helix-product-shared';
22
25
  import { applyByoCdnHeaders } from '../utils/cache-headers.js';
23
26
 
@@ -105,4 +108,35 @@ export async function setProductCacheHeaders(state, req, resp) {
105
108
 
106
109
  const productKeys = await computeProductKeys(org, site, info.path, contentBusId);
107
110
  setCachingHeaders(req, resp, productKeys);
111
+ if (state.stagePricing) resp.headers.set('cache-control', 'no-store');
112
+ }
113
+
114
+ /**
115
+ * Sets the cache headers for an index.json resource
116
+ * @param {PipelineState} state
117
+ * @param {PipelineRequest} req
118
+ * @param {PipelineResponse} resp
119
+ * @returns {Promise<void>}
120
+ */
121
+ export async function setIndexCacheHeaders(state, req, resp) {
122
+ const { org, site, info } = state;
123
+ const keys = await computeIndexKeys(org, site, info.path);
124
+ keys.push(computePriceRulesKey(org, site));
125
+ setCachingHeaders(req, resp, keys);
126
+ if (state.stagePricing) resp.headers.set('cache-control', 'no-store');
127
+ }
128
+
129
+ /**
130
+ * Sets the cache headers for a sitemap.xml resource
131
+ * @param {PipelineState} state
132
+ * @param {PipelineRequest} req
133
+ * @param {PipelineResponse} resp
134
+ * @returns {Promise<void>}
135
+ */
136
+ export async function setSitemapCacheHeaders(state, req, resp) {
137
+ const { org, site, info } = state;
138
+ const keys = await computeSitemapKeys(org, site, info.path);
139
+ keys.push(computePriceRulesKey(org, site));
140
+ setCachingHeaders(req, resp, keys);
141
+ if (state.stagePricing) resp.headers.set('cache-control', 'no-store');
108
142
  }