@faststore/api 4.5.0-dev.0 → 4.5.0-dev.2

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 (48) hide show
  1. package/dist/cjs/index.js +96 -42
  2. package/dist/cjs/index.js.map +1 -1
  3. package/dist/es/index.mjs +1267 -1091
  4. package/dist/es/index.mjs.map +1 -1
  5. package/dist/src/__generated__/schema.d.ts +40 -0
  6. package/dist/src/__generated__/schema.d.ts.map +1 -1
  7. package/dist/src/platforms/vtex/clients/commerce/Contract.d.ts +1 -0
  8. package/dist/src/platforms/vtex/clients/commerce/Contract.d.ts.map +1 -1
  9. package/dist/src/platforms/vtex/clients/commerce/index.d.ts +12 -0
  10. package/dist/src/platforms/vtex/clients/commerce/index.d.ts.map +1 -1
  11. package/dist/src/platforms/vtex/clients/commerce/types/RecommendationResult.d.ts +25 -0
  12. package/dist/src/platforms/vtex/clients/commerce/types/RecommendationResult.d.ts.map +1 -0
  13. package/dist/src/platforms/vtex/clients/commerce/types/Session.d.ts +10 -0
  14. package/dist/src/platforms/vtex/clients/commerce/types/Session.d.ts.map +1 -1
  15. package/dist/src/platforms/vtex/clients/index.d.ts +4 -0
  16. package/dist/src/platforms/vtex/clients/index.d.ts.map +1 -1
  17. package/dist/src/platforms/vtex/index.d.ts +7 -0
  18. package/dist/src/platforms/vtex/index.d.ts.map +1 -1
  19. package/dist/src/platforms/vtex/resolvers/getOrderEntryOperation.d.ts.map +1 -1
  20. package/dist/src/platforms/vtex/resolvers/index.d.ts +7 -0
  21. package/dist/src/platforms/vtex/resolvers/index.d.ts.map +1 -1
  22. package/dist/src/platforms/vtex/resolvers/mutation.d.ts +1 -0
  23. package/dist/src/platforms/vtex/resolvers/mutation.d.ts.map +1 -1
  24. package/dist/src/platforms/vtex/resolvers/query.d.ts +7 -1
  25. package/dist/src/platforms/vtex/resolvers/query.d.ts.map +1 -1
  26. package/dist/src/platforms/vtex/resolvers/recommendations.d.ts +18 -0
  27. package/dist/src/platforms/vtex/resolvers/recommendations.d.ts.map +1 -0
  28. package/dist/src/platforms/vtex/resolvers/startRecommendationSession.d.ts +11 -0
  29. package/dist/src/platforms/vtex/resolvers/startRecommendationSession.d.ts.map +1 -0
  30. package/dist/src/platforms/vtex/resolvers/validateSession.d.ts.map +1 -1
  31. package/dist/src/platforms/vtex/utils/contract.d.ts +59 -0
  32. package/dist/src/platforms/vtex/utils/contract.d.ts.map +1 -0
  33. package/package.json +3 -3
  34. package/src/__generated__/schema.ts +47 -0
  35. package/src/platforms/vtex/clients/commerce/Contract.ts +1 -0
  36. package/src/platforms/vtex/clients/commerce/index.ts +88 -3
  37. package/src/platforms/vtex/clients/commerce/types/RecommendationResult.ts +27 -0
  38. package/src/platforms/vtex/clients/commerce/types/Session.ts +9 -0
  39. package/src/platforms/vtex/resolvers/mutation.ts +2 -0
  40. package/src/platforms/vtex/resolvers/query.ts +60 -3
  41. package/src/platforms/vtex/resolvers/recommendations.ts +80 -0
  42. package/src/platforms/vtex/resolvers/startRecommendationSession.ts +30 -0
  43. package/src/platforms/vtex/resolvers/validateSession.ts +2 -1
  44. package/src/platforms/vtex/typeDefs/mutation.graphql +4 -0
  45. package/src/platforms/vtex/typeDefs/organization.graphql +18 -0
  46. package/src/platforms/vtex/typeDefs/query.graphql +21 -0
  47. package/src/platforms/vtex/typeDefs/recommendation.graphql +11 -0
  48. package/src/platforms/vtex/utils/contract.ts +144 -0
