@faststore/api 4.4.1-dev.0 → 4.5.0-dev.1

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 (52) hide show
  1. package/dist/cjs/index.js +207 -41
  2. package/dist/cjs/index.js.map +1 -1
  3. package/dist/es/index.mjs +1545 -1138
  4. package/dist/es/index.mjs.map +1 -1
  5. package/dist/src/__generated__/schema.d.ts +90 -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 +16 -0
  10. package/dist/src/platforms/vtex/clients/commerce/index.d.ts.map +1 -1
  11. package/dist/src/platforms/vtex/clients/commerce/types/Quote.d.ts +26 -0
  12. package/dist/src/platforms/vtex/clients/commerce/types/Quote.d.ts.map +1 -0
  13. package/dist/src/platforms/vtex/clients/commerce/types/RecommendationResult.d.ts +25 -0
  14. package/dist/src/platforms/vtex/clients/commerce/types/RecommendationResult.d.ts.map +1 -0
  15. package/dist/src/platforms/vtex/clients/commerce/types/Session.d.ts +10 -0
  16. package/dist/src/platforms/vtex/clients/commerce/types/Session.d.ts.map +1 -1
  17. package/dist/src/platforms/vtex/clients/index.d.ts +7 -0
  18. package/dist/src/platforms/vtex/clients/index.d.ts.map +1 -1
  19. package/dist/src/platforms/vtex/index.d.ts +33 -0
  20. package/dist/src/platforms/vtex/index.d.ts.map +1 -1
  21. package/dist/src/platforms/vtex/resolvers/getOrderEntryOperation.d.ts.map +1 -1
  22. package/dist/src/platforms/vtex/resolvers/index.d.ts +33 -0
  23. package/dist/src/platforms/vtex/resolvers/index.d.ts.map +1 -1
  24. package/dist/src/platforms/vtex/resolvers/mutation.d.ts +1 -0
  25. package/dist/src/platforms/vtex/resolvers/mutation.d.ts.map +1 -1
  26. package/dist/src/platforms/vtex/resolvers/query.d.ts +33 -1
  27. package/dist/src/platforms/vtex/resolvers/query.d.ts.map +1 -1
  28. package/dist/src/platforms/vtex/resolvers/recommendations.d.ts +18 -0
  29. package/dist/src/platforms/vtex/resolvers/recommendations.d.ts.map +1 -0
  30. package/dist/src/platforms/vtex/resolvers/startRecommendationSession.d.ts +11 -0
  31. package/dist/src/platforms/vtex/resolvers/startRecommendationSession.d.ts.map +1 -0
  32. package/dist/src/platforms/vtex/resolvers/validateSession.d.ts.map +1 -1
  33. package/dist/src/platforms/vtex/utils/contract.d.ts +59 -0
  34. package/dist/src/platforms/vtex/utils/contract.d.ts.map +1 -0
  35. package/package.json +3 -3
  36. package/src/__generated__/schema.ts +102 -0
  37. package/src/platforms/vtex/clients/commerce/Contract.ts +1 -0
  38. package/src/platforms/vtex/clients/commerce/index.ts +188 -3
  39. package/src/platforms/vtex/clients/commerce/types/Quote.ts +27 -0
  40. package/src/platforms/vtex/clients/commerce/types/RecommendationResult.ts +27 -0
  41. package/src/platforms/vtex/clients/commerce/types/Session.ts +9 -0
  42. package/src/platforms/vtex/resolvers/mutation.ts +2 -0
  43. package/src/platforms/vtex/resolvers/query.ts +137 -3
  44. package/src/platforms/vtex/resolvers/recommendations.ts +80 -0
  45. package/src/platforms/vtex/resolvers/startRecommendationSession.ts +30 -0
  46. package/src/platforms/vtex/resolvers/validateSession.ts +2 -1
  47. package/src/platforms/vtex/typeDefs/mutation.graphql +4 -0
  48. package/src/platforms/vtex/typeDefs/organization.graphql +18 -0
  49. package/src/platforms/vtex/typeDefs/query.graphql +68 -0
  50. package/src/platforms/vtex/typeDefs/quote.graphql +65 -0
  51. package/src/platforms/vtex/typeDefs/recommendation.graphql +11 -0
  52. package/src/platforms/vtex/utils/contract.ts +144 -0
@@ -45,11 +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'
54
+ import type { QuoteListResult, ListUserQuotesArgs } from './types/Quote'
50
55
 
51
56
  type ValueOf<T> = T extends Record<string, infer K> ? K : never
