@seekora-ai/search-sdk 1.0.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/dist/client.js ADDED
@@ -0,0 +1,991 @@
1
+ "use strict";
2
+ /**
3
+ * Seekora SDK Client
4
+ * High-level wrapper around generated OpenAPI client
5
+ */
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.SeekoraClient = void 0;
11
+ const generated_1 = require("../generated");
12
+ const axios_1 = __importDefault(require("axios"));
13
+ const config_1 = require("./config");
14
+ const logger_1 = require("./logger");
15
+ const config_loader_1 = require("./config-loader");
16
+ const utils_1 = require("./utils");
17
+ /**
18
+ * Seekora SDK Client
19
+ *
20
+ * Provides a clean, easy-to-use interface for the Seekora Search API
21
+ */
22
+ class SeekoraClient {
23
+ constructor(config = {}) {
24
+ // Load configuration from file, env, and code (in that order)
25
+ const mergedConfig = (0, config_loader_1.loadConfig)(config);
26
+ this.storeId = mergedConfig.storeId;
27
+ this.readSecret = mergedConfig.readSecret;
28
+ this.writeSecret = mergedConfig.writeSecret;
29
+ // Initialize logger FIRST (before using it)
30
+ const logLevel = mergedConfig.logLevel || config.logLevel || (0, logger_1.getLogLevelFromEnv)();
31
+ this.logger = (0, logger_1.createLogger)({
32
+ level: logLevel,
33
+ ...config.logger,
34
+ });
35
+ // Initialize identifiers
36
+ this.userId = config.userId;
37
+ this.anonId = config.anonId || (0, utils_1.getOrCreateAnonId)();
38
+ this.sessionId = config.sessionId || (0, utils_1.getOrCreateSessionId)();
39
+ this.autoTrackSearch = config.autoTrackSearch || false;
40
+ // Log identifier initialization
41
+ this.logger.verbose('Client identifiers initialized', {
42
+ hasUserId: !!this.userId,
43
+ anonId: this.anonId.substring(0, 8) + '...', // Log partial ID for privacy
44
+ sessionId: this.sessionId.substring(0, 8) + '...',
45
+ autoTrackSearch: this.autoTrackSearch
46
+ });
47
+ this.logger.verbose('Initializing SeekoraClient', {
48
+ storeId: this.storeId,
49
+ environment: mergedConfig.environment,
50
+ logLevel,
51
+ });
52
+ // Get base URL from environment or config
53
+ const baseUrl = (0, config_1.getBaseUrl)(mergedConfig.environment, mergedConfig.baseUrl);
54
+ this.logger.verbose('Using base URL', { baseUrl });
55
+ // Create configuration with base URL
56
+ this.config = new generated_1.Configuration({
57
+ basePath: baseUrl,
58
+ baseOptions: {
59
+ timeout: mergedConfig.timeout || 30000,
60
+ headers: {
61
+ 'x-storeid': this.storeId,
62
+ 'x-storesecret': this.readSecret,
63
+ }
64
+ }
65
+ });
66
+ // Initialize API clients
67
+ this.searchApi = new generated_1.SearchApi(this.config);
68
+ this.suggestionsApi = new generated_1.QuerySuggestionsApi(this.config);
69
+ this.suggestionsConfigApi = new generated_1.SDKQuerySuggestionsConfigApi(this.config);
70
+ this.analyticsApi = new generated_1.AnalyticsEventsApi(this.config);
71
+ this.storesApi = new generated_1.SDKStoreConfigApi(this.config);
72
+ this.logger.info('SeekoraClient initialized successfully');
73
+ }
74
+ /**
75
+ * Search for documents
76
+ *
77
+ * Generates a new correlation_id for this search and returns SearchContext
78
+ * for linking subsequent events (clicks, conversions) to this search.
79
+ *
80
+ * @param query - Search query string
81
+ * @param options - Search options (pagination, filters, facets, etc.)
82
+ * @returns SearchResponse with results and SearchContext
83
+ */
84
+ async search(query, options) {
85
+ // Convert empty query to wildcard
86
+ const searchQuery = query.trim() || '*';
87
+ // Generate correlation_id for this search journey
88
+ const correlationId = (0, utils_1.generateUUID)();
89
+ this.logger.verbose('Executing search', {
90
+ originalQuery: query,
91
+ searchQuery,
92
+ correlationId,
93
+ options
94
+ });
95
+ const searchRequest = {
96
+ q: searchQuery,
97
+ per_page: options?.per_page || 10,
98
+ page: options?.page || 1,
99
+ filter: options?.filter || options?.filter_by,
100
+ facet_by: options?.facet_by,
101
+ sort: options?.sort || options?.sort_by,
102
+ analytics_tags: options?.analytics_tags,
103
+ widget_mode: options?.widget_mode || false,
104
+ // New advanced parameters
105
+ search_fields: options?.search_fields,
106
+ field_weights: options?.field_weights,
107
+ return_fields: options?.return_fields,
108
+ omit_fields: options?.omit_fields,
109
+ snippet_fields: options?.snippet_fields,
110
+ full_snippet_fields: options?.full_snippet_fields,
111
+ snippet_prefix: options?.snippet_prefix,
112
+ snippet_suffix: options?.snippet_suffix,
113
+ snippet_token_limit: options?.snippet_token_limit,
114
+ snippet_min_len: options?.snippet_min_len,
115
+ include_snippets: options?.include_snippets,
116
+ group_field: options?.group_field,
117
+ group_size: options?.group_size,
118
+ prefix_mode: options?.prefix_mode,
119
+ infix_mode: options?.infix_mode,
120
+ typo_max: options?.typo_max,
121
+ typo_min_len_1: options?.typo_min_len_1,
122
+ typo_min_len_2: options?.typo_min_len_2,
123
+ search_timeout_ms: options?.search_timeout_ms,
124
+ require_all_terms: options?.require_all_terms,
125
+ exact_match_boost: options?.exact_match_boost,
126
+ cache_results: options?.cache_results,
127
+ apply_rules: options?.apply_rules,
128
+ preset_name: options?.preset_name,
129
+ facet_search_text: options?.facet_search_text,
130
+ include_suggestions: options?.include_suggestions,
131
+ suggestions_limit: options?.suggestions_limit,
132
+ max_facet_values: options?.max_facet_values,
133
+ stopword_sets: options?.stopword_sets,
134
+ synonym_sets: options?.synonym_sets,
135
+ };
136
+ // Log search request details (verbose level for debugging)
137
+ this.logger.verbose('Search request prepared', {
138
+ filter: searchRequest.filter,
139
+ filterType: typeof searchRequest.filter,
140
+ fullRequest: searchRequest
141
+ });
142
+ try {
143
+ // Log API request start
144
+ this.logger.verbose('Sending search API request', {
145
+ endpoint: '/api/v1/search',
146
+ method: 'POST',
147
+ storeId: this.storeId
148
+ });
149
+ // Build headers with personalization support
150
+ const headers = {
151
+ 'x-storeid': this.storeId,
152
+ 'x-storesecret': this.readSecret,
153
+ };
154
+ // Add personalization headers if available
155
+ if (this.userId) {
156
+ headers['x-user-id'] = this.userId;
157
+ }
158
+ if (this.anonId) {
159
+ headers['x-anon-id'] = this.anonId;
160
+ }
161
+ if (this.sessionId) {
162
+ headers['x-session-id'] = this.sessionId;
163
+ }
164
+ const response = await this.searchApi.v1SearchPost(this.storeId, this.readSecret, searchRequest, this.userId, this.anonId, this.sessionId, {
165
+ headers
166
+ });
167
+ // Log API response received
168
+ this.logger.verbose('Search API response received', {
169
+ status: response.status,
170
+ hasData: !!response.data
171
+ });
172
+ // Extract search results
173
+ const data = response.data;
174
+ const facets = data?.data?.facets || [];
175
+ // Log API response (verbose level for detailed debugging)
176
+ this.logger.verbose('API response received', {
177
+ status: response.status,
178
+ totalResults: data?.data?.total_results || 0,
179
+ resultsCount: data?.data?.results?.length || 0,
180
+ facetsType: Array.isArray(facets) ? 'array' : typeof facets,
181
+ facetsCount: Array.isArray(facets) ? facets.length : Object.keys(facets || {}).length
182
+ });
183
+ // Convert facets array to a more accessible object format
184
+ const facetsMap = {};
185
+ if (Array.isArray(facets)) {
186
+ this.logger.verbose('Processing facets as array', { facetsCount: facets.length });
187
+ facets.forEach((facet, index) => {
188
+ // Handle different facet formats
189
+ const fieldName = facet?.field_name || facet?.fieldName || facet?.name || `facet_${index}`;
190
+ if (fieldName && (facet?.counts || facet?.values || Array.isArray(facet))) {
191
+ facetsMap[fieldName] = {
192
+ field_name: fieldName,
193
+ counts: facet.counts || facet.values || (Array.isArray(facet) ? facet : []),
194
+ stats: facet.stats || {}
195
+ };
196
+ this.logger.verbose('Processed facet', { fieldName, index, counts: facetsMap[fieldName].counts?.length || 0 });
197
+ }
198
+ });
199
+ }
200
+ else if (facets && typeof facets === 'object' && !Array.isArray(facets)) {
201
+ this.logger.verbose('Processing facets as object', { facetKeys: Object.keys(facets) });
202
+ // If facets is already an object, use it directly but ensure field_name is set
203
+ Object.keys(facets).forEach((key) => {
204
+ const facetData = facets[key];
205
+ if (facetData) {
206
+ facetsMap[key] = {
207
+ field_name: facetData.field_name || key,
208
+ counts: facetData.counts || facetData.values || [],
209
+ stats: facetData.stats || {}
210
+ };
211
+ }
212
+ });
213
+ }
214
+ this.logger.verbose('Facets processed', {
215
+ facetFields: Object.keys(facetsMap),
216
+ totalFacetFields: Object.keys(facetsMap).length
217
+ });
218
+ // Extract search_id from response (if present)
219
+ const searchId = data?.data?.search_id;
220
+ // Create search context for linking events
221
+ const context = {
222
+ correlationId,
223
+ searchId,
224
+ userId: this.userId,
225
+ anonId: this.anonId,
226
+ sessionId: this.sessionId,
227
+ };
228
+ const results = {
229
+ results: data?.data?.results || [],
230
+ totalResults: data?.data?.total_results || 0,
231
+ searchId, // Keep for backward compatibility
232
+ context, // New: search context for event linking
233
+ facets: facetsMap,
234
+ facet_counts: facetsMap, // Alias for backward compatibility
235
+ ...data
236
+ };
237
+ this.logger.info('Search completed', {
238
+ query: searchQuery,
239
+ originalQuery: query,
240
+ totalResults: results.totalResults,
241
+ searchId: results.searchId,
242
+ correlationId,
243
+ });
244
+ // Automatically track search event if enabled
245
+ if (this.autoTrackSearch) {
246
+ this.trackSearch({
247
+ query: searchQuery,
248
+ resultsCount: results.totalResults,
249
+ context,
250
+ }).catch((err) => {
251
+ // Log but don't fail the search if tracking fails
252
+ this.logger.warn('Failed to auto-track search event', { error: err.message });
253
+ });
254
+ }
255
+ return results;
256
+ }
257
+ catch (error) {
258
+ // Log detailed error information
259
+ const errorDetails = {
260
+ query: searchQuery,
261
+ originalQuery: query,
262
+ error: error.message,
263
+ };
264
+ if (error.response) {
265
+ errorDetails.status = error.response.status;
266
+ errorDetails.responseData = error.response.data;
267
+ errorDetails.requestUrl = error.config?.url;
268
+ errorDetails.requestMethod = error.config?.method;
269
+ }
270
+ else if (error.request) {
271
+ errorDetails.networkError = true;
272
+ errorDetails.requestConfig = {
273
+ url: error.config?.url,
274
+ method: error.config?.method,
275
+ };
276
+ }
277
+ this.logger.error('Search failed', errorDetails);
278
+ throw this.handleError(error, 'search');
279
+ }
280
+ }
281
+ /**
282
+ * Get query suggestions
283
+ *
284
+ * Uses POST when filtered_tabs or include_dropdown_recommendations is set (GET does not send include_dropdown_recommendations).
285
+ * With returnFullResponse: true returns full shape including extensions (trending_searches, top_searches, related_searches, popular_brands, filtered_tabs).
286
+ *
287
+ * @param query - Partial query to get suggestions for
288
+ * @param options - Optional parameters for suggestions
289
+ * @returns Suggestion hits array, or QuerySuggestionsFullResponse when returnFullResponse is true
290
+ */
291
+ async getSuggestions(query, options) {
292
+ this.logger.verbose('Getting query suggestions', { query, options });
293
+ try {
294
+ const headers = {
295
+ 'x-storeid': this.storeId,
296
+ 'x-storesecret': this.readSecret,
297
+ };
298
+ if (this.userId)
299
+ headers['x-user-id'] = this.userId;
300
+ if (this.anonId)
301
+ headers['x-anon-id'] = this.anonId;
302
+ if (this.sessionId)
303
+ headers['x-session-id'] = this.sessionId;
304
+ const usePost = (options?.filtered_tabs && options.filtered_tabs.length > 0) ||
305
+ options?.include_dropdown_recommendations === true;
306
+ const buildRequestBody = () => {
307
+ const body = {
308
+ query,
309
+ hitsPerPage: options?.hitsPerPage || 5,
310
+ page: options?.page,
311
+ include_categories: options?.include_categories,
312
+ include_facets: options?.include_facets,
313
+ max_categories: options?.max_categories,
314
+ max_facets: options?.max_facets,
315
+ min_popularity: options?.min_popularity,
316
+ time_range: options?.time_range,
317
+ disable_typo_tolerance: options?.disable_typo_tolerance,
318
+ include_dropdown_recommendations: options?.include_dropdown_recommendations,
319
+ filtered_tabs: options?.filtered_tabs,
320
+ };
321
+ if (options?.analytics_tags) {
322
+ body.analytics_tags = Array.isArray(options.analytics_tags)
323
+ ? options.analytics_tags
324
+ : [options.analytics_tags];
325
+ }
326
+ if (options?.tags_match_mode)
327
+ body.tags_match_mode = options.tags_match_mode;
328
+ return body;
329
+ };
330
+ if (usePost) {
331
+ this.logger.verbose('Using POST endpoint (filtered_tabs or include_dropdown_recommendations)', {
332
+ endpoint: '/api/v1/suggestions/queries',
333
+ query,
334
+ });
335
+ const requestBody = buildRequestBody();
336
+ const response = await this.suggestionsApi.v1SuggestionsQueriesPost(this.storeId, this.readSecret, this.userId, this.anonId, this.sessionId, requestBody, { headers });
337
+ const responseData = response.data?.data || response.data;
338
+ const suggestions = responseData?.results?.[0]?.hits || responseData?.hits || [];
339
+ const extensions = responseData?.results?.[1]?.extensions ?? responseData?.results?.[0]?.extensions;
340
+ if (options?.returnFullResponse) {
341
+ this.logger.info('Query suggestions retrieved (POST, full response)', {
342
+ query,
343
+ count: suggestions.length,
344
+ status: response.status,
345
+ });
346
+ return {
347
+ suggestions,
348
+ results: responseData?.results,
349
+ extensions: extensions ?? undefined,
350
+ raw: responseData,
351
+ };
352
+ }
353
+ this.logger.info('Query suggestions retrieved (POST)', {
354
+ query,
355
+ count: suggestions.length,
356
+ status: response.status,
357
+ });
358
+ return suggestions;
359
+ }
360
+ // GET for simple requests (no filtered_tabs, no include_dropdown_recommendations)
361
+ this.logger.verbose('Using GET endpoint', {
362
+ endpoint: '/api/v1/suggestions/queries',
363
+ query,
364
+ });
365
+ const analyticsTags = Array.isArray(options?.analytics_tags)
366
+ ? options.analytics_tags.join(',')
367
+ : options?.analytics_tags;
368
+ const response = await this.suggestionsApi.v1SuggestionsQueriesGet(this.storeId, this.readSecret, this.userId, this.anonId, this.sessionId, query, options?.hitsPerPage || 5, options?.page, analyticsTags, options?.tags_match_mode, options?.include_categories, options?.include_facets, options?.max_categories, options?.max_facets, options?.min_popularity, options?.time_range, options?.disable_typo_tolerance, { headers });
369
+ const responseData = response.data?.data || response.data;
370
+ const suggestions = responseData?.results?.[0]?.hits || responseData?.hits || [];
371
+ if (options?.returnFullResponse) {
372
+ const extensions = responseData?.results?.[1]?.extensions ?? responseData?.results?.[0]?.extensions;
373
+ this.logger.info('Query suggestions retrieved (GET, full response)', {
374
+ query,
375
+ count: suggestions.length,
376
+ status: response.status,
377
+ });
378
+ return {
379
+ suggestions,
380
+ results: responseData?.results,
381
+ extensions: extensions ?? undefined,
382
+ raw: responseData,
383
+ };
384
+ }
385
+ this.logger.info('Query suggestions retrieved (GET)', {
386
+ query,
387
+ count: suggestions.length,
388
+ status: response.status,
389
+ });
390
+ return suggestions;
391
+ }
392
+ catch (error) {
393
+ this.logger.error('Failed to get suggestions', { query, error: error.message });
394
+ throw this.handleError(error, 'getSuggestions');
395
+ }
396
+ }
397
+ /**
398
+ * Get query suggestions configuration (store-specific).
399
+ * Uses GET /api/v1/stores/{storeId}/query-suggestions/config via SDKQuerySuggestionsConfigApi.
400
+ */
401
+ async getSuggestionsConfig() {
402
+ this.logger.verbose('Getting suggestions configuration');
403
+ try {
404
+ const headers = {
405
+ 'x-storeid': this.storeId,
406
+ 'x-storesecret': this.readSecret,
407
+ };
408
+ try {
409
+ const response = await this.suggestionsConfigApi.apiV1StoresXStoreIDQuerySuggestionsConfigGet(this.storeId, this.readSecret, this.storeId, { headers });
410
+ const data = response.data?.data;
411
+ const config = data?.metadata?.config ?? data?.config ?? data;
412
+ this.logger.info('Suggestions configuration retrieved', {
413
+ configKeys: config ? Object.keys(config) : [],
414
+ status: response.status,
415
+ });
416
+ return config;
417
+ }
418
+ catch (e) {
419
+ this.logger.verbose('Store config endpoint failed, trying legacy endpoint', { message: e?.message });
420
+ const response = await this.suggestionsApi.v1SuggestionsConfigGet(this.storeId, this.readSecret, { headers });
421
+ const config = response.data?.data;
422
+ this.logger.info('Suggestions configuration retrieved (legacy)', {
423
+ configKeys: config ? Object.keys(config) : [],
424
+ status: response.status,
425
+ });
426
+ return config;
427
+ }
428
+ }
429
+ catch (error) {
430
+ this.logger.error('Failed to get suggestions config', { error: error.message });
431
+ throw this.handleError(error, 'getSuggestionsConfig');
432
+ }
433
+ }
434
+ /**
435
+ * Get store configuration
436
+ * Returns store search configuration and onboarding status
437
+ */
438
+ async getConfig() {
439
+ this.logger.verbose('Getting store configuration');
440
+ try {
441
+ this.logger.verbose('Fetching store configuration', {
442
+ endpoint: `/api/v1/stores/${this.storeId}/config`
443
+ });
444
+ // Build headers with personalization support
445
+ const headers = {
446
+ 'x-storeid': this.storeId,
447
+ 'x-storesecret': this.readSecret,
448
+ };
449
+ // Add personalization headers if available
450
+ if (this.userId) {
451
+ headers['x-user-id'] = this.userId;
452
+ }
453
+ if (this.anonId) {
454
+ headers['x-anon-id'] = this.anonId;
455
+ }
456
+ if (this.sessionId) {
457
+ headers['x-session-id'] = this.sessionId;
458
+ }
459
+ const response = await this.storesApi.apiV1StoresXStoreIDConfigGet(this.storeId, this.readSecret, this.storeId, { headers });
460
+ const config = response.data?.data;
461
+ this.logger.info('Store configuration retrieved', {
462
+ status: response.status,
463
+ hasConfig: !!config
464
+ });
465
+ return config;
466
+ }
467
+ catch (error) {
468
+ this.logger.error('Failed to get store config', { error: error.message });
469
+ throw this.handleError(error, 'getConfig');
470
+ }
471
+ }
472
+ /**
473
+ * Get store information
474
+ * Returns store metadata including name, status, and configuration details
475
+ *
476
+ * Note: This extracts information from the store config response.
477
+ * Store name may be derived from config fields if not directly available.
478
+ *
479
+ * @returns Store information object
480
+ */
481
+ async getStoreInfo() {
482
+ this.logger.verbose('Getting store information');
483
+ try {
484
+ const configResponse = await this.getConfig();
485
+ // Extract store info from config response
486
+ const storeInfo = {
487
+ storeId: this.storeId,
488
+ };
489
+ // The config response structure: { config: IndexConfig, onboarding_status: string }
490
+ if (configResponse) {
491
+ // Handle different response structures
492
+ const config = configResponse.config || configResponse;
493
+ const onboardingStatus = configResponse.onboarding_status;
494
+ if (onboardingStatus) {
495
+ storeInfo.onboardingStatus = onboardingStatus;
496
+ }
497
+ if (config) {
498
+ // Extract store name from various possible fields
499
+ storeInfo.storeName =
500
+ config.store_name ||
501
+ config.storeName ||
502
+ config.name ||
503
+ config.title ||
504
+ config.collection_name ||
505
+ `Store ${this.storeId.substring(0, 8).toUpperCase()}`;
506
+ // Extract query and facet fields
507
+ if (config.query_by) {
508
+ storeInfo.queryFields = Array.isArray(config.query_by)
509
+ ? config.query_by
510
+ : [config.query_by];
511
+ }
512
+ if (config.facet_by) {
513
+ storeInfo.facetFields = Array.isArray(config.facet_by)
514
+ ? config.facet_by
515
+ : [config.facet_by];
516
+ }
517
+ if (config.primary_text) {
518
+ storeInfo.primaryField = config.primary_text;
519
+ }
520
+ // Store the full config for reference
521
+ storeInfo.config = config;
522
+ }
523
+ }
524
+ this.logger.info('Store information retrieved', {
525
+ storeId: storeInfo.storeId,
526
+ storeName: storeInfo.storeName
527
+ });
528
+ return storeInfo;
529
+ }
530
+ catch (error) {
531
+ this.logger.error('Failed to get store info', { error: error.message });
532
+ throw this.handleError(error, 'getStoreInfo');
533
+ }
534
+ }
535
+ /**
536
+ * Update store configuration (requires write secret)
537
+ */
538
+ async updateConfig(config) {
539
+ this.logger.verbose('Updating store configuration', { configKeys: Object.keys(config) });
540
+ if (!this.writeSecret) {
541
+ this.logger.error('Write secret required but not provided', {
542
+ operation: 'updateConfig',
543
+ storeId: this.storeId
544
+ });
545
+ throw new Error('Write secret is required for updateConfig');
546
+ }
547
+ try {
548
+ this.logger.verbose('Updating store configuration', {
549
+ endpoint: `/api/v1/stores/${this.storeId}/config`,
550
+ configKeys: Object.keys(config)
551
+ });
552
+ // Build headers with write secret
553
+ const headers = {
554
+ 'x-storeid': this.storeId,
555
+ 'x-storesecret': this.readSecret,
556
+ 'x-store-write-secret': this.writeSecret,
557
+ };
558
+ const response = await this.storesApi.apiV1StoresXStoreIDConfigPut(this.storeId, this.writeSecret, this.storeId, config, { headers });
559
+ const updatedConfig = response.data?.data;
560
+ this.logger.info('Store configuration updated successfully', {
561
+ status: response.status,
562
+ hasConfig: !!updatedConfig
563
+ });
564
+ return updatedConfig;
565
+ }
566
+ catch (error) {
567
+ this.logger.error('Failed to update store config', { error: error.message });
568
+ throw this.handleError(error, 'updateConfig');
569
+ }
570
+ }
571
+ /**
572
+ * Update query suggestions configuration (requires write secret).
573
+ * Uses PUT /api/v1/stores/{xStoreID}/query-suggestions/config via SDKQuerySuggestionsConfigApi.
574
+ */
575
+ async updateQuerySuggestionsConfig(config) {
576
+ this.logger.verbose('Updating query suggestions configuration', { configKeys: Object.keys(config) });
577
+ if (!this.writeSecret) {
578
+ this.logger.error('Write secret required but not provided', {
579
+ operation: 'updateQuerySuggestionsConfig',
580
+ storeId: this.storeId,
581
+ });
582
+ throw new Error('Write secret is required for updateQuerySuggestionsConfig');
583
+ }
584
+ try {
585
+ const headers = {
586
+ 'x-storeid': this.storeId,
587
+ 'x-store-write-secret': this.writeSecret,
588
+ 'Content-Type': 'application/json',
589
+ };
590
+ try {
591
+ const response = await this.suggestionsConfigApi.apiV1StoresXStoreIDQuerySuggestionsConfigPut(this.storeId, this.writeSecret, this.storeId, config, { headers });
592
+ const data = response.data?.data;
593
+ const updatedConfig = data?.metadata?.config ?? data?.config ?? data;
594
+ this.logger.info('Query suggestions configuration updated successfully', {
595
+ status: response.status,
596
+ hasConfig: !!updatedConfig,
597
+ });
598
+ return updatedConfig;
599
+ }
600
+ catch (apiError) {
601
+ this.logger.verbose('PUT via generated API failed, using direct HTTP', { message: apiError?.message });
602
+ const axiosInstance = this.suggestionsConfigApi.axios ?? this.suggestionsApi.axios ?? axios_1.default;
603
+ const baseUrl = this.config.basePath ?? 'https://api.seekora.com/api';
604
+ const response = await axiosInstance.put(`${baseUrl}/v1/stores/${this.storeId}/query-suggestions/config`, config, { headers });
605
+ const data = response.data?.data;
606
+ const updatedConfig = data?.metadata?.config ?? data?.config ?? data;
607
+ this.logger.info('Query suggestions configuration updated successfully', {
608
+ status: response.status,
609
+ hasConfig: !!updatedConfig,
610
+ });
611
+ return updatedConfig;
612
+ }
613
+ }
614
+ catch (error) {
615
+ this.logger.error('Failed to update query suggestions config', { error: error.message });
616
+ throw this.handleError(error, 'updateQuerySuggestionsConfig');
617
+ }
618
+ }
619
+ /**
620
+ * Get configuration schema
621
+ */
622
+ async getConfigSchema() {
623
+ this.logger.verbose('Getting configuration schema');
624
+ try {
625
+ const response = await this.storesApi.apiV1StoresXStoreIDConfigSchemaGet(this.storeId, this.readSecret, this.storeId, {
626
+ headers: {
627
+ 'x-storeid': this.storeId,
628
+ 'x-storesecret': this.readSecret,
629
+ }
630
+ });
631
+ const schema = response.data?.data;
632
+ this.logger.verbose('Configuration schema retrieved', { schemaKeys: schema ? Object.keys(schema) : [] });
633
+ return schema;
634
+ }
635
+ catch (error) {
636
+ this.logger.error('Failed to get config schema', { error: error.message });
637
+ throw this.handleError(error, 'getConfigSchema');
638
+ }
639
+ }
640
+ /**
641
+ * Build event payload with identifiers
642
+ * Ensures user_id or anon_id is present, and sets correlation_id/search_id at top level
643
+ */
644
+ buildEventPayload(event, context) {
645
+ const payload = {
646
+ ...event,
647
+ // Set identifiers from context or fall back to client defaults
648
+ user_id: event.user_id || context?.userId || this.userId,
649
+ anon_id: event.anon_id || context?.anonId || this.anonId,
650
+ session_id: event.session_id || context?.sessionId || this.sessionId,
651
+ // Set correlation_id at top level if provided (per identifier spec)
652
+ correlation_id: event.correlation_id || context?.correlationId,
653
+ // Set search_id at top level (not just in metadata) if provided (per identifier spec)
654
+ search_id: event.search_id || context?.searchId,
655
+ };
656
+ // Ensure either user_id or anon_id is present
657
+ if (!payload.user_id && !payload.anon_id) {
658
+ // This should not happen as we always have anonId, but add check for safety
659
+ payload.anon_id = this.anonId;
660
+ }
661
+ // Also include search_id in metadata for backward compatibility
662
+ if (payload.search_id && !payload.metadata) {
663
+ payload.metadata = {};
664
+ }
665
+ if (payload.search_id && payload.metadata && !payload.metadata.search_id) {
666
+ payload.metadata.search_id = payload.search_id;
667
+ }
668
+ return payload;
669
+ }
670
+ /**
671
+ * Track a single analytics event
672
+ *
673
+ * @param event - Event payload (partial, will be enriched with identifiers)
674
+ * @param context - Optional search context for linking events to searches
675
+ */
676
+ async trackEvent(event, context) {
677
+ this.logger.verbose('Tracking analytics event', {
678
+ eventName: event.event_name,
679
+ hasContext: !!context
680
+ });
681
+ const payload = this.buildEventPayload(event, context);
682
+ try {
683
+ this.logger.verbose('Sending analytics event', {
684
+ endpoint: '/api/analytics/event',
685
+ eventName: event.event_name
686
+ });
687
+ // Cast to DataTypesEventPayload for API call (backend accepts extended fields)
688
+ const response = await this.analyticsApi.analyticsEventPost(this.storeId, this.readSecret, payload, // Type assertion needed as generated types may not include correlation_id/search_id yet
689
+ {
690
+ headers: {
691
+ 'x-storeid': this.storeId,
692
+ 'x-storesecret': this.readSecret,
693
+ }
694
+ });
695
+ this.logger.verbose('Analytics event tracked successfully', {
696
+ eventName: event.event_name,
697
+ status: response.status
698
+ });
699
+ }
700
+ catch (error) {
701
+ this.logger.error('Failed to track event', { eventName: event.event_name, error: error.message });
702
+ throw this.handleError(error, 'trackEvent');
703
+ }
704
+ }
705
+ /**
706
+ * Track multiple analytics events (batch)
707
+ *
708
+ * Note: Events should already have identifiers set. Consider using individual trackEvent()
709
+ * calls or buildEventPayload() to ensure identifiers are properly set.
710
+ *
711
+ * @param events - Array of event payloads (should have identifiers already set)
712
+ */
713
+ async trackEvents(events, context) {
714
+ this.logger.verbose('Tracking batch analytics events', {
715
+ count: events.length,
716
+ hasContext: !!context
717
+ });
718
+ if (events.length > 100) {
719
+ this.logger.error('Batch size exceeds maximum', { count: events.length, max: 100 });
720
+ throw new Error('Maximum 100 events per batch');
721
+ }
722
+ try {
723
+ this.logger.verbose('Sending batch analytics events', {
724
+ endpoint: '/api/analytics/batch',
725
+ eventCount: events.length
726
+ });
727
+ // Build full event payloads with identifiers
728
+ const enrichedEvents = events.map(event => this.buildEventPayload(event, context));
729
+ const batchRequest = {
730
+ events: enrichedEvents // Type assertion for extended payloads
731
+ };
732
+ const response = await this.analyticsApi.analyticsBatchPost(this.storeId, this.readSecret, batchRequest, {
733
+ headers: {
734
+ 'x-storeid': this.storeId,
735
+ 'x-storesecret': this.readSecret,
736
+ }
737
+ });
738
+ this.logger.info('Batch analytics events tracked successfully', {
739
+ count: events.length,
740
+ status: response.status
741
+ });
742
+ }
743
+ catch (error) {
744
+ this.logger.error('Failed to track batch events', { count: events.length, error: error.message });
745
+ throw this.handleError(error, 'trackEvents');
746
+ }
747
+ }
748
+ /**
749
+ * Track a search event
750
+ *
751
+ * @param params - Search tracking parameters
752
+ */
753
+ async trackSearch(params) {
754
+ this.logger.verbose('Tracking search event', {
755
+ query: params.query,
756
+ resultsCount: params.resultsCount,
757
+ hasContext: !!params.context
758
+ });
759
+ await this.trackEvent({
760
+ event_name: 'search.performed',
761
+ query: params.query,
762
+ results_count: params.resultsCount,
763
+ analytics_tags: params.analyticsTags,
764
+ metadata: params.metadata,
765
+ }, params.context);
766
+ }
767
+ /**
768
+ * Track an impression event
769
+ *
770
+ * @param params - Impression tracking parameters
771
+ */
772
+ async trackImpression(params) {
773
+ this.logger.verbose('Tracking impression event', {
774
+ itemId: params.itemId,
775
+ position: params.position,
776
+ hasContext: !!params.context
777
+ });
778
+ await this.trackEvent({
779
+ event_name: 'impression',
780
+ clicked_item_id: params.itemId,
781
+ metadata: {
782
+ position: params.position,
783
+ list_type: params.listType,
784
+ search_query: params.searchQuery,
785
+ ...params.metadata,
786
+ },
787
+ }, params.context);
788
+ }
789
+ async trackClick(itemId, position, contextOrSearchId) {
790
+ this.logger.verbose('Tracking click event', {
791
+ itemId,
792
+ position,
793
+ hasContext: !!contextOrSearchId
794
+ });
795
+ // Handle backward compatibility: if third param is string, treat as searchId
796
+ const context = typeof contextOrSearchId === 'string'
797
+ ? { correlationId: '', searchId: contextOrSearchId, anonId: this.anonId, sessionId: this.sessionId }
798
+ : contextOrSearchId;
799
+ await this.trackEvent({
800
+ event_name: 'product_click',
801
+ clicked_item_id: itemId,
802
+ metadata: {
803
+ position,
804
+ },
805
+ }, context);
806
+ }
807
+ async trackView(itemId, contextOrSearchId) {
808
+ this.logger.verbose('Tracking view event', {
809
+ itemId,
810
+ hasContext: !!contextOrSearchId
811
+ });
812
+ // Handle backward compatibility: if second param is string, treat as searchId
813
+ const context = typeof contextOrSearchId === 'string'
814
+ ? { correlationId: '', searchId: contextOrSearchId, anonId: this.anonId, sessionId: this.sessionId }
815
+ : contextOrSearchId;
816
+ await this.trackEvent({
817
+ event_name: 'view',
818
+ clicked_item_id: itemId,
819
+ }, context);
820
+ }
821
+ async trackConversion(itemId, value, currency, contextOrSearchId) {
822
+ this.logger.verbose('Tracking conversion event', {
823
+ itemId,
824
+ value,
825
+ currency,
826
+ hasContext: !!contextOrSearchId
827
+ });
828
+ // Handle backward compatibility: if fourth param is string, treat as searchId
829
+ const context = typeof contextOrSearchId === 'string'
830
+ ? { correlationId: '', searchId: contextOrSearchId, anonId: this.anonId, sessionId: this.sessionId }
831
+ : contextOrSearchId;
832
+ await this.trackEvent({
833
+ event_name: 'conversion',
834
+ clicked_item_id: itemId,
835
+ value,
836
+ currency,
837
+ }, context);
838
+ }
839
+ /**
840
+ * Track a custom event
841
+ *
842
+ * @param eventName - Custom event name
843
+ * @param payload - Additional event data
844
+ * @param context - Optional search context for linking events to searches
845
+ */
846
+ async trackCustom(eventName, payload = {}, context) {
847
+ this.logger.verbose('Tracking custom event', {
848
+ eventName,
849
+ hasContext: !!context
850
+ });
851
+ await this.trackEvent({
852
+ ...payload,
853
+ event_name: eventName,
854
+ }, context);
855
+ }
856
+ /**
857
+ * Validate an event payload before sending
858
+ * Useful for debugging and ensuring events are properly formatted
859
+ *
860
+ * @param event - Event payload to validate
861
+ * @returns Validation result with success status and any error messages
862
+ */
863
+ async validateEvent(event) {
864
+ this.logger.verbose('Validating event payload', { eventName: event.event_name });
865
+ try {
866
+ this.logger.verbose('Validating event payload', {
867
+ endpoint: '/api/analytics/validate',
868
+ eventName: event.event_name
869
+ });
870
+ const payload = this.buildEventPayload(event);
871
+ const response = await this.analyticsApi.analyticsValidatePost(this.storeId, this.readSecret, payload, {
872
+ headers: {
873
+ 'x-storeid': this.storeId,
874
+ 'x-storesecret': this.readSecret,
875
+ }
876
+ });
877
+ const result = response.data?.data || response.data;
878
+ const isValid = result?.valid !== false && response.status === 200;
879
+ this.logger.verbose('Event validation result', {
880
+ valid: isValid,
881
+ eventName: event.event_name,
882
+ message: result?.message
883
+ });
884
+ return {
885
+ valid: isValid,
886
+ message: result?.message || (isValid ? 'Event is valid' : 'Event validation failed'),
887
+ errors: result?.errors,
888
+ };
889
+ }
890
+ catch (error) {
891
+ this.logger.error('Event validation failed', { error: error.message });
892
+ return {
893
+ valid: false,
894
+ message: error.response?.data?.message || error.message || 'Validation request failed',
895
+ errors: error.response?.data?.errors || error.response?.data,
896
+ };
897
+ }
898
+ }
899
+ /**
900
+ * Get event schema
901
+ * Returns the JSON schema for event payloads, useful for understanding required/optional fields
902
+ *
903
+ * @returns Event schema with field definitions, types, and validation rules
904
+ */
905
+ async getEventSchema() {
906
+ this.logger.verbose('Getting event schema');
907
+ try {
908
+ const response = await this.analyticsApi.analyticsSchemaGet(this.storeId, this.readSecret, {
909
+ headers: {
910
+ 'x-storeid': this.storeId,
911
+ 'x-storesecret': this.readSecret,
912
+ }
913
+ });
914
+ const schema = response.data?.data || response.data;
915
+ this.logger.verbose('Event schema retrieved', { schemaKeys: schema ? Object.keys(schema) : [] });
916
+ return schema;
917
+ }
918
+ catch (error) {
919
+ this.logger.error('Failed to get event schema', { error: error.message });
920
+ throw this.handleError(error, 'getEventSchema');
921
+ }
922
+ }
923
+ /**
924
+ * Set user ID (call this when user logs in)
925
+ *
926
+ * @param userId - Authenticated user ID
927
+ */
928
+ setUserId(userId) {
929
+ this.userId = userId;
930
+ this.logger.info('User ID updated', { userId });
931
+ }
932
+ /**
933
+ * Clear user ID (call this when user logs out)
934
+ */
935
+ clearUserId() {
936
+ this.userId = undefined;
937
+ this.logger.info('User ID cleared');
938
+ }
939
+ /**
940
+ * Get current identifiers
941
+ * Useful for debugging or custom event tracking
942
+ */
943
+ getIdentifiers() {
944
+ return {
945
+ userId: this.userId,
946
+ anonId: this.anonId,
947
+ sessionId: this.sessionId,
948
+ };
949
+ }
950
+ /**
951
+ * Handle errors consistently
952
+ */
953
+ handleError(error, operation) {
954
+ if (error.response) {
955
+ const status = error.response.status;
956
+ const data = error.response.data;
957
+ const message = data?.message || `API request failed with status ${status}`;
958
+ // Log based on status code
959
+ if (status >= 500) {
960
+ this.logger.error('API server error', { operation, status, message });
961
+ }
962
+ else if (status === 401 || status === 403) {
963
+ this.logger.error('Authentication error', { operation, status, message });
964
+ }
965
+ else if (status === 429) {
966
+ this.logger.warn('Rate limit exceeded', { operation, status, message });
967
+ }
968
+ else {
969
+ this.logger.error('API request failed', { operation, status, message });
970
+ }
971
+ return new Error(`[${operation}] ${message} (${status})`);
972
+ }
973
+ else if (error.request) {
974
+ this.logger.error('Network error', { operation, error: error.message });
975
+ return new Error(`[${operation}] Network error: ${error.message}`);
976
+ }
977
+ else {
978
+ this.logger.error('Request error', { operation, error: error.message });
979
+ return new Error(`[${operation}] ${error.message}`);
980
+ }
981
+ }
982
+ /**
983
+ * Get the logger instance
984
+ */
985
+ getLogger() {
986
+ return this.logger;
987
+ }
988
+ }
989
+ exports.SeekoraClient = SeekoraClient;
990
+ // Export default
991
+ exports.default = SeekoraClient;