@@ -45,12 +45,28 @@ import type {
45
45
  SimulationArgs,
46
46
  SimulationOptions,
47
47
  } from './types/Simulation'
48
+ import type {
49
+ RecommendationResult,
50
+ StartRecommendationSessionResult,
51
+ } from './types/RecommendationResult'
48
52
  import type { ScopesByUnit, UnitResponse } from './types/Unit'
49
53
  import type { VtexIdResponse } from './types/VtexId'
50
54
  import type { QuoteListResult, ListUserQuotesArgs } from './types/Quote'
51
55
 
52
56
  type ValueOf<T> = T extends Record<string, infer K> ? K : never
53
57
 
58
+ // Identifies the storefront origin to the Recommendations BFF, as required by
59
+ // the API (`x-vtex-rec-origin` header).
60
+ const REC_ORIGIN_SUFFIX = 'storefront/faststore.recommendation-shelf@v4'
61
+
62
+ export interface RecommendationArgs {
63
+ campaignVrn: string
64
+ userId?: string
65
+ products?: string[]
66
+ salesChannel?: string
67
+ locale?: string
68
+ }
69
+
54
70
  const BASE_INIT = {
55
71
  method: 'POST',
56
72
  headers: {
@@ -156,6 +172,26 @@ export const VtexCommerce = (
156
172
 
157
173
  const forwardedHost = host.replace(selectedPrefix, '')
158
174
 
175
+ // Recommendations BFF (`/api/recommend-bff/v2`) lives under the same account
176
+ // host as the other commerce APIs, so it's exposed here as a namespace.
177
+ const recommendationBase = `${base}/api/recommend-bff/v2`
178
+ const recommendationHeaders: HeadersInit = withCookie({
179
+ accept: 'application/json',
180
+ 'content-type': 'application/json',
181
+ 'x-vtex-rec-origin': `${account}/${REC_ORIGIN_SUFFIX}`,
182
+ })
183
+ const withBuyerAuthHeaders = (
184
+ additionalHeaders: Record<string, string> = {}
185
+ ): HeadersInit => {
186
+ const authToken = getAuthCookie(getUpdatedCookie(ctx) ?? '', account)
187
+
188
+ return withCookie({
189
+ ...additionalHeaders,
190
+ 'X-FORWARDED-HOST': forwardedHost,
191
+ ...(authToken ? { [`VtexIdclientAutCookie_${account}`]: authToken } : {}),
192
+ })
193
+ }
194
+
159
195
  return {
160
196
  catalog: {
161
197
  salesChannel: (sc: string): Promise<SalesChannel> =>
@@ -532,7 +568,7 @@ export const VtexCommerce = (
532
568
 
533
569
  params.set(
534
570
  'items',
535
- 'profile.id,profile.email,profile.firstName,profile.lastName,profile.phone,shopper.firstName,shopper.lastName,shopper.organizationManager,store.channel,store.countryCode,store.cultureInfo,store.currencyCode,store.currencySymbol,authentication.customerId,authentication.storeUserId,authentication.storeUserEmail,authentication.unitId,authentication.unitName,checkout.regionId,public.postalCode'
571
+ 'profile.id,profile.email,profile.firstName,profile.lastName,profile.phone,shopper.firstName,shopper.lastName,shopper.organizationManager,shopper.availableContracts,shopper.activeContractId,store.channel,store.countryCode,store.cultureInfo,store.currencyCode,store.currencySymbol,authentication.customerId,authentication.storeUserId,authentication.storeUserEmail,authentication.unitId,authentication.unitName,checkout.regionId,public.postalCode'
536
572
  )
537
573
 
538
574
  const headers: HeadersInit = withCookie({
@@ -839,10 +875,9 @@ export const VtexCommerce = (
839
875
  throw new BadRequestError('Missing contractId to fetch CL fields.')
840
876
  }
841
877
 
842
- const headers: HeadersInit = withAppKeyAndToken({
878
+ const headers: HeadersInit = withBuyerAuthHeaders({
843
879
  Accept: 'application/json',
844
880
  'content-type': 'application/json',
845
- 'X-FORWARDED-HOST': forwardedHost,
846
881
  })
847
882
 
848
883
  return fetchAPI(
@@ -1085,5 +1120,55 @@ export const VtexCommerce = (
1085
1120
  )
1086
1121
  },
1087
1122
  },
1123
+
1124
+ // Anonymous personalization / recommendations served by the VTEX
1125
+ // Recommendations BFF. The BFF replies with `vtex-rec-*` Set-Cookie headers
1126
+ // (forwarded to the browser via `ctx.storage.cookies`) and returns products
1127
+ // already hydrated in the Intelligent Search shape.
1128
+ recommendation: {
1129
+ recommendations: ({
1130
+ campaignVrn,
1131
+ userId,
1132
+ products = [],
1133
+ salesChannel,
1134
+ locale,
1135
+ }: RecommendationArgs): Promise<RecommendationResult> => {
1136
+ const params = new URLSearchParams({ an: account, campaignVrn })
1137
+
1138
+ if (userId) {
1139
+ params.append('userId', userId)
1140
+ }
1141
+
1142
+ if (products.length > 0) {
1143
+ params.append('products', products.join(','))
1144
+ }
1145
+
1146
+ if (salesChannel) {
1147
+ params.append('salesChannel', salesChannel)
1148
+ }
1149
+
1150
+ if (locale) {
1151
+ params.append('locale', locale)
1152
+ }
1153
+
1154
+ return fetchAPI(
1155
+ `${recommendationBase}/recommendations?${params.toString()}`,
1156
+ { headers: recommendationHeaders },
1157
+ { storeCookies }
1158
+ )
1159
+ },
1160
+
1161
+ startRecommendationSession: (): Promise<
1162
+ StartRecommendationSessionResult | undefined
1163
+ > => {
1164
+ const params = new URLSearchParams({ an: account })
1165
+
1166
+ return fetchAPI(
1167
+ `${recommendationBase}/users/start-session?${params.toString()}`,
1168
+ { method: 'POST', headers: recommendationHeaders },
1169
+ { storeCookies }
1170
+ )
1171
+ },
1172
+ },
1088
1173
  }
1089
1174
  }
@@ -0,0 +1,27 @@
1
+ import type { Product } from '../../search/types/ProductSearchResult'
2
+
3
+ /**
4
+ * Raw response of the VTEX Recommendations BFF
5
+ * (`GET /api/recommend-bff/v2/recommendations`).
6
+ *
7
+ * The BFF already returns the products fully hydrated in the same Intelligent
8
+ * Search shape (`Product`) used by the `search` query, so the resolver can map
9
+ * them straight to the normalized `StoreProduct` shape via `pickBestSku` +
10
+ * `enhanceSku` — no extra round-trip to search is needed.
11
+ */
12
+ export interface RecommendationResult {
13
+ products: Product[]
14
+ correlationId: string
15
+ campaign: RecommendationBffCampaign
16
+ }
17
+
18
+ export interface RecommendationBffCampaign {
19
+ id: string
20
+ title?: string
21
+ type: string
22
+ }
23
+
24
+ /** Response of `POST /api/recommend-bff/v2/users/start-session`. */
25
+ export interface StartRecommendationSessionResult {
26
+ recommendationsUserId: string
27
+ }
@@ -40,10 +40,19 @@ export interface Profile {
40
40
  phone?: Value
41
41
  }
42
42
 
43
+ export type SessionAvailableContract = {
44
+ customerId: string
45
+ contractName: string
46
+ isActive: boolean
47
+ isCurrent: boolean
48
+ }
49
+
43
50
  export interface Shopper {
44
51
  firstName?: Value
45
52
  lastName?: Value
46
53
  organizationManager?: { value: boolean }
54
+ availableContracts?: { value: SessionAvailableContract[] }
55
+ activeContractId?: Value
47
56
  }
48
57
 
49
58
  export interface Checkout {
@@ -1,6 +1,7 @@
1
1
  import { cancelOrder } from './cancelOrder'
2
2
  import { processOrderAuthorization } from './processOrderAuthorization'
3
3
  import { startOrderEntryOperation } from './startOrderEntryOperation'
4
+ import { startRecommendationSession } from './startRecommendationSession'
4
5
  import { subscribeToNewsletter } from './subscribeToNewsletter'
5
6
  import { uploadFileToOrderEntry } from './uploadFileToOrderEntry'
6
7
  import { validateCart } from './validateCart'
@@ -14,4 +15,5 @@ export const Mutation = {
14
15
  processOrderAuthorization,
15
16
  uploadFileToOrderEntry,
16
17
  startOrderEntryOperation,
18
+ startRecommendationSession,
17
19
  }
@@ -2,6 +2,7 @@ import type {
2
2
  ProcessOrderAuthorizationRule,
3
3
  QueryAllCollectionsArgs,
4
4
  QueryAllProductsArgs,
5
+ QueryAvailableContractsArgs,
5
6
  QueryCollectionArgs,
6
7
  QueryListUserOrdersArgs,
7
8
  QueryPickupPointsArgs,
@@ -14,10 +15,12 @@ import type {
14
15
  QuerySellersArgs,
15
16
  QueryShippingArgs,
16
17
  QueryUserOrderArgs,
18
+ StoreContract,
17
19
  UserOrderFromList,
18
20
  } from '../../../__generated__/schema'
19
21
  import { getOrderEntryOperation } from './getOrderEntryOperation'
20
22
  import { getOrderFormItems } from './getOrderFormItems'
23
+ import { recommendations } from './recommendations'
21
24
  import {
22
25
  BadRequestError,
23
26
  ForbiddenError,
@@ -31,6 +34,12 @@ import type { SearchArgs } from '../clients/search'
31
34
  import type { ProductSearchResult } from '../clients/search/types/ProductSearchResult'
32
35
  import type { GraphqlContext } from '../index'
33
36
  import { extractRuleForAuthorization } from '../utils/commercialAuth'
37
+ import {
38
+ mapSessionContractsToStoreContracts,
39
+ parseSessionAvailableContracts,
40
+ resolveActiveContractDisplayName,
41
+ resolveActiveContractIdFromSession,
42
+ } from '../utils/contract'
34
43
  import { mutateChannelContext, mutateLocaleContext } from '../utils/contex'
35
44
  import { getAuthCookie, parseJwt } from '../utils/cookies'
36
45
  import { enhanceSku } from '../utils/enhanceSku'
@@ -741,9 +750,7 @@ export const Query = {
741
750
  contractId: profile?.id?.value ?? '',
742
751
  })
743
752
 
744
- const name =
745
- contract?.corporateName ??
746
- `${(profile?.firstName?.value ?? '').trim()} ${(profile?.lastName?.value ?? '').trim()}`.trim()
753
+ const name = resolveActiveContractDisplayName(contract, profile)
747
754
 
748
755
  return {
749
756
  name: name || '',
@@ -766,6 +773,55 @@ export const Query = {
766
773
  // createdAt: '',
767
774
  }
768
775
  },
776
+ // only b2b users
777
+ // Contract list from VTEX session `shopper.availableContracts`.
778
+ availableContracts: async (
779
+ _: unknown,
780
+ { orgUnitId }: QueryAvailableContractsArgs,
781
+ ctx: GraphqlContext
782
+ ): Promise<StoreContract[]> => {
783
+ if (!orgUnitId) {
784
+ throw new BadRequestError('Missing orgUnitId')
785
+ }
786
+
787
+ const {
788
+ account,
789
+ headers,
790
+ clients: { commerce },
791
+ } = ctx
792
+
793
+ const sessionData = await commerce.session('').catch(() => null)
794
+ const authToken = getAuthCookie(headers?.cookie ?? '', account)
795
+ let jwt: ReturnType<typeof parseJwt> = null
796
+
797
+ try {
798
+ jwt = authToken ? parseJwt(authToken) : null
799
+ } catch {
800
+ jwt = null
801
+ }
802
+
803
+ const sessionUnitId =
804
+ sessionData?.namespaces.authentication?.unitId?.value?.trim() ??
805
+ jwt?.unitId?.trim() ??
806
+ ''
807
+
808
+ if (!sessionUnitId || sessionUnitId !== orgUnitId) {
809
+ throw new ForbiddenError(
810
+ 'You are not allowed to list contracts for this organization unit'
811
+ )
812
+ }
813
+
814
+ const contracts = parseSessionAvailableContracts(
815
+ sessionData?.namespaces.shopper
816
+ )
817
+
818
+ const activeContractId =
819
+ resolveActiveContractIdFromSession(sessionData) ||
820
+ jwt?.customerId?.trim() ||
821
+ ''
822
+
823
+ return mapSessionContractsToStoreContracts(contracts, activeContractId)
824
+ },
769
825
  pickupPoints: async (
770
826
  _: unknown,
771
827
  { geoCoordinates }: QueryPickupPointsArgs,
@@ -783,4 +839,5 @@ export const Query = {
783
839
  },
784
840
  orderEntryOperation: getOrderEntryOperation,
785
841
  orderFormItems: getOrderFormItems,
842
+ recommendations,
786
843
  }
@@ -0,0 +1,80 @@
1
+ import { BadRequestError } from '../../errors'
2
+ import type { QueryRecommendationsArgs } from '../../../__generated__/schema'
3
+ import type { GraphqlContext } from '../index'
4
+ import { enhanceSku, type EnhancedSku } from '../utils/enhanceSku'
5
+ import { pickBestSku } from '../utils/sku'
6
+
7
+ // Structural check for a recommendations campaign VRN
8
+ // (`vrn:recommendations:<account>:<campaign-type>:<campaign-id>`). Kept generic
9
+ // on purpose: the authoritative campaign taxonomy lives in `@faststore/core`,
10
+ // and `@faststore/api` must not depend on it, so we only validate the shape.
11
+ const RECOMMENDATION_VRN_PATTERN = /^vrn:recommendations:[^:]+:[^:]+:[^:]+$/
12
+
13
+ /**
14
+ * Resolves personalized recommendations for a campaign.
15
+ *
16
+ * The VTEX Recommendations BFF already returns the products fully hydrated in
17
+ * the same Intelligent Search shape used by the `search` query. We map them
18
+ * straight to the normalized `StoreProduct` shape (`pickBestSku` + `enhanceSku`)
19
+ * so recommendation shelves render identical cards to regular shelves, while
20
+ * preserving the recommendation order returned by the BFF.
21
+ */
22
+ export const recommendations = async (
23
+ _: unknown,
24
+ { campaignVrn, userId, products }: QueryRecommendationsArgs,
25
+ ctx: GraphqlContext
26
+ ) => {
27
+ const {
28
+ clients: { commerce },
29
+ } = ctx
30
+
31
+ const { salesChannel } = ctx.storage.channel
32
+
33
+ // Validate inputs server-side so only clean, well-formed payloads reach the
34
+ // Recommendations BFF (see @faststore/api server-side validation principle).
35
+ const normalizedCampaignVrn = campaignVrn?.trim()
36
+ if (
37
+ !normalizedCampaignVrn ||
38
+ !RECOMMENDATION_VRN_PATTERN.test(normalizedCampaignVrn)
39
+ ) {
40
+ throw new BadRequestError(`Invalid campaignVrn: "${campaignVrn}"`)
41
+ }
42
+
43
+ const normalizedUserId = userId?.trim()
44
+ if (userId != null && !normalizedUserId) {
45
+ throw new BadRequestError('Invalid userId: must be a non-empty string')
46
+ }
47
+
48
+ if (
49
+ products != null &&
50
+ (!Array.isArray(products) || products.some((product) => !product?.trim()))
51
+ ) {
52
+ throw new BadRequestError(
53
+ 'Invalid products: must be an array of non-empty strings'
54
+ )
55
+ }
56
+
57
+ const response = await commerce.recommendation.recommendations({
58
+ campaignVrn: normalizedCampaignVrn,
59
+ userId: normalizedUserId || undefined,
60
+ products: products ?? [],
61
+ salesChannel: salesChannel ?? undefined,
62
+ locale: ctx.storage.locale,
63
+ })
64
+
65
+ const { campaign, correlationId } = response
66
+
67
+ const orderedProducts = (response.products ?? [])
68
+ .map((product) => {
69
+ const sku = pickBestSku(product.items)
70
+
71
+ return sku ? enhanceSku(sku, product) : null
72
+ })
73
+ .filter((sku): sku is EnhancedSku => Boolean(sku))
74
+
75
+ return {
76
+ products: orderedProducts,
77
+ correlationId,
78
+ campaign,
79
+ }
80
+ }
@@ -0,0 +1,30 @@
1
+ import type { GraphqlContext } from '../index'
2
+
3
+ /**
4
+ * Starts (or updates) the anonymous personalization session for the current
5
+ * shopper via the Recommendations BFF.
6
+ *
7
+ * The BFF replies with the `vtex-rec-user-id`/`vtex-rec-user-start-session`
8
+ * Set-Cookie headers, which the client forwards to the browser through
9
+ * `ctx.storage.cookies`. Returns `true` once the session has been started.
10
+ */
11
+ export const startRecommendationSession = async (
12
+ _: unknown,
13
+ __: unknown,
14
+ ctx: GraphqlContext
15
+ ) => {
16
+ const result =
17
+ await ctx.clients.commerce.recommendation.startRecommendationSession()
18
+
19
+ // The BFF may not be ready on the first call and can resolve without a
20
+ // session payload. Only report success once a session actually exists (its
21
+ // `recommendationsUserId`); otherwise surface an error so the caller retries
22
+ // instead of treating an empty response as a started session.
23
+ if (!result?.recommendationsUserId) {
24
+ throw new Error(
25
+ 'Failed to start recommendation session: no session data returned'
26
+ )
27
+ }
28
+
29
+ return true
30
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  StoreSession,
8
8
  } from '../../../__generated__/schema'
9
9
  import ChannelMarshal from '../utils/channel'
10
+ import { resolveActiveContractDisplayName } from '../utils/contract'
10
11
  import { getAuthCookie, parseJwt } from '../utils/cookies'
11
12
 
12
13
  async function getPreciseLocationData(
@@ -198,7 +199,7 @@ export const validateSession = async (
198
199
  `${typeof shopper?.firstName?.value === 'string' ? shopper.firstName.value : ''} ${typeof shopper?.lastName?.value === 'string' ? shopper.lastName.value : ''}`.trim(),
199
200
  userEmail: authentication?.storeUserEmail.value ?? '',
200
201
  savedPostalCode: publicData?.postalCode?.value ?? '',
201
- contractName: contract?.corporateName ?? '',
202
+ contractName: resolveActiveContractDisplayName(contract, profile),
202
203
  organizationManager: shopper?.organizationManager?.value ?? false,
203
204
  }
204
205
  : null,
@@ -35,4 +35,8 @@ type Mutation {
35
35
  startOrderEntryOperation(
36
36
  data: IOrderEntryOperation!
37
37
  ): StoreOrderEntryOperationResult @auth
38
+ """
39
+ Starts an anonymous personalization session for the current shopper.
40
+ """
41
+ startRecommendationSession: Boolean!
38
42
  }
@@ -17,3 +17,21 @@ input IStoreOrganization {
17
17
  """
18
18
  identifier: String!
19
19
  }
20
+
21
+ """
22
+ A commercial contract available to a buyer's Organization Unit.
23
+ """
24
+ type StoreContract {
25
+ """
26
+ Contract identifier (the contract/scope ID associated with the Organization Unit).
27
+ """
28
+ id: ID!
29
+ """
30
+ Human-readable corporate name of the contract (resolved from MasterData).
31
+ """
32
+ corporateName: String!
33
+ """
34
+ Indicates whether this contract is the one currently active in the session.
35
+ """
36
+ isActive: Boolean!
37
+ }
@@ -491,6 +491,18 @@ type Query {
491
491
  @auth
492
492
  @cacheControl(scope: "private", sMaxAge: 300, staleWhileRevalidate: 3600)
493
493
 
494
+ """
495
+ Lists the commercial contracts associated with the given Organization Unit,
496
+ resolved to human-readable corporate names. Governed: only contracts associated
497
+ with the authenticated buyer's Organization Unit are returned.
498
+ """
499
+ availableContracts(
500
+ """
501
+ The Organization Unit identifier whose contracts will be listed.
502
+ """
503
+ orgUnitId: String!
504
+ ): [StoreContract!]! @auth @cacheControl(scope: "private", sMaxAge: 0)
505
+
494
506
  """
495
507
  Returns whether the current authenticated user belongs to a B2B organization unit.
496
508
  """
@@ -517,6 +529,15 @@ type Query {
517
529
  Returns the items in an orderForm by its ID.
518
530
  """
519
531
  orderFormItems(orderFormId: String!): [StoreOrderFormCartItem!]! @auth
532
+ """
533
+ Returns personalized product recommendations for a given campaign.
534
+ """
535
+ recommendations(
536
+ campaignVrn: String!
537
+ userId: String
538
+ products: [String!]
539
+ ): RecommendationResponse!
540
+ @cacheControl(scope: "private", sMaxAge: 120, staleWhileRevalidate: 3600)
520
541
  }
521
542
 
522
543
  type ValidateUserData {
@@ -0,0 +1,11 @@
1
+ type RecommendationResponse {
2
+ products: [StoreProduct!]!
3
+ correlationId: String!
4
+ campaign: RecommendationCampaign!
5
+ }
6
+
7
+ type RecommendationCampaign {
8
+ id: String!
9
+ title: String
10
+ type: String!
11
+ }
@@ -0,0 +1,144 @@
1
+ import type { ContractResponse } from '../clients/commerce/Contract'
2
+ import type {
3
+ SessionAvailableContract,
4
+ Shopper,
5
+ } from '../clients/commerce/types/Session'
6
+
7
+ type ProfileNameFields = {
8
+ firstName?: { value?: string | null } | null
9
+ lastName?: { value?: string | null } | null
10
+ } | null
11
+
12
+ /**
13
+ * Display name from Master Data, aligned with buyer-portal `personalDataToContract`.
14
+ */
15
+ export const resolveContractDisplayNameFromMd = (
16
+ contract: ContractResponse | null | undefined
17
+ ): string => {
18
+ const corporateName = (contract?.corporateName ?? '').trim()
19
+
20
+ if (corporateName) {
21
+ return corporateName
22
+ }
23
+
24
+ return (contract?.firstName ?? '').trim()
25
+ }
26
+
27
+ const isSessionAvailableContract = (
28
+ value: unknown
29
+ ): value is SessionAvailableContract => {
30
+ if (!value || typeof value !== 'object') {
31
+ return false
32
+ }
33
+
34
+ const contract = value as Record<string, unknown>
35
+
36
+ return (
37
+ typeof contract.customerId === 'string' &&
38
+ contract.customerId.trim() !== '' &&
39
+ typeof contract.contractName === 'string' &&
40
+ typeof contract.isActive === 'boolean' &&
41
+ typeof contract.isCurrent === 'boolean'
42
+ )
43
+ }
44
+
45
+ /**
46
+ * Parses VTEX session `shopper.availableContracts` into a typed list.
47
+ */
48
+ export const parseSessionAvailableContracts = (
49
+ shopper: Shopper | null | undefined
50
+ ): SessionAvailableContract[] => {
51
+ const raw = shopper?.availableContracts?.value
52
+
53
+ if (!Array.isArray(raw)) {
54
+ return []
55
+ }
56
+
57
+ return raw.filter(isSessionAvailableContract)
58
+ }
59
+
60
+ /**
61
+ * Whether a session contract is eligible for the switcher list.
62
+ */
63
+ export const isSwitchableSessionContract = (
64
+ contract: SessionAvailableContract | null | undefined
65
+ ): boolean => {
66
+ if (!contract?.customerId) {
67
+ return false
68
+ }
69
+
70
+ const name = (contract.contractName ?? '').trim()
71
+
72
+ return Boolean(name && contract.isActive)
73
+ }
74
+
75
+ /**
76
+ * Resolves the active contract id from VTEX session namespaces.
77
+ */
78
+ export const resolveActiveContractIdFromSession = (
79
+ sessionData: {
80
+ namespaces?: {
81
+ shopper?: Shopper | null
82
+ authentication?: { customerId?: { value?: string | null } | null } | null
83
+ profile?: { id?: { value?: string | null } | null } | null
84
+ } | null
85
+ } | null
86
+ ): string => {
87
+ return (
88
+ sessionData?.namespaces?.shopper?.activeContractId?.value?.trim() ??
89
+ sessionData?.namespaces?.authentication?.customerId?.value?.trim() ??
90
+ sessionData?.namespaces?.profile?.id?.value?.trim() ??
91
+ ''
92
+ )
93
+ }
94
+
95
+ /**
96
+ * Maps session contracts to GraphQL `StoreContract` entries.
97
+ */
98
+ export const mapSessionContractsToStoreContracts = (
99
+ contracts: SessionAvailableContract[],
100
+ activeContractId = ''
101
+ ): Array<{ id: string; corporateName: string; isActive: boolean }> => {
102
+ const normalizedActiveId = activeContractId.trim()
103
+
104
+ return contracts.filter(isSwitchableSessionContract).map((contract) => ({
105
+ id: contract.customerId,
106
+ corporateName: contract.contractName.trim(),
107
+ isActive: normalizedActiveId
108
+ ? contract.customerId === normalizedActiveId
109
+ : contract.isCurrent,
110
+ }))
111
+ }
112
+
113
+ /**
114
+ * Whether a CL record is eligible for contract switcher summaries (attached list).
115
+ */
116
+ export const isSwitchableContractSummary = (
117
+ contract: ContractResponse | null | undefined
118
+ ): boolean => {
119
+ if (!contract) {
120
+ return false
121
+ }
122
+
123
+ const name = resolveContractDisplayNameFromMd(contract)
124
+ const email = (contract.email ?? '').trim()
125
+
126
+ return Boolean(name && email)
127
+ }
128
+
129
+ /**
130
+ * Resolves the active contract name: Master Data first, then VTEX session profile.
131
+ * Matches `accountProfile` / buyer-portal behavior when CL lookup fails.
132
+ */
133
+ export const resolveActiveContractDisplayName = (
134
+ contract: ContractResponse | null | undefined,
135
+ profile: ProfileNameFields
136
+ ): string => {
137
+ const fromMasterData = resolveContractDisplayNameFromMd(contract)
138
+
139
+ if (fromMasterData) {
140
+ return fromMasterData
141
+ }
142
+
143
+ return (profile?.firstName?.value ?? '').trim()
144
+ }