52
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
+
53
70
  const BASE_INIT = {
54
71
  method: 'POST',
55
72
  headers: {
@@ -57,6 +74,84 @@ const BASE_INIT = {
57
74
  },
58
75
  }
59
76
 
77
+ const QUOTE_ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
78
+ const QUOTE_VALID_STATUSES = new Set([
79
+ 'Draft',
80
+ 'Requested',
81
+ 'InReview',
82
+ 'Reviewed',
83
+ 'Approved',
84
+ 'Declined',
85
+ 'Expired',
86
+ 'ConvertedToCart',
87
+ 'ConvertedToOrder',
88
+ ])
89
+
90
+ function validateListUserQuotesArgs({
91
+ page,
92
+ perPage,
93
+ status,
94
+ createdAtFrom,
95
+ createdAtTo,
96
+ expiresAtFrom,
97
+ expiresAtTo,
98
+ }: ListUserQuotesArgs): void {
99
+ if (page !== undefined && (!Number.isInteger(page) || page < 1)) {
100
+ throw new Error(
101
+ `listUserQuotes: invalid page "${page}" — must be a positive integer`
102
+ )
103
+ }
104
+ if (perPage !== undefined && (!Number.isInteger(perPage) || perPage < 1)) {
105
+ throw new Error(
106
+ `listUserQuotes: invalid perPage "${perPage}" — must be a positive integer`
107
+ )
108
+ }
109
+ for (const [field, value] of [
110
+ ['createdAtFrom', createdAtFrom],
111
+ ['createdAtTo', createdAtTo],
112
+ ['expiresAtFrom', expiresAtFrom],
113
+ ['expiresAtTo', expiresAtTo],
114
+ ] as [string, string | undefined][]) {
115
+ if (value && !QUOTE_ISO_DATE.test(value)) {
116
+ throw new Error(
117
+ `listUserQuotes: invalid ${field} "${value}" — must be YYYY-MM-DD`
118
+ )
119
+ }
120
+ }
121
+ if (status && status.length > 0) {
122
+ const invalid = status.filter((s) => !QUOTE_VALID_STATUSES.has(s))
123
+ if (invalid.length > 0) {
124
+ throw new Error(
125
+ `listUserQuotes: invalid status values: ${invalid.join(', ')}`
126
+ )
127
+ }
128
+ }
129
+ }
130
+
131
+ function buildListUserQuotesParams({
132
+ page,
133
+ perPage,
134
+ status,
135
+ createdAtFrom,
136
+ createdAtTo,
137
+ expiresAtFrom,
138
+ expiresAtTo,
139
+ label,
140
+ }: ListUserQuotesArgs): URLSearchParams {
141
+ const params = new URLSearchParams()
142
+
143
+ if (page) params.append('pageNumber', page.toString())
144
+ if (perPage) params.append('pageSize', perPage.toString())
145
+ status?.forEach((s) => params.append('status', s))
146
+ if (createdAtFrom) params.append('createdAtFrom', createdAtFrom)
147
+ if (createdAtTo) params.append('createdAtTo', createdAtTo)
148
+ if (expiresAtFrom) params.append('expiresAtFrom', expiresAtFrom)
149
+ if (expiresAtTo) params.append('expiresAtTo', expiresAtTo)
150
+ if (label?.trim()) params.append('label', label.trim())
151
+
152
+ return params
153
+ }
154
+
60
155
  export const VtexCommerce = (
61
156
  { account, environment, incrementAddress, subDomainPrefix }: Options,
62
157
  ctx: GraphqlContext
@@ -77,6 +172,26 @@ export const VtexCommerce = (
77
172
 
78
173
  const forwardedHost = host.replace(selectedPrefix, '')
79
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
+
80
195
  return {
81
196
  catalog: {
82
197
  salesChannel: (sc: string): Promise<SalesChannel> =>
@@ -453,7 +568,7 @@ export const VtexCommerce = (
453
568
 
454
569
  params.set(
455
570
  'items',
456
- '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'
457
572
  )
458
573
 
459
574
  const headers: HeadersInit = withCookie({
@@ -760,10 +875,9 @@ export const VtexCommerce = (
760
875
  throw new BadRequestError('Missing contractId to fetch CL fields.')
761
876
  }
762
877
 
763
- const headers: HeadersInit = withAppKeyAndToken({
878
+ const headers: HeadersInit = withBuyerAuthHeaders({
764
879
  Accept: 'application/json',
765
880
  'content-type': 'application/json',
766
- 'X-FORWARDED-HOST': forwardedHost,
767
881
  })
768
882
 
769
883
  return fetchAPI(
@@ -807,6 +921,27 @@ export const VtexCommerce = (
807
921
  )
808
922
  },
809
923
  },
924
+ quotes: {
925
+ listUserQuotes: (args: ListUserQuotesArgs): Promise<QuoteListResult> => {
926
+ validateListUserQuotesArgs(args)
927
+
928
+ const params = buildListUserQuotesParams(args)
929
+
930
+ const headers: HeadersInit = withCookie({
931
+ 'content-type': 'application/json',
932
+ 'X-FORWARDED-HOST': forwardedHost,
933
+ })
934
+
935
+ return fetchAPI(
936
+ `${base}/api/quoting/quotes?${params.toString()}`,
937
+ {
938
+ method: 'GET',
939
+ headers,
940
+ },
941
+ { storeCookies }
942
+ )
943
+ },
944
+ },
810
945
  vtexid: {
811
946
  validate: (): Promise<VtexIdResponse> => {
812
947
  const headers: HeadersInit = withCookie({
@@ -985,5 +1120,55 @@ export const VtexCommerce = (
985
1120
  )
986
1121
  },
987
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
+ },
988
1173
  }
989
1174
  }
@@ -0,0 +1,27 @@
1
+ export interface QuoteSummary {
2
+ id: string
3
+ status: string
4
+ label?: string
5
+ createdAt: string
6
+ expiresAt: string
7
+ amount: number
8
+ createdBy?: string
9
+ }
10
+
11
+ export interface QuoteListResult {
12
+ items: QuoteSummary[]
13
+ pageNumber: number
14
+ pageSize: number
15
+ totalItems: number
16
+ }
17
+
18
+ export interface ListUserQuotesArgs {
19
+ page?: number
20
+ perPage?: number
21
+ status?: string[]
22
+ createdAtFrom?: string
23
+ createdAtTo?: string
24
+ expiresAtFrom?: string
25
+ expiresAtTo?: string
26
+ label?: string
27
+ }
@@ -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'
@@ -594,6 +603,70 @@ export const Query = {
594
603
  paging: orders.paging,
595
604
  }
596
605
  },
606
+ listUserQuotes: async (
607
+ _: unknown,
608
+ filters: {
609
+ page?: number
610
+ perPage?: number
611
+ status?: string[]
612
+ createdAtFrom?: string
613
+ createdAtTo?: string
614
+ expiresAtFrom?: string
615
+ expiresAtTo?: string
616
+ label?: string
617
+ },
618
+ ctx: GraphqlContext
619
+ ) => {
620
+ const {
621
+ clients: { commerce },
622
+ } = ctx
623
+
624
+ const result = await commerce.quotes.listUserQuotes(filters)
625
+
626
+ const uniqueCreatedByIds = [
627
+ ...new Set(result.items.map((quote) => quote.createdBy).filter(Boolean)),
628
+ ] as string[]
629
+
630
+ const createdByNameById = new Map<string, string>()
631
+ await Promise.all(
632
+ uniqueCreatedByIds.map(async (userId) => {
633
+ try {
634
+ const [shopper] = await commerce.masterData.getShopperById({
635
+ userId,
636
+ })
637
+ if (shopper) {
638
+ const fullName = [shopper.firstName, shopper.lastName]
639
+ .filter(Boolean)
640
+ .join(' ')
641
+ if (fullName) createdByNameById.set(userId, fullName)
642
+ }
643
+ } catch {
644
+ // Fall back to the raw id below if the lookup fails
645
+ }
646
+ })
647
+ )
648
+
649
+ const list = result.items.map((quote) => ({
650
+ id: quote.id,
651
+ status: quote.status,
652
+ label: quote.label ?? null,
653
+ createdAt: quote.createdAt,
654
+ expiresAt: quote.expiresAt,
655
+ amount: quote.amount,
656
+ createdBy: quote.createdBy
657
+ ? (createdByNameById.get(quote.createdBy) ?? quote.createdBy)
658
+ : null,
659
+ }))
660
+
661
+ return {
662
+ list,
663
+ paging: {
664
+ total: result.totalItems,
665
+ currentPage: result.pageNumber,
666
+ perPage: result.pageSize,
667
+ },
668
+ }
669
+ },
597
670
  validateUser: async (_: unknown, __: unknown, _ctx: GraphqlContext) => {
598
671
  // Authentication is now handled by @auth directive
599
672
  // If we reach here, validation was successful, otherwise an error would have been thrown
@@ -601,6 +674,19 @@ export const Query = {
601
674
  isValid: true,
602
675
  }
603
676
  },
677
+ isOrganizationMember: async (
678
+ _: unknown,
679
+ __: unknown,
680
+ ctx: GraphqlContext
681
+ ) => {
682
+ const {
683
+ clients: { commerce },
684
+ } = ctx
685
+
686
+ const sessionData = await commerce.session('').catch(() => null)
687
+
688
+ return Boolean(sessionData?.namespaces.authentication?.unitId?.value)
689
+ },
604
690
  // only b2b users
605
691
  userDetails: async (_: unknown, __: unknown, ctx: GraphqlContext) => {
606
692
  const {
@@ -664,9 +750,7 @@ export const Query = {
664
750
  contractId: profile?.id?.value ?? '',
665
751
  })
666
752
 
667
- const name =
668
- contract?.corporateName ??
669
- `${(profile?.firstName?.value ?? '').trim()} ${(profile?.lastName?.value ?? '').trim()}`.trim()
753
+ const name = resolveActiveContractDisplayName(contract, profile)
670
754
 
671
755
  return {
672
756
  name: name || '',
@@ -689,6 +773,55 @@ export const Query = {
689
773
  // createdAt: '',
690
774
  }
691
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
+ },
692
825
  pickupPoints: async (
693
826
  _: unknown,
694
827
  { geoCoordinates }: QueryPickupPointsArgs,
@@ -706,4 +839,5 @@ export const Query = {
706
839
  },
707
840
  orderEntryOperation: getOrderEntryOperation,
708
841
  orderFormItems: getOrderFormItems,
842
+ recommendations,
709
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
+ }