@shushed/helpers 0.0.248 → 0.0.249-fh-20260130152735

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.
@@ -0,0 +1,500 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ const crypto = __importStar(require("crypto"));
40
+ const fs_1 = __importDefault(require("fs"));
41
+ const child_process = __importStar(require("node:child_process"));
42
+ const stream_1 = require("stream");
43
+ const archiver_1 = __importDefault(require("archiver"));
44
+ const fredhopper_types_1 = require("./fredhopper.types");
45
+ const defaultConfig = {
46
+ username: 'hush',
47
+ password: '',
48
+ baseUrl: 'https://test.fredhopperservices.com',
49
+ environment: 'test1',
50
+ region: 'eu1',
51
+ };
52
+ class FredHelperBase {
53
+ logging;
54
+ config;
55
+ attributes;
56
+ categories;
57
+ constructor(logging, config, attributes, categories) {
58
+ this.logging = logging;
59
+ this.config = config;
60
+ this.attributes = attributes;
61
+ this.categories = categories;
62
+ this.config = { ...defaultConfig, ...config };
63
+ this.config.base64Credentials = btoa(`${this.config.username}:${this.config.password}`);
64
+ }
65
+ updateType = 'incremental';
66
+ get timestamp() {
67
+ const now = new Date();
68
+ return (now.getUTCFullYear() +
69
+ '-' +
70
+ String(now.getUTCMonth() + 1).padStart(2, '0') +
71
+ '-' +
72
+ String(now.getUTCDate()).padStart(2, '0') +
73
+ '_' +
74
+ String(now.getUTCHours()).padStart(2, '0') +
75
+ '-' +
76
+ String(now.getUTCMinutes()).padStart(2, '0') +
77
+ '-' +
78
+ String(now.getUTCSeconds()).padStart(2, '0'));
79
+ }
80
+ get authHeader() {
81
+ return FredHelperBase.authHeader(this.config);
82
+ }
83
+ get baseFilesUrl() {
84
+ return `${this.baseUrl}/fas:${this.config.environment}`;
85
+ }
86
+ get baseUrl() {
87
+ return `https://my.eu1.fredhopperservices.com`;
88
+ }
89
+ static validateCategoryId(categoryId) {
90
+ return (/^[a-z0-9_-]+$/i.test(categoryId) &&
91
+ categoryId?.length > 0 &&
92
+ categoryId?.length <= 50);
93
+ }
94
+ static validateAttributeId(attributeId) {
95
+ return (/^[a-z0-9_-]+$/i.test(attributeId) &&
96
+ attributeId?.length > 0 &&
97
+ attributeId?.length <= 50);
98
+ }
99
+ static validateProductId(productId) {
100
+ return (/^[a-z0-9_]+$/.test(productId) &&
101
+ productId?.length > 0 &&
102
+ productId?.length <= 100);
103
+ }
104
+ static authHeader(config) {
105
+ return `Basic ${btoa(`${config.username}:${config.password}`)}`;
106
+ }
107
+ async getCategories() {
108
+ const categories = this.categories;
109
+ for (const category of categories) {
110
+ if (!category.locales || category.locales?.length === 0) {
111
+ category.locales = [];
112
+ }
113
+ }
114
+ return categories;
115
+ }
116
+ async getAttributesMeta() {
117
+ return this.attributes;
118
+ }
119
+ async validateProducts(products) {
120
+ const attributeMap = new Map(this.attributes.map(attr => [attr.attribute_id, attr]));
121
+ for (const product of products) {
122
+ if (!FredHelperBase.validateProductId(product.product_id)) {
123
+ throw new fredhopper_types_1.FredhopperValidationError(`Invalid product ID: ${product.product_id}`);
124
+ }
125
+ if (product.operation_type &&
126
+ !['add', 'update', 'replace', 'delete'].includes(product.operation_type)) {
127
+ throw new fredhopper_types_1.FredhopperValidationError(`Invalid operation type: ${product.operation_type}`);
128
+ }
129
+ for (const [attributeId, value] of Object.entries(product.attributes)) {
130
+ if (!FredHelperBase.validateAttributeId(attributeId)) {
131
+ throw new fredhopper_types_1.FredhopperValidationError(`Invalid attribute ID: ${attributeId} in product ${product.product_id}`);
132
+ }
133
+ const attributeMeta = attributeMap.get(attributeId);
134
+ if (!attributeMeta) {
135
+ throw new fredhopper_types_1.FredhopperValidationError(`Unknown attribute: ${attributeId} in product ${product.product_id}`);
136
+ }
137
+ if (attributeMeta.type === 'float' &&
138
+ typeof value !== 'number' &&
139
+ !Number.isFinite(Number(value))) {
140
+ throw new fredhopper_types_1.FredhopperValidationError(`Attribute ${attributeId} in product ${product.product_id} must be a number`);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ async uploadProductUpdate(products) {
146
+ await this.validateProducts(products);
147
+ const { blob, checksum } = await this.createProductUpdateZip(products);
148
+ return this.uploadToFredhopper(blob, checksum);
149
+ }
150
+ async uploadToFredhopper(zipData, checksum) {
151
+ const timestamp = this.timestamp;
152
+ const uploadUrl = `${this.baseFilesUrl}/data/input/${timestamp}/${this.uploadFileName}?checksum=${checksum}`;
153
+ const response = await fetch(uploadUrl, {
154
+ method: 'PUT',
155
+ headers: {
156
+ 'Content-Type': 'application/zip',
157
+ Authorization: this.authHeader,
158
+ },
159
+ body: zipData,
160
+ });
161
+ if (!response.ok) {
162
+ throw new Error(`Failed to upload data: ${response.status} ${response.statusText}`);
163
+ }
164
+ await this.uploadChecksumFile(checksum, timestamp);
165
+ const responseText = await response.text();
166
+ const dataIdMatch = responseText.match(/data-id=([^\s]+)/);
167
+ if (!dataIdMatch) {
168
+ throw new Error('Could not extract data-id from response');
169
+ }
170
+ return dataIdMatch[1];
171
+ }
172
+ async uploadChecksumFile(checksum, timestamp) {
173
+ const uploadUrl = `${this.baseFilesUrl}/data/input/${timestamp}/${this.uploadFileName}.md5`;
174
+ const response = await fetch(uploadUrl, {
175
+ method: 'PUT',
176
+ headers: {
177
+ 'Content-Type': 'text/plain',
178
+ Authorization: `Basic ${btoa(`${this.config.username}:${this.config.password}`)}`,
179
+ },
180
+ body: checksum,
181
+ });
182
+ if (!response.ok) {
183
+ throw new Error(`Failed to upload checksum: ${response.status} ${response.statusText}`);
184
+ }
185
+ }
186
+ async triggerIncrementalUpdate(dataId) {
187
+ const triggerUrl = `${this.baseFilesUrl}/trigger/load-data`;
188
+ const response = await fetch(triggerUrl, {
189
+ method: 'PUT',
190
+ headers: {
191
+ 'Content-Type': 'text/plain',
192
+ Authorization: `Basic ${btoa(`${this.config.username}:${this.config.password}`)}`,
193
+ },
194
+ body: `data-id=${dataId}`,
195
+ });
196
+ if (!response.ok) {
197
+ throw new Error(`Failed to trigger update: ${response.status} ${response.statusText}`);
198
+ }
199
+ const location = response.headers.get('Location');
200
+ if (!location) {
201
+ throw new Error('Could not get trigger location from response');
202
+ }
203
+ const triggerIdMatch = location.match(/\/([^/]+)$/);
204
+ if (!triggerIdMatch) {
205
+ throw new Error('Could not extract trigger ID from location');
206
+ }
207
+ return triggerIdMatch[1];
208
+ }
209
+ async checkUpdateStatus(triggerId) {
210
+ const statusUrl = `${this.baseFilesUrl}/trigger/load-data/${triggerId}/status`;
211
+ const response = await fetch(statusUrl, {
212
+ method: 'GET',
213
+ headers: {
214
+ Authorization: `Basic ${btoa(`${this.config.username}:${this.config.password}`)}`,
215
+ },
216
+ });
217
+ if (!response.ok) {
218
+ return 'UNKNOWN';
219
+ }
220
+ const status = (await response.text()).trim();
221
+ return status;
222
+ }
223
+ async getUpdateLogs(triggerId) {
224
+ const logsUrl = `${this.baseFilesUrl}/trigger/load-data/${triggerId}/logs/kitchen-incremental.log`;
225
+ const response = await fetch(logsUrl, {
226
+ method: 'GET',
227
+ headers: {
228
+ Authorization: `Basic ${btoa(`${this.config.username}:${this.config.password}`)}`,
229
+ },
230
+ });
231
+ const t = await response.text();
232
+ return t.trim();
233
+ }
234
+ async waitForUpdateCompletion(triggerId, pollInterval = 15000, timeout = 1200000) {
235
+ const startTime = Date.now();
236
+ let status = 'UNKNOWN';
237
+ while (!status.includes('SUCCESS') &&
238
+ !status.includes('FAILURE') &&
239
+ Date.now() - startTime < timeout) {
240
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
241
+ const newStatus = await this.checkUpdateStatus(triggerId);
242
+ if (newStatus !== status) {
243
+ status = newStatus;
244
+ this.logging.log(`status: ${status}\n`);
245
+ }
246
+ else {
247
+ this.logging.log(`...polling again in ${pollInterval / 1000}s`);
248
+ }
249
+ }
250
+ if (status === 'UNKNOWN' || status.includes('RUNNING')) {
251
+ throw new Error('Update timed out');
252
+ }
253
+ return status;
254
+ }
255
+ async triggerUpdate(dataId) {
256
+ const triggerId = await this.triggerIncrementalUpdate(dataId);
257
+ this.logging.log('Update triggered successfully', { triggerId });
258
+ const status = await this.checkUpdateStatus(triggerId);
259
+ this.logging.log('Initial status check', { status });
260
+ return { dataId, triggerId, status };
261
+ }
262
+ generateCategoriesCSV(categories) {
263
+ const lines = [
264
+ this.createCSVLine('category_id', 'parent_category_id', 'locale', 'name'),
265
+ ];
266
+ const normalizeLocale = (value) => {
267
+ if (!value)
268
+ return '';
269
+ const [lang, region] = value.split('-');
270
+ if (!lang || !region)
271
+ return value;
272
+ return `${lang.toLowerCase()}_${region.toUpperCase()}`;
273
+ };
274
+ lines.push(this.createCSVLine('catalog01', 'catalog01', 'en_GB', 'Hush Master Catalog'), this.createCSVLine('catalog01', 'catalog01', 'en_US', 'Hush Master Catalog'));
275
+ for (const category of categories) {
276
+ for (const locale of (category.locales ?? []).filter(l => l.name)) {
277
+ const localeFormatted = normalizeLocale(locale.locale);
278
+ const nameTrimmed = (locale.name || '').trim();
279
+ const parentCategoryId = category.parent_id && category.parent_id.length > 0
280
+ ? category.parent_id
281
+ : 'catalog01';
282
+ lines.push(this.createCSVLine(category.category_id, parentCategoryId, localeFormatted, nameTrimmed));
283
+ }
284
+ }
285
+ return lines.join('\n') + '\n';
286
+ }
287
+ generateAttributesMetaCSV(attributes) {
288
+ const lines = [
289
+ this.createCSVLine('attribute_id', 'type', 'locale', 'name'),
290
+ ];
291
+ for (const attribute of attributes) {
292
+ if (!attribute.locales || attribute.locales?.length <= 0) {
293
+ lines.push(this.createCSVLine(attribute.attribute_id, attribute.type, '', ''));
294
+ }
295
+ else {
296
+ for (const locale of attribute.locales ?? []) {
297
+ lines.push(this.createCSVLine(attribute.attribute_id, attribute.type, locale.locale, locale.name));
298
+ }
299
+ }
300
+ }
301
+ return lines.join('\n') + '\n';
302
+ }
303
+ generateProductsCSV(products) {
304
+ const lines = [this.createCSVLine('product_id', 'locale', 'category_ids')];
305
+ for (const product of products) {
306
+ const categories = (product.categories || []).join(' ');
307
+ lines.push(this.createCSVLine(product.product_id, 'en_GB', categories));
308
+ }
309
+ return lines.join('\n') + '\n';
310
+ }
311
+ generateVariantsCSV(inputs) {
312
+ const lines = [this.createCSVLine('variant_id', 'product_id', 'locale')];
313
+ inputs.forEach(input => input.variants.forEach(variant => {
314
+ lines.push(this.createCSVLine(variant.id, input.productId, 'en_GB'));
315
+ }));
316
+ return lines.join('\n') + '\n';
317
+ }
318
+ generateCustomVariantAttributesCSV(input) {
319
+ const lines = [
320
+ this.createCSVLine('variant_id', 'locale', 'attribute_id', 'attribute_value_id', 'attribute_value'),
321
+ ];
322
+ input.forEach(variants => {
323
+ for (const variant of variants) {
324
+ lines.push(this.createCSVLine(variant.id, 'en_GB', 'operation_type', '', 'update'));
325
+ const { id, ...rest } = variant;
326
+ Object.entries(rest).forEach(([key, value]) => {
327
+ const valueId = this.attributes.find(a => a.attribute_id === key)?.type === 'list'
328
+ || this.attributes.find(a => a.attribute_id === key)?.type === 'set' ?
329
+ (Array.isArray(value) ? value.map(v => `_${v}`).join(' | ') : `_${value}`) : '';
330
+ lines.push(this.createCSVLine(variant.id, 'en_GB', key, valueId, Array.isArray(value) ? value.join(' | ') : String(value)));
331
+ });
332
+ }
333
+ });
334
+ return lines.join('\n') + '\n';
335
+ }
336
+ generateProductAttributesCSV(products) {
337
+ const lines = [
338
+ this.createCSVLine('product_id', 'locale', 'attribute_id', 'attribute_value_id', 'attribute_value'),
339
+ ];
340
+ for (const product of products) {
341
+ if (product.operation_type) {
342
+ lines.push(this.createCSVLine(product.product_id, 'en_GB', 'operation_type', ' ', product.operation_type));
343
+ }
344
+ for (const [attributeId, value] of Object.entries(product.attributes)) {
345
+ if (attributeId === 'variants') {
346
+ continue;
347
+ }
348
+ if (attributeId !== 'operation_type') {
349
+ if (!value) {
350
+ continue;
351
+ }
352
+ const valueId = this.attributes.find(a => a.attribute_id === attributeId)?.type === 'list'
353
+ || this.attributes.find(a => a.attribute_id === attributeId)?.type === 'set' ?
354
+ (Array.isArray(value) ? value.map(v => `_${v}`).join(' | ') : `_${value}`) : '';
355
+ const valueStr = Array.isArray(value)
356
+ ? value.join(' | ')
357
+ : String(value);
358
+ if (valueStr) {
359
+ lines.push(this.createCSVLine(product.product_id, 'en_GB', attributeId, valueId, valueStr));
360
+ }
361
+ }
362
+ }
363
+ }
364
+ return lines.join('\n') + '\n';
365
+ }
366
+ async createProductUpdateZip(products) {
367
+ const files = await this.createProductZipContent(products);
368
+ return this.createZip(files);
369
+ }
370
+ async getProductCategories(productId) {
371
+ const product = await this.getProduct(productId);
372
+ if (!product?.attribute) {
373
+ return ['catalog01'];
374
+ }
375
+ const categoryContainer = product.attribute.find((a) => a?.name === 'categories');
376
+ return categoryContainer.value
377
+ .map((c) => c['non-ml'])
378
+ .map((c) => c.split('_').pop());
379
+ }
380
+ async createZip(files) {
381
+ return new Promise((resolve, reject) => {
382
+ const archive = (0, archiver_1.default)('zip', { zlib: { level: 9 } });
383
+ const passthrough = new stream_1.PassThrough();
384
+ const chunks = [];
385
+ const fileStream = fs_1.default.createWriteStream('./' + this.uploadFileName);
386
+ const hash = crypto.createHash('md5');
387
+ passthrough.on('data', chunk => {
388
+ chunks.push(chunk);
389
+ hash.update(chunk);
390
+ });
391
+ passthrough.on('end', async () => {
392
+ const buffer = Buffer.concat(chunks);
393
+ const blob = new Blob([buffer], { type: 'application/zip' });
394
+ const checksum = hash.digest('hex');
395
+ resolve({ blob, checksum });
396
+ });
397
+ archive.on('error', (err) => reject(err));
398
+ fileStream.on('error', err => reject(err));
399
+ archive.pipe(passthrough);
400
+ archive.pipe(fileStream);
401
+ for (const [filename, content] of Object.entries(files)) {
402
+ archive.append(content, { name: filename });
403
+ }
404
+ archive.finalize();
405
+ });
406
+ }
407
+ async calculateChecksum(filename) {
408
+ return new Promise((resolve, reject) => {
409
+ child_process.exec(`md5sum ${filename} | cut -d ' ' -f1`, (error, stdout) => {
410
+ if (error)
411
+ reject(error);
412
+ resolve(stdout.trim().replace(/\n/g, ''));
413
+ });
414
+ });
415
+ }
416
+ createCSVLine(...parts) {
417
+ return parts
418
+ .reduce((acc, next) => (acc ? `${acc}\t\t${next}` : next), '')
419
+ .toString();
420
+ }
421
+ async getProduct(id) {
422
+ const items = await this.queryProduct({ fh_secondid: id });
423
+ return items?.[0];
424
+ }
425
+ async queryProduct(query) {
426
+ const queryString = Object.entries(query)
427
+ .map(([key, value]) => `&${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
428
+ .join('');
429
+ const url = `https://query.prepublished.${this.config.environment}.fas.eu1.fredhopperservices.com/fredhopper/query?fh_displayfields_mode=all${queryString}`;
430
+ const res = await fetch(url, {
431
+ headers: {
432
+ Authorization: `Basic ${this.config.base64Credentials}`,
433
+ Accept: 'application/json'
434
+ }
435
+ });
436
+ if (!res.ok) {
437
+ throw new Error(`Failed to fetch product: ${res.status} ${res.statusText}`);
438
+ }
439
+ try {
440
+ const json = await res.json();
441
+ return json.universes.universe['1']['items-section']?.items.item;
442
+ }
443
+ catch (error) {
444
+ throw new Error(`Failed to parse product JSON: ${error}`);
445
+ }
446
+ }
447
+ mapAttributesFromExistingProduct(product) {
448
+ const keysToSkip = ['categories', 'secondid', 'search_categories'];
449
+ return product?.attribute?.reduce((acc, attr) => {
450
+ if (keysToSkip.includes(attr.name)) {
451
+ return acc;
452
+ }
453
+ if (attr.name.startsWith('analytics_') || attr.name.startsWith('_')) {
454
+ return acc;
455
+ }
456
+ if (!this.attributes.find(a => a.attribute_id === attr.name)) {
457
+ return acc;
458
+ }
459
+ const value = attr.value[0]?.['non-ml'];
460
+ if (value) {
461
+ acc[attr.name] = attr.basetype === 'datetime' ? new Date(+value).toISOString() : value;
462
+ }
463
+ return acc;
464
+ }, {}) || {};
465
+ }
466
+ async updateProducts(products) {
467
+ this.logging.log('Starting incremental product update');
468
+ try {
469
+ const dataId = await this.uploadProductUpdate(products);
470
+ this.logging.log('Data uploaded successfully', { dataId });
471
+ this.logging.log(`https://my.eu1.fredhopperservices.com/hush/input/fas/${this.config.environment}/${dataId}/${this.uploadFileName}`);
472
+ return this.triggerUpdate(dataId);
473
+ }
474
+ catch (error) {
475
+ this.logging.error('Failed to update products', {
476
+ error: error?.message,
477
+ });
478
+ throw error;
479
+ }
480
+ }
481
+ async createProductZipContent(products) {
482
+ const categories = await this.getCategories();
483
+ const attributes = await this.getAttributesMeta();
484
+ const files = {
485
+ 'categories.csv': this.generateCategoriesCSV(categories),
486
+ 'products.csv': this.generateProductsCSV(products),
487
+ 'custom_attributes_meta.csv': this.generateAttributesMetaCSV(attributes),
488
+ 'custom_attributes_values.csv': this.generateProductAttributesCSV(products)
489
+ };
490
+ if (products.some(p => p.variants && p.variants.length > 0)) {
491
+ files['variants.csv'] = this.generateVariantsCSV(products.map(p => ({ variants: p.variants || [], productId: p.product_id })));
492
+ files['custom_variant_attributes.csv'] = this.generateCustomVariantAttributesCSV(products.map(p => p.variants || []));
493
+ }
494
+ return files;
495
+ }
496
+ get uploadFileName() {
497
+ return this.updateType === 'full' ? 'data.zip' : 'data-incremental.zip';
498
+ }
499
+ }
500
+ exports.default = FredHelperBase;
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fredhopper_base_1 = __importDefault(require("./fredhopper.base"));
7
+ class FredHelper extends fredhopper_base_1.default {
8
+ async patchAttributes(updates) {
9
+ const products = await Promise.all(updates.map(async ({ productId, attributes }) => {
10
+ const categories = await this.getProductCategories(productId);
11
+ return {
12
+ product_id: productId,
13
+ attributes,
14
+ categories,
15
+ operation_type: 'update'
16
+ };
17
+ }));
18
+ return this.updateProducts(products);
19
+ }
20
+ async updateProductCategories(updates) {
21
+ const products = await Promise.all(updates.map(async ({ productId, categories }) => {
22
+ const existingProduct = await this.getProduct(productId);
23
+ return {
24
+ product_id: productId,
25
+ attributes: this.mapAttributesFromExistingProduct(existingProduct),
26
+ categories,
27
+ operation_type: 'update'
28
+ };
29
+ }));
30
+ return this.updateProducts(products);
31
+ }
32
+ async updateProductAndVariants(items) {
33
+ const products = items.map(item => item.product);
34
+ await this.validateProducts(products);
35
+ const files = await this.createProductZipContent(products);
36
+ const variantsInput = items.map(item => ({
37
+ variants: item.variants,
38
+ productId: item.product.product_id
39
+ }));
40
+ files['variants.csv'] = this.generateVariantsCSV(variantsInput);
41
+ const allVariants = items.map(item => item.variants);
42
+ files['custom_variant_attributes.csv'] = this.generateCustomVariantAttributesCSV(allVariants);
43
+ const { blob, checksum } = await this.createZip(files);
44
+ const dataId = await this.uploadToFredhopper(blob, checksum);
45
+ this.logging.log(`https://my.eu1.fredhopperservices.com/hush/input/fas/${this.config.environment}/${dataId}/data-incremental.zip`);
46
+ return this.triggerUpdate(dataId);
47
+ }
48
+ async updateCategory(updatedCentraCategory) {
49
+ const existingCategory = this.categories.find((c) => c.category_id === updatedCentraCategory.category_code);
50
+ let updatedCategories = [];
51
+ let changeType = 'none';
52
+ if (['INACTIVE', 'DISABLED'].includes(updatedCentraCategory.status) && existingCategory) {
53
+ updatedCategories = this.categories.filter((c) => c.category_id !== existingCategory.category_id);
54
+ changeType = 'remove';
55
+ }
56
+ else if (existingCategory) {
57
+ const locales = updatedCentraCategory.translations?.map((t) => ({ locale: t.language_code, name: t.name })) ?? [];
58
+ updatedCategories = this.categories.map((c) => c.category_id === existingCategory.category_id ? {
59
+ ...existingCategory,
60
+ parent_id: updatedCentraCategory.parent_category_code ?? '',
61
+ locales
62
+ } : c);
63
+ changeType = 'update';
64
+ }
65
+ else if (updatedCentraCategory.status === 'ACTIVE') {
66
+ updatedCategories = [...this.categories, {
67
+ category_id: updatedCentraCategory.category_code,
68
+ parent_id: updatedCentraCategory.parent_category_code ?? '',
69
+ locales: updatedCentraCategory.translations?.map((t) => ({ locale: t.language_code, name: t.name })) ?? []
70
+ }];
71
+ changeType = 'create';
72
+ }
73
+ if (!updatedCategories.length) {
74
+ return `No changes to apply: a new category was added (${updatedCentraCategory.category_code}) but the status is ${updatedCentraCategory.status}`;
75
+ }
76
+ let categorySearch = 'catalog01';
77
+ if (changeType !== 'create') {
78
+ let parentCategory = this.categories.find(c => c.category_id === existingCategory?.parent_id);
79
+ while (parentCategory) {
80
+ categorySearch = `${categorySearch}_${parentCategory.category_id}`;
81
+ parentCategory = this.categories.find(c => c.category_id === parentCategory?.parent_id);
82
+ }
83
+ categorySearch = `${categorySearch}_${updatedCentraCategory.category_code}`;
84
+ }
85
+ const products = await this.queryProduct({ fh_location: `//catalog01/en_GB/categories<{${categorySearch}` });
86
+ this.categories = updatedCategories;
87
+ const { triggerId } = await this.updateProducts(products.map((p) => {
88
+ let updatedProductCategories = p.attribute.find((a) => a.name === 'categories').value.map((c) => c['non-ml'].split('_').pop());
89
+ if (changeType === 'remove') {
90
+ updatedProductCategories = updatedProductCategories.filter((c) => c !== existingCategory?.category_id);
91
+ }
92
+ if (!updatedProductCategories.length) {
93
+ updatedProductCategories = ['catalog01'];
94
+ }
95
+ return {
96
+ product_id: p.id,
97
+ attributes: this.mapAttributesFromExistingProduct(p),
98
+ categories: updatedProductCategories,
99
+ };
100
+ }));
101
+ console.log(`https://my.eu1.fredhopperservices.com/hush/track/fas/${this.config.environment}/load-data/${triggerId}/logs/kitchen-incremental.log`);
102
+ return triggerId;
103
+ }
104
+ }
105
+ exports.default = FredHelper;
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FredhopperValidationError = void 0;
4
+ class FredhopperValidationError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = 'FredhopperValidationError';
8
+ }
9
+ }
10
+ exports.FredhopperValidationError = FredhopperValidationError;
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.FredhopperValidationError = exports.FredHelper = void 0;
7
+ var fredhopper_1 = require("./fredhopper");
8
+ Object.defineProperty(exports, "FredHelper", { enumerable: true, get: function () { return __importDefault(fredhopper_1).default; } });
9
+ var fredhopper_types_1 = require("./fredhopper.types");
10
+ Object.defineProperty(exports, "FredhopperValidationError", { enumerable: true, get: function () { return fredhopper_types_1.FredhopperValidationError; } });
@@ -17,7 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
17
17
  return (mod && mod.__esModule) ? mod : { "default": mod };
18
18
  };
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.SitooHelper = exports.RedisConnectionError = exports.RateLimit = exports.setHeaders = exports.BCOrderHelper = exports.DatoHelper = exports.AirtableHelper = exports.CentraHelper = exports.BigQueryHelper = exports.JWKSHelper = exports.CloudTasksHelper = exports.Secrets = exports.SchedulerHelper = exports.Logging = exports.Runtime = exports.PubSubHelper = exports.EnvEngine = exports.validate = void 0;
20
+ exports.RedisConnectionError = exports.RateLimit = exports.setHeaders = exports.BCOrderHelper = exports.DatoHelper = exports.AirtableHelper = exports.CentraHelper = exports.BigQueryHelper = exports.JWKSHelper = exports.CloudTasksHelper = exports.Secrets = exports.SchedulerHelper = exports.Logging = exports.Runtime = exports.PubSubHelper = exports.EnvEngine = exports.validate = void 0;
21
21
  var validate_1 = require("./validate");
22
22
  Object.defineProperty(exports, "validate", { enumerable: true, get: function () { return __importDefault(validate_1).default; } });
23
23
  __exportStar(require("./sanitize"), exports);
@@ -52,5 +52,4 @@ Object.defineProperty(exports, "setHeaders", { enumerable: true, get: function (
52
52
  var rateLimit_1 = require("./rateLimit");
53
53
  Object.defineProperty(exports, "RateLimit", { enumerable: true, get: function () { return __importDefault(rateLimit_1).default; } });
54
54
  Object.defineProperty(exports, "RedisConnectionError", { enumerable: true, get: function () { return rateLimit_1.RedisConnectionError; } });
55
- var sitoo_1 = require("./sitoo");
56
- Object.defineProperty(exports, "SitooHelper", { enumerable: true, get: function () { return __importDefault(sitoo_1).default; } });
55
+ __exportStar(require("./fredhopper"), exports);
package/dist/package.json CHANGED
@@ -9,6 +9,7 @@
9
9
  "@hackylabs/deep-redact": "^2.2.1",
10
10
  "ajv": "^8.17.1",
11
11
  "ajv-formats": "^3.0.1",
12
+ "archiver": "^7.0.1",
12
13
  "co-body": "^6.2.0",
13
14
  "jose": "^6.0.11",
14
15
  "lodash.chunk": "^4.2.0",
@@ -0,0 +1,59 @@
1
+ import { ILogging } from '../runtime';
2
+ import { FredhopperAttribute, FredhopperCategory, FredhopperConfig, FredhopperProduct, UpdateResult, UpdateStatus } from './fredhopper.types';
3
+ export default abstract class FredHelperBase {
4
+ protected logging: ILogging;
5
+ protected readonly config: FredhopperConfig;
6
+ protected attributes: FredhopperAttribute[];
7
+ protected categories: FredhopperCategory[];
8
+ constructor(logging: ILogging, config: FredhopperConfig, attributes: FredhopperAttribute[], categories: FredhopperCategory[]);
9
+ updateType: 'full' | 'incremental';
10
+ protected get timestamp(): string;
11
+ protected get authHeader(): string;
12
+ protected get baseFilesUrl(): string;
13
+ protected get baseUrl(): string;
14
+ static validateCategoryId(categoryId: string): boolean;
15
+ static validateAttributeId(attributeId: string): boolean;
16
+ static validateProductId(productId: string): boolean;
17
+ static authHeader(config: FredhopperConfig): string;
18
+ getCategories(): Promise<FredhopperCategory[]>;
19
+ getAttributesMeta(): Promise<FredhopperAttribute[]>;
20
+ validateProducts(products: FredhopperProduct[]): Promise<void>;
21
+ uploadProductUpdate(products: FredhopperProduct[]): Promise<string>;
22
+ uploadToFredhopper(zipData: Blob, checksum: string): Promise<string>;
23
+ uploadChecksumFile(checksum: string, timestamp: string): Promise<void>;
24
+ triggerIncrementalUpdate(dataId: string): Promise<string>;
25
+ checkUpdateStatus(triggerId: string): Promise<UpdateStatus>;
26
+ getUpdateLogs(triggerId: string): Promise<string>;
27
+ waitForUpdateCompletion(triggerId: string, pollInterval?: number, timeout?: number): Promise<string>;
28
+ protected triggerUpdate(dataId: string): Promise<UpdateResult>;
29
+ protected generateCategoriesCSV(categories: FredhopperCategory[]): string;
30
+ protected generateAttributesMetaCSV(attributes: FredhopperAttribute[]): string;
31
+ protected generateProductsCSV(products: FredhopperProduct[]): string;
32
+ protected generateVariantsCSV(inputs: {
33
+ variants: any[];
34
+ productId: string;
35
+ }[]): string;
36
+ protected generateCustomVariantAttributesCSV(input: {
37
+ [key: string]: string | number;
38
+ }[][]): string;
39
+ protected generateProductAttributesCSV(products: FredhopperProduct[]): string;
40
+ protected createProductUpdateZip(products: FredhopperProduct[]): Promise<{
41
+ blob: Blob;
42
+ checksum: string;
43
+ }>;
44
+ getProductCategories(productId: string): Promise<any>;
45
+ protected createZip(files: Record<string, string>): Promise<{
46
+ blob: Blob;
47
+ checksum: string;
48
+ }>;
49
+ protected calculateChecksum(filename: string): Promise<string>;
50
+ protected createCSVLine(...parts: (string | number)[]): string;
51
+ getProduct(id: string): Promise<any>;
52
+ protected queryProduct(query: {
53
+ [key: string]: string;
54
+ }): Promise<any>;
55
+ protected mapAttributesFromExistingProduct(product: any): any;
56
+ protected updateProducts(products: FredhopperProduct[]): Promise<UpdateResult>;
57
+ protected createProductZipContent(products: FredhopperProduct[]): Promise<Record<string, string>>;
58
+ protected get uploadFileName(): "data.zip" | "data-incremental.zip";
59
+ }
@@ -0,0 +1,21 @@
1
+ import FredHelperBase from './fredhopper.base';
2
+ import { CentraCategory, FredhopperProduct, UpdateResult } from './fredhopper.types';
3
+ export default class FredHelper extends FredHelperBase {
4
+ patchAttributes(updates: Array<{
5
+ productId: string;
6
+ attributes: {
7
+ [attributeId: string]: any;
8
+ };
9
+ }>): Promise<UpdateResult>;
10
+ updateProductCategories(updates: Array<{
11
+ productId: string;
12
+ categories: string[];
13
+ }>): Promise<UpdateResult>;
14
+ updateProductAndVariants(items: Array<{
15
+ product: FredhopperProduct;
16
+ variants: {
17
+ [key: string]: string | number;
18
+ }[];
19
+ }>): Promise<UpdateResult>;
20
+ updateCategory(updatedCentraCategory: CentraCategory): Promise<any>;
21
+ }
@@ -0,0 +1,70 @@
1
+ export interface FredhopperCategory {
2
+ category_id: string;
3
+ parent_id: string;
4
+ locales: {
5
+ locale: string;
6
+ name: string;
7
+ }[];
8
+ }
9
+ export type UpdateStatus = 'RUNNING' | 'SUCCESS' | 'FAILURE' | 'UNKNOWN';
10
+ export type UpdateResult = {
11
+ dataId: string;
12
+ triggerId: string;
13
+ status: string;
14
+ };
15
+ export interface FredhopperAttribute {
16
+ attribute_id: string;
17
+ type: 'float' | 'set' | 'list' | 'int' | 'datetime' | 'asset' | 'text' | 'hierarchical';
18
+ locales?: {
19
+ locale: string;
20
+ name: string;
21
+ }[];
22
+ }
23
+ export interface FredhopperProduct {
24
+ product_id: string;
25
+ attributes: {
26
+ [attributeId: string]: any;
27
+ };
28
+ categories?: string[];
29
+ operation_type?: 'add' | 'update' | 'replace' | 'delete';
30
+ variants?: {
31
+ [key: string]: string | number;
32
+ }[];
33
+ }
34
+ export interface FredhopperConfig {
35
+ username: string;
36
+ password: string;
37
+ baseUrl: string;
38
+ environment: string;
39
+ region?: string;
40
+ base64Credentials?: string;
41
+ }
42
+ export declare class FredhopperValidationError extends Error {
43
+ constructor(message: string);
44
+ }
45
+ export type LocalizedName = {
46
+ name: string;
47
+ locale: string;
48
+ };
49
+ export type CategoryNode = {
50
+ name: string;
51
+ localizedNames: LocalizedName[];
52
+ children: CategoryNode[];
53
+ };
54
+ export type Catalog = {
55
+ tenant: string;
56
+ environment: string;
57
+ version: number;
58
+ name: string;
59
+ children: CategoryNode[];
60
+ localizedNames: LocalizedName[];
61
+ };
62
+ export type CentraCategory = {
63
+ category_code: string;
64
+ parent_category_code?: string;
65
+ status: string;
66
+ translations?: {
67
+ language_code: string;
68
+ name: string;
69
+ }[];
70
+ };
@@ -0,0 +1,2 @@
1
+ export { default as FredHelper } from './fredhopper';
2
+ export { FredhopperValidationError, type FredhopperCategory, type FredhopperAttribute, type FredhopperProduct, type FredhopperConfig, } from './fredhopper.types';
@@ -17,4 +17,4 @@ export { default as setHeaders } from './setHeaders';
17
17
  export { type CentraError, type CentraErrors, type BasicCentraCountry, type BasicCentraMarket, type BasicCentraSizeChart, type BasicPricelist, type BasicCentraWarehouse, type BasicCentraCampaign, type BasicCentraProduct, type BasicCentraVariant, type BasicCentraDisplay } from './centra';
18
18
  export { default as RateLimit, RedisConnectionError } from './rateLimit';
19
19
  export { type TriggerOnCreateOptions, type TriggerOnExecuteOptions, type NodeOptions, type RNConfiguration, type TriggerExtraOptions } from './types';
20
- export { default as SitooHelper, type UnitCost, type StockMovementInput, type BasicSitooWarehouse, type BasicSitooStore, type BasicWarehouseTransaction, type WarehouseTransactionEnvelope, type WarehouseTransactionSyncOptions } from './sitoo';
20
+ export * from './fredhopper';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shushed/helpers",
3
- "version": "0.0.248",
3
+ "version": "0.0.249-fh-20260130152735",
4
4
  "author": "",
5
5
  "license": "UNLICENSED",
6
6
  "description": "",
@@ -9,6 +9,7 @@
9
9
  "@hackylabs/deep-redact": "^2.2.1",
10
10
  "ajv": "^8.17.1",
11
11
  "ajv-formats": "^3.0.1",
12
+ "archiver": "^7.0.1",
12
13
  "co-body": "^6.2.0",
13
14
  "jose": "^6.0.11",
14
15
  "lodash.chunk": "^4.2.0",