@reactionary/source 0.3.1 → 0.3.3

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.
Files changed (46) hide show
  1. package/core/src/client/client-builder.ts +8 -0
  2. package/core/src/client/client.ts +4 -0
  3. package/core/src/initialization.ts +4 -1
  4. package/core/src/providers/identity.provider.ts +5 -0
  5. package/core/src/providers/index.ts +2 -0
  6. package/core/src/providers/product-associations.provider.ts +49 -0
  7. package/core/src/providers/product-recommendations.provider.ts +150 -0
  8. package/core/src/schemas/capabilities.schema.ts +2 -0
  9. package/core/src/schemas/models/identifiers.model.ts +8 -0
  10. package/core/src/schemas/models/index.ts +1 -0
  11. package/core/src/schemas/models/product-recommendations.model.ts +10 -0
  12. package/core/src/schemas/queries/index.ts +2 -0
  13. package/core/src/schemas/queries/product-associations.query.ts +23 -0
  14. package/core/src/schemas/queries/product-recommendations.query.ts +72 -0
  15. package/core/src/schemas/session.schema.ts +2 -1
  16. package/documentation/docs/7-marketing.md +95 -0
  17. package/documentation/docs/8-tracking.md +1 -1
  18. package/examples/node/package.json +6 -6
  19. package/examples/node/src/basic/client-creation.spec.ts +2 -2
  20. package/examples/node/src/capabilities/product-recommendations.spec.ts +96 -0
  21. package/examples/node/src/utils.ts +3 -0
  22. package/package.json +1 -1
  23. package/providers/algolia/README.md +12 -4
  24. package/providers/algolia/src/core/initialize.ts +8 -2
  25. package/providers/algolia/src/index.ts +1 -0
  26. package/providers/algolia/src/providers/analytics.provider.ts +9 -7
  27. package/providers/algolia/src/providers/index.ts +2 -1
  28. package/providers/algolia/src/providers/product-recommendations.provider.ts +234 -0
  29. package/providers/algolia/src/providers/product-search.provider.ts +5 -4
  30. package/providers/algolia/src/schema/capabilities.schema.ts +2 -1
  31. package/providers/algolia/src/schema/product-recommendation.schema.ts +9 -0
  32. package/providers/algolia/src/test/analytics.spec.ts +138 -0
  33. package/providers/commercetools/src/providers/identity.provider.ts +8 -1
  34. package/providers/commercetools/src/test/caching.spec.ts +3 -3
  35. package/providers/commercetools/src/test/identity.spec.ts +2 -2
  36. package/providers/google-analytics/package.json +0 -6
  37. package/providers/medusa/src/core/initialize.ts +6 -0
  38. package/providers/medusa/src/index.ts +1 -0
  39. package/providers/medusa/src/providers/identity.provider.ts +34 -10
  40. package/providers/medusa/src/providers/product-recommendations.provider.ts +117 -0
  41. package/providers/medusa/src/schema/capabilities.schema.ts +1 -0
  42. package/providers/meilisearch/src/core/initialize.ts +6 -0
  43. package/providers/meilisearch/src/index.ts +1 -0
  44. package/providers/meilisearch/src/providers/index.ts +1 -0
  45. package/providers/meilisearch/src/providers/product-recommendations.provider.ts +89 -0
  46. package/providers/meilisearch/src/schema/capabilities.schema.ts +1 -0
@@ -0,0 +1,138 @@
1
+ import { describe, it, assert } from 'vitest';
2
+ import { AlgoliaAnalyticsProvider } from '../providers/analytics.provider.js';
3
+ import { createInitialRequestContext, NoOpCache } from '@reactionary/core';
4
+ import type { AlgoliaConfiguration } from '../schema/configuration.schema.js';
5
+ import { AlgoliaProductSearchProvider } from '../providers/product-search.provider.js';
6
+
7
+ describe('Analytics event tracking', async () => {
8
+ const config = {
9
+ apiKey: process.env['ALGOLIA_API_KEY'] || '',
10
+ appId: process.env['ALGOLIA_APP_ID'] || '',
11
+ indexName: process.env['ALGOLIA_INDEX'] || '',
12
+ } satisfies AlgoliaConfiguration;
13
+ const cache = new NoOpCache();
14
+ const context = createInitialRequestContext();
15
+
16
+ const search = new AlgoliaProductSearchProvider(cache, context, config);
17
+ const analytics = new AlgoliaAnalyticsProvider(cache, context, config);
18
+ const searchResult = await search.queryByTerm({
19
+ search: {
20
+ facets: [],
21
+ filters: [],
22
+ paginationOptions: {
23
+ pageNumber: 1,
24
+ pageSize: 10,
25
+ },
26
+ term: 'q',
27
+ },
28
+ });
29
+
30
+ if (!searchResult.success) {
31
+ assert.fail();
32
+ }
33
+
34
+ it('can track summary clicks', async () => {
35
+ await analytics.track({
36
+ event: 'product-summary-click',
37
+ product: searchResult.value.items[0].identifier,
38
+ position: 1,
39
+ source: {
40
+ type: 'search',
41
+ identifier: searchResult.value.identifier,
42
+ },
43
+ });
44
+ });
45
+
46
+ it('can track summary views', async () => {
47
+ await analytics.track({
48
+ event: 'product-summary-view',
49
+ products: searchResult.value.items.map((x) => x.identifier),
50
+ source: {
51
+ type: 'search',
52
+ identifier: searchResult.value.identifier,
53
+ },
54
+ });
55
+ });
56
+
57
+ it('can track add to cart', async () => {
58
+ await analytics.track({
59
+ event: 'product-cart-add',
60
+ product: searchResult.value.items[0].identifier,
61
+ source: {
62
+ type: 'search',
63
+ identifier: searchResult.value.identifier,
64
+ },
65
+ });
66
+ });
67
+
68
+ it('can track purchase', async () => {
69
+ await analytics.track({
70
+ event: 'purchase',
71
+ order: {
72
+ identifier: {
73
+ key: crypto.randomUUID(),
74
+ },
75
+ inventoryStatus: 'Allocated',
76
+ items: [
77
+ {
78
+ identifier: {
79
+ key: crypto.randomUUID(),
80
+ },
81
+ inventoryStatus: 'Allocated',
82
+ price: {
83
+ unitPrice: {
84
+ currency: 'USD',
85
+ value: 50,
86
+ },
87
+ totalDiscount: {
88
+ currency: 'USD',
89
+ value: 0,
90
+ },
91
+ totalPrice: {
92
+ currency: 'USD',
93
+ value: 50,
94
+ },
95
+ unitDiscount: {
96
+ currency: 'USD',
97
+ value: 0,
98
+ },
99
+ },
100
+ quantity: 1,
101
+ variant: searchResult.value.items[0].variants[0].variant,
102
+ },
103
+ ],
104
+ orderStatus: 'Shipped',
105
+ paymentInstructions: [],
106
+ price: {
107
+ grandTotal: {
108
+ currency: 'USD',
109
+ value: 50,
110
+ },
111
+ totalDiscount: {
112
+ currency: 'USD',
113
+ value: 0,
114
+ },
115
+ totalProductPrice: {
116
+ currency: 'USD',
117
+ value: 50,
118
+ },
119
+ totalShipping: {
120
+ currency: 'USD',
121
+ value: 0,
122
+ },
123
+ totalSurcharge: {
124
+ currency: 'USD',
125
+ value: 0,
126
+ },
127
+ totalTax: {
128
+ currency: 'USD',
129
+ value: 0,
130
+ },
131
+ },
132
+ userId: {
133
+ userId: crypto.randomUUID()
134
+ }
135
+ },
136
+ });
137
+ });
138
+ });
@@ -15,7 +15,6 @@ import {
15
15
  success,
16
16
  } from '@reactionary/core';
17
17
  import type { CommercetoolsConfiguration } from '../schema/configuration.schema.js';
18
- import type z from 'zod';
19
18
  import type { CommercetoolsAPI } from '../core/client.js';
20
19
 
21
20
  export class CommercetoolsIdentityProvider extends IdentityProvider {
@@ -41,6 +40,8 @@ export class CommercetoolsIdentityProvider extends IdentityProvider {
41
40
  public override async getSelf(payload: IdentityQuerySelf): Promise<Result<Identity>> {
42
41
  const identity = await this.commercetools.introspect();
43
42
 
43
+ this.updateIdentityContext(identity);
44
+
44
45
  return success(identity);
45
46
  }
46
47
 
@@ -54,6 +55,8 @@ export class CommercetoolsIdentityProvider extends IdentityProvider {
54
55
  payload.password
55
56
  );
56
57
 
58
+ this.updateIdentityContext(identity);
59
+
57
60
  return success(identity);
58
61
  }
59
62
 
@@ -63,6 +66,8 @@ export class CommercetoolsIdentityProvider extends IdentityProvider {
63
66
  public override async logout(payload: Record<string, never>): Promise<Result<Identity>> {
64
67
  const identity = await this.commercetools.logout();
65
68
 
69
+ this.updateIdentityContext(identity);
70
+
66
71
  return success(identity);
67
72
  }
68
73
 
@@ -78,6 +83,8 @@ export class CommercetoolsIdentityProvider extends IdentityProvider {
78
83
  payload.password
79
84
  );
80
85
 
86
+ this.updateIdentityContext(identity);
87
+
81
88
  return success(identity);
82
89
  }
83
90
  }
@@ -8,7 +8,7 @@ import {
8
8
  type ProductSearchQueryByTerm,
9
9
  } from '@reactionary/core';
10
10
  import { CommercetoolsProductProvider } from '../providers/product.provider.js';
11
- import { CommercetoolsClient } from '../core/client.js';
11
+ import { CommercetoolsAPI } from '../core/client.js';
12
12
  import { CommercetoolsSearchProvider } from '../providers/product-search.provider.js';
13
13
 
14
14
  describe('Caching', () => {
@@ -16,7 +16,7 @@ describe('Caching', () => {
16
16
  const config = getCommercetoolsTestConfiguration();
17
17
  const context = createInitialRequestContext();
18
18
  const cache = new MemoryCache();
19
- const client = new CommercetoolsClient(config, context);
19
+ const client = new CommercetoolsAPI(config, context);
20
20
  const provider = new CommercetoolsProductProvider(config, cache, context, client);
21
21
 
22
22
  const identifier = {
@@ -48,7 +48,7 @@ describe('Caching', () => {
48
48
  const config = getCommercetoolsTestConfiguration();
49
49
  const context = createInitialRequestContext();
50
50
  const cache = new MemoryCache();
51
- const client = new CommercetoolsClient(config, context);
51
+ const client = new CommercetoolsAPI(config, context);
52
52
  const provider = new CommercetoolsSearchProvider(config, cache, context, client);
53
53
 
54
54
  const query = {
@@ -1,6 +1,6 @@
1
1
  import 'dotenv/config';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { CommercetoolsClient } from '../core/client.js';
3
+ import { CommercetoolsAPI } from '../core/client.js';
4
4
  import { getCommercetoolsTestConfiguration } from './test-utils.js';
5
5
  import {
6
6
  createInitialRequestContext,
@@ -12,7 +12,7 @@ import type { CommercetoolsConfiguration } from '../schema/configuration.schema.
12
12
  function setup() {
13
13
  const config = getCommercetoolsTestConfiguration();
14
14
  const context = createInitialRequestContext();
15
- const root = new CommercetoolsClient(config, context);
15
+ const root = new CommercetoolsAPI(config, context);
16
16
 
17
17
  return {
18
18
  config,
@@ -7,12 +7,6 @@
7
7
  "@reactionary/core": "0.0.1",
8
8
  "zod": "4.1.9"
9
9
  },
10
- "devDependencies": {
11
- "vitest": "*",
12
- "@vitest/ui": "*",
13
- "@vitest/coverage-v8": "*",
14
- "vite-tsconfig-paths": "*"
15
- },
16
10
  "type": "module",
17
11
  "sideEffects": false
18
12
  }
@@ -12,6 +12,7 @@ import { MedusaOrderSearchProvider } from "../providers/order-search.provider.js
12
12
  import { MedusaOrderProvider } from "../providers/order.provider.js";
13
13
  import { MedusaPriceProvider } from "../providers/price.provider.js";
14
14
  import { MedusaSearchProvider } from "../providers/product-search.provider.js";
15
+ import { MedusaProductRecommendationsProvider } from "../providers/product-recommendations.provider.js";
15
16
  import { MedusaProductProvider } from "../providers/product.provider.js";
16
17
  import { MedusaProfileProvider } from "../providers/profile.provider.js";
17
18
  import { MedusaCapabilitiesSchema, type MedusaCapabilities } from "../schema/capabilities.schema.js";
@@ -23,6 +24,7 @@ export function withMedusaCapabilities<T extends MedusaCapabilities>(
23
24
  capabilities: T
24
25
  ) {
25
26
  return (cache: Cache, context: RequestContext): ClientFromCapabilities<T> => {
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
28
  const client: any = {};
27
29
  const config = MedusaConfigurationSchema.parse(configuration);
28
30
  const caps = MedusaCapabilitiesSchema.parse(capabilities);
@@ -34,6 +36,10 @@ export function withMedusaCapabilities<T extends MedusaCapabilities>(
34
36
  client.productSearch = new MedusaSearchProvider(configuration, cache, context, medusaApi);
35
37
  }
36
38
 
39
+ if (caps.productRecommendations) {
40
+ client.productRecommendations = new MedusaProductRecommendationsProvider(configuration, cache, context, medusaApi);
41
+ }
42
+
37
43
  if (caps.category) {
38
44
  client.category = new MedusaCategoryProvider(configuration, cache, context, medusaApi);
39
45
  }
@@ -10,4 +10,5 @@ export * from './providers/order.provider.js';
10
10
  export * from './providers/order-search.provider.js';
11
11
  export * from './providers/price.provider.js';
12
12
  export * from './providers/product-search.provider.js';
13
+ export * from './providers/product-recommendations.provider.js';
13
14
  export * from './providers/profile.provider.js';
@@ -19,7 +19,6 @@ import {
19
19
  success,
20
20
  } from '@reactionary/core';
21
21
  import type { MedusaConfiguration } from '../schema/configuration.schema.js';
22
- import type z from 'zod';
23
22
  import type { MedusaAPI } from '../core/client.js';
24
23
  import createDebug from 'debug';
25
24
 
@@ -60,7 +59,10 @@ export class MedusaIdentityProvider extends IdentityProvider {
60
59
 
61
60
  if (!token) {
62
61
  debug('No active session token found, returning anonymous identity');
63
- return success(this.createAnonymousIdentity());
62
+ const identity = this.createAnonymousIdentity();
63
+ this.updateIdentityContext(identity);
64
+
65
+ return success(identity);
64
66
  }
65
67
 
66
68
  // Try to fetch customer details to verify authentication
@@ -68,18 +70,30 @@ export class MedusaIdentityProvider extends IdentityProvider {
68
70
 
69
71
  if (customerResponse.customer) {
70
72
  debug('Customer authenticated:', customerResponse.customer.email);
71
- return success({
73
+
74
+ const identity = {
72
75
  id: {
73
76
  userId: customerResponse.customer.id,
74
77
  },
75
78
  type: 'Registered',
76
- } satisfies RegisteredIdentity);
79
+ } satisfies RegisteredIdentity;
80
+
81
+ this.updateIdentityContext(identity);
82
+
83
+ return success(identity);
77
84
  }
78
85
 
79
- return success(this.createAnonymousIdentity());
86
+ const identity = this.createAnonymousIdentity();
87
+ this.updateIdentityContext(identity);
88
+
89
+ return success(identity);
80
90
  } catch (error) {
81
91
  debug('getSelf failed, returning anonymous identity:', error);
82
- return success(this.createAnonymousIdentity());
92
+
93
+ const identity = this.createAnonymousIdentity();
94
+ this.updateIdentityContext(identity);
95
+
96
+ return success(identity);
83
97
  }
84
98
  }
85
99
 
@@ -87,13 +101,17 @@ export class MedusaIdentityProvider extends IdentityProvider {
87
101
  inputSchema: IdentityMutationLoginSchema,
88
102
  outputSchema: IdentitySchema,
89
103
  })
90
- public override async login(payload: IdentityMutationLogin): Promise<Result<Identity>> {
104
+ public override async login(
105
+ payload: IdentityMutationLogin
106
+ ): Promise<Result<Identity>> {
91
107
  debug('Attempting login for user:', payload.username);
92
- const identity = await this.medusaApi.login(
108
+ const identity = (await this.medusaApi.login(
93
109
  payload.username,
94
110
  payload.password,
95
111
  this.context
96
- ) satisfies Identity;
112
+ )) satisfies Identity;
113
+
114
+ this.updateIdentityContext(identity);
97
115
 
98
116
  return success(identity);
99
117
  }
@@ -102,10 +120,14 @@ export class MedusaIdentityProvider extends IdentityProvider {
102
120
  inputSchema: IdentityMutationLogoutSchema,
103
121
  outputSchema: IdentitySchema,
104
122
  })
105
- public override async logout(_payload: IdentityMutationLogout): Promise<Result<Identity>> {
123
+ public override async logout(
124
+ _payload: IdentityMutationLogout
125
+ ): Promise<Result<Identity>> {
106
126
  debug('Logging out user');
107
127
  const identity = await this.medusaApi.logout(this.context);
108
128
 
129
+ this.updateIdentityContext(identity);
130
+
109
131
  return success(identity);
110
132
  }
111
133
 
@@ -132,6 +154,8 @@ export class MedusaIdentityProvider extends IdentityProvider {
132
154
  this.context
133
155
  );
134
156
 
157
+ this.updateIdentityContext(identity);
158
+
135
159
  return success(identity);
136
160
  }
137
161
  }
@@ -0,0 +1,117 @@
1
+ import {
2
+ type Cache,
3
+ ProductRecommendationsProvider,
4
+ type ProductRecommendation,
5
+ type ProductRecommendationsByCollectionQuery,
6
+ type RequestContext,
7
+ type Result,
8
+ success, error,
9
+ NotFoundErrorSchema,
10
+ type NotFoundError,
11
+ errorToString,
12
+ } from '@reactionary/core';
13
+ import createDebug from 'debug';
14
+ import type { MedusaAPI } from '../core/client.js';
15
+ import type { MedusaConfiguration } from '../schema/configuration.schema.js';
16
+
17
+ const debug = createDebug('reactionary:medusa:product-recommendations');
18
+
19
+ /**
20
+ * MedusaProductRecommendationsProvider
21
+ *
22
+ * Provides product recommendations using Medusa's collection-based product grouping.
23
+ * Only overrides the getCollection method to fetch products from Medusa collections.
24
+ *
25
+ * Note: This implementation leverages Medusa's product collections feature to provide
26
+ * curated product recommendations. Collections are typically manually managed or
27
+ * created through Medusa's admin panel or API.
28
+ */
29
+ export class MedusaProductRecommendationsProvider extends ProductRecommendationsProvider {
30
+ protected config: MedusaConfiguration;
31
+ protected medusaApi: MedusaAPI;
32
+
33
+ constructor(config: MedusaConfiguration, cache: Cache, context: RequestContext, medusaApi: MedusaAPI) {
34
+ super(cache, context);
35
+ this.config = config;
36
+ this.medusaApi = medusaApi;
37
+ }
38
+
39
+ /**
40
+ * Get product recommendations from a Medusa collection
41
+ *
42
+ * This method fetches products from a specific Medusa collection by name or handle.
43
+ * It's useful for displaying curated product lists like "Featured Products",
44
+ * "New Arrivals", "Best Sellers", etc.
45
+ */
46
+ public override async getCollection(
47
+ query: ProductRecommendationsByCollectionQuery
48
+ ): Promise<Result<ProductRecommendation[]>> {
49
+ const client = await this.medusaApi.getClient();
50
+
51
+ try {
52
+ if (debug.enabled) {
53
+ debug(`Fetching collection: ${query.collectionName}`);
54
+ }
55
+
56
+ // First, find the collection by handle/name
57
+ const collectionsResponse = await client.store.collection.list({
58
+ handle: query.collectionName,
59
+ limit: 1,
60
+ });
61
+
62
+ if (!collectionsResponse.collections || collectionsResponse.collections.length === 0) {
63
+ if (debug.enabled) {
64
+ debug(`Collection not found: ${query.collectionName}`);
65
+ }
66
+ return error<NotFoundError>({
67
+ type: 'NotFound',
68
+ identifier: query.collectionName
69
+ });
70
+ }
71
+
72
+ const collection = collectionsResponse.collections[0];
73
+
74
+ if (debug.enabled) {
75
+ debug(`Found collection: ${collection.title} (${collection.id})`);
76
+ }
77
+
78
+ // Fetch products from the collection
79
+ const productsResponse = await client.store.product.list({
80
+ collection_id: [collection.id],
81
+ limit: query.numberOfRecommendations,
82
+ fields: '+variants.id,+variants.sku',
83
+ });
84
+
85
+ if (debug.enabled) {
86
+ debug(`Found ${productsResponse.products.length} products in collection`);
87
+ }
88
+
89
+ // Map products to recommendations
90
+ const recommendations: ProductRecommendation[] = [];
91
+
92
+ for (const product of productsResponse.products) {
93
+ recommendations.push({
94
+ recommendationIdentifier: {
95
+ key: `${collection.id}_${product.id}`,
96
+ algorithm: 'collection',
97
+ },
98
+ product: {
99
+ key: product.id
100
+ },
101
+ });
102
+ }
103
+
104
+ if (debug.enabled) {
105
+ debug(`Returning ${recommendations.length} recommendations`);
106
+ }
107
+
108
+ return success(recommendations);
109
+ } catch (error) {
110
+ if (debug.enabled) {
111
+ debug(`Error fetching collection recommendations: %O`, error);
112
+ }
113
+ console.error('Error fetching collection recommendations:', error);
114
+ return success([]);
115
+ }
116
+ }
117
+ }
@@ -3,6 +3,7 @@ import type { z } from 'zod';
3
3
 
4
4
  export const MedusaCapabilitiesSchema = CapabilitiesSchema.pick({
5
5
  productSearch: true,
6
+ productRecommendations: true,
6
7
  cart: true,
7
8
  checkout: true,
8
9
  category: true,
@@ -1,17 +1,23 @@
1
1
  import type { Cache, ClientFromCapabilities, RequestContext } from "@reactionary/core";
2
2
  import { MeilisearchSearchProvider } from "../providers/product-search.provider.js";
3
+ import { MeilisearchProductRecommendationsProvider } from "../providers/product-recommendations.provider.js";
3
4
  import { MeilisearchOrderSearchProvider } from "../providers/order-search.provider.js";
4
5
  import type { MeilisearchCapabilities } from "../schema/capabilities.schema.js";
5
6
  import type { MeilisearchConfiguration } from "../schema/configuration.schema.js";
6
7
 
7
8
  export function withMeilisearchCapabilities<T extends MeilisearchCapabilities>(configuration: MeilisearchConfiguration, capabilities: T) {
8
9
  return (cache: Cache, context: RequestContext): ClientFromCapabilities<T> => {
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
11
  const client: any = {};
10
12
 
11
13
  if (capabilities.productSearch) {
12
14
  client.productSearch = new MeilisearchSearchProvider(configuration, cache, context);
13
15
  }
14
16
 
17
+ if (capabilities.productRecommendations) {
18
+ client.productRecommendations = new MeilisearchProductRecommendationsProvider(configuration, cache, context);
19
+ }
20
+
15
21
  if (capabilities.orderSearch) {
16
22
  client.orderSearch = new MeilisearchOrderSearchProvider(configuration, cache, context);
17
23
  }
@@ -1,5 +1,6 @@
1
1
  export * from './core/initialize.js';
2
2
  export * from './providers/product-search.provider.js';
3
+ export * from './providers/product-recommendations.provider.js';
3
4
  export * from './providers/order-search.provider.js';
4
5
 
5
6
  export * from './schema/configuration.schema.js';
@@ -1 +1,2 @@
1
1
  export * from './product-search.provider.js';
2
+ export * from './product-recommendations.provider.js';
@@ -0,0 +1,89 @@
1
+ import {
2
+ type Cache,
3
+ ProductRecommendationsProvider,
4
+ type ProductRecommendation,
5
+ type ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery,
6
+ type ProductRecommendationAlgorithmSimilarProductsQuery,
7
+ type ProductRecommendationAlgorithmRelatedProductsQuery,
8
+ type ProductRecommendationAlgorithmTrendingInCategoryQuery,
9
+ type RequestContext,
10
+ } from '@reactionary/core';
11
+ import { MeiliSearch, type Hits, type RecordAny, type SearchParams, type SearchResponse } from 'meilisearch';
12
+ import type { MeilisearchConfiguration } from '../schema/configuration.schema.js';
13
+
14
+ interface MeilisearchRecommendHit {
15
+ id: string;
16
+ }
17
+
18
+ /**
19
+ * MeilisearchProductRecommendationsProvider
20
+ *
21
+ * Provides product recommendations using Meilisearch's hybrid search and filtering capabilities.
22
+ * Supports frequentlyBoughtTogether, similar, related, and trendingInCategory algorithms.
23
+ *
24
+ * Note: This implementation uses semantic search (if AI embedding is enabled) and facet-based filtering.
25
+ * For production use, consider implementing more sophisticated recommendation logic or integrating
26
+ * with a dedicated recommendation engine.
27
+ */
28
+ export class MeilisearchProductRecommendationsProvider extends ProductRecommendationsProvider {
29
+ protected config: MeilisearchConfiguration;
30
+
31
+ constructor(config: MeilisearchConfiguration, cache: Cache, context: RequestContext) {
32
+ super(cache, context);
33
+ this.config = config;
34
+ }
35
+
36
+ /**
37
+ * Get similar product recommendations
38
+ * Uses semantic search to find visually or data-wise similar products
39
+ */
40
+ protected override async getSimilarProductsRecommendations(
41
+ query: ProductRecommendationAlgorithmSimilarProductsQuery
42
+ ): Promise<ProductRecommendation[]> {
43
+ const client = new MeiliSearch({
44
+ host: this.config.apiUrl,
45
+ apiKey: this.config.apiKey,
46
+ });
47
+
48
+ const index = client.index(this.config.indexName);
49
+
50
+ if (!this.config.useAIEmbedding) {
51
+ console.warn('AI embedding is not enabled in configuration. Similar product recommendations will be based on keyword matching, which may not provide optimal results.');
52
+ return [];
53
+ }
54
+
55
+ try {
56
+ const searchOptions: SearchParams = {
57
+ limit: query.numberOfRecommendations,
58
+ };
59
+
60
+ const response = await index.searchSimilarDocuments<MeilisearchRecommendHit>({
61
+ id: query.sourceProduct.key,
62
+ limit: query.numberOfRecommendations,
63
+ embedder: this.config.useAIEmbedding,
64
+ });
65
+
66
+
67
+ return this.parseRecommendations(response, 'similar');
68
+ } catch (error) {
69
+ console.error('Error fetching similar product recommendations:', error);
70
+ return [];
71
+ }
72
+ }
73
+
74
+
75
+ /**
76
+ * Maps Meilisearch search results to ProductRecommendation format
77
+ */
78
+ protected parseRecommendations(recommendation: SearchResponse<MeilisearchRecommendHit>, algorithm: string): ProductRecommendation[] {
79
+ return recommendation.hits.map((hit) => ({
80
+ recommendationIdentifier: {
81
+ key: hit.id,
82
+ algorithm,
83
+ },
84
+ product: {
85
+ key: hit.id,
86
+ },
87
+ }));
88
+ }
89
+ }
@@ -3,6 +3,7 @@ import type { z } from 'zod';
3
3
 
4
4
  export const MeilisearchCapabilitiesSchema = CapabilitiesSchema.pick({
5
5
  productSearch: true,
6
+ productRecommendations: true,
6
7
  orderSearch: true,
7
8
  analytics: true
8
9
  }).partial();