@faststore/api 4.10.1 → 4.10.2-dev.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.
Files changed (43) hide show
  1. package/dist/cjs/index.js +49 -46
  2. package/dist/cjs/index.js.map +1 -1
  3. package/dist/es/index.mjs +1160 -1030
  4. package/dist/es/index.mjs.map +1 -1
  5. package/dist/src/__generated__/schema.d.ts +1 -0
  6. package/dist/src/__generated__/schema.d.ts.map +1 -1
  7. package/dist/src/platforms/vtex/clients/commerce/index.d.ts +16 -2
  8. package/dist/src/platforms/vtex/clients/commerce/index.d.ts.map +1 -1
  9. package/dist/src/platforms/vtex/clients/fetch.d.ts +1 -0
  10. package/dist/src/platforms/vtex/clients/fetch.d.ts.map +1 -1
  11. package/dist/src/platforms/vtex/clients/index.d.ts +3 -3
  12. package/dist/src/platforms/vtex/clients/index.d.ts.map +1 -1
  13. package/dist/src/platforms/vtex/clients/search/index.d.ts +7 -1
  14. package/dist/src/platforms/vtex/clients/search/index.d.ts.map +1 -1
  15. package/dist/src/platforms/vtex/index.d.ts +1 -1
  16. package/dist/src/platforms/vtex/index.d.ts.map +1 -1
  17. package/dist/src/platforms/vtex/loaders/sku.d.ts.map +1 -1
  18. package/dist/src/platforms/vtex/resolvers/getOrderEntryOperation.d.ts.map +1 -1
  19. package/dist/src/platforms/vtex/resolvers/index.d.ts +1 -1
  20. package/dist/src/platforms/vtex/resolvers/index.d.ts.map +1 -1
  21. package/dist/src/platforms/vtex/resolvers/query.d.ts +1 -1
  22. package/dist/src/platforms/vtex/resolvers/query.d.ts.map +1 -1
  23. package/dist/src/platforms/vtex/resolvers/subscribeToNewsletter.d.ts.map +1 -1
  24. package/dist/src/platforms/vtex/resolvers/validateSession.d.ts.map +1 -1
  25. package/dist/src/platforms/vtex/utils/contractAssortment.d.ts +22 -0
  26. package/dist/src/platforms/vtex/utils/contractAssortment.d.ts.map +1 -0
  27. package/dist/src/platforms/vtex/utils/intelligentSearchRequest.d.ts +5 -0
  28. package/dist/src/platforms/vtex/utils/intelligentSearchRequest.d.ts.map +1 -1
  29. package/dist/src/platforms/vtex/utils/validateSessionHelpers.d.ts +29 -2
  30. package/dist/src/platforms/vtex/utils/validateSessionHelpers.d.ts.map +1 -1
  31. package/package.json +3 -3
  32. package/src/__generated__/schema.ts +1 -0
  33. package/src/platforms/vtex/clients/commerce/index.ts +80 -3
  34. package/src/platforms/vtex/clients/fetch.ts +52 -1
  35. package/src/platforms/vtex/clients/search/index.ts +13 -1
  36. package/src/platforms/vtex/loaders/sku.ts +1 -0
  37. package/src/platforms/vtex/resolvers/query.ts +51 -37
  38. package/src/platforms/vtex/resolvers/subscribeToNewsletter.ts +28 -1
  39. package/src/platforms/vtex/resolvers/validateSession.ts +3 -3
  40. package/src/platforms/vtex/typeDefs/session.graphql +3 -0
  41. package/src/platforms/vtex/utils/contractAssortment.ts +87 -0
  42. package/src/platforms/vtex/utils/intelligentSearchRequest.ts +10 -0
  43. package/src/platforms/vtex/utils/validateSessionHelpers.ts +47 -5
@@ -44,6 +44,7 @@ import {
44
44
  resolveActiveContractIdFromSession,
45
45
  resolveDefaultContractId,
46
46
  } from '../utils/contract'
47
+ import { assertProductInContractAssortment } from '../utils/contractAssortment'
47
48
  import { mutateChannelContext, mutateLocaleContext } from '../utils/contex'
48
49
  import { getAuthCookie, parseJwt } from '../utils/cookies'
49
50
  import { enhanceSku, type EnhancedSku } from '../utils/enhanceSku'
@@ -59,6 +60,10 @@ import { getCatalogLocale, isLocalizationEnabled } from '../utils/localization'
59
60
  import { isValidSkuId, pickBestSku } from '../utils/sku'
60
61
  import { slugify } from '../utils/slugify'
61
62
  import { resolveSort } from '../utils/sort'
63
+ import {
64
+ firstNonBlank,
65
+ resolveB2bUnitId,
66
+ } from '../utils/validateSessionHelpers'
62
67
  import { FACET_CROSS_SELLING_MAP } from './../utils/facets'
63
68
  import { StoreCollection } from './collection'
64
69
  import { getOrderEntryOperation } from './getOrderEntryOperation'
@@ -202,25 +207,35 @@ export const Query = {
202
207
  loaders: { skuLoader },
203
208
  } = ctx
204
209
 
205
- try {
206
- const skuId = id ?? slug?.split('-').pop() ?? ''
210
+ const loadSku = async () => {
211
+ try {
212
+ const skuId = id ?? slug?.split('-').pop() ?? ''
207
213
 
208
- if (!isValidSkuId(skuId)) {
209
- throw new Error(INVALID_SKU_ID_ERROR)
210
- }
214
+ if (!isValidSkuId(skuId)) {
215
+ throw new Error(INVALID_SKU_ID_ERROR)
216
+ }
211
217
 
212
- const sku = await skuLoader.load(skuId)
218
+ const sku = await skuLoader.load(skuId)
213
219
 
214
- await assertSkuMatchesSlug(ctx, sku, slug, locale)
220
+ await assertSkuMatchesSlug(ctx, sku, slug, locale)
215
221
 
216
- return sku
217
- } catch (err) {
218
- if (!shouldFallbackToProductRoute(err)) {
219
- throw err
220
- }
222
+ return sku
223
+ } catch (err) {
224
+ if (!shouldFallbackToProductRoute(err)) {
225
+ throw err
226
+ }
221
227
 
222
- return fetchProductBySlugFallback(ctx, slug)
228
+ return fetchProductBySlugFallback(ctx, slug)
229
+ }
223
230
  }
231
+
232
+ const sku = await loadSku()
233
+
234
+ // Runs outside `loadSku` so its NotFoundError is not swallowed by the
235
+ // slug fallback, which would refetch and return the same product.
236
+ assertProductInContractAssortment(ctx, sku)
237
+
238
+ return sku
224
239
  },
225
240
  collection: (
226
241
  _: unknown,
@@ -871,8 +886,8 @@ export const Query = {
871
886
  orgUnit: authentication?.unitName?.value ?? '',
872
887
  }
873
888
  },
874
- // If isRepresentative, return b2b information.
875
- // If not, return b2c user information
889
+ // Buyers with an organizational unit and a contract get the contract
890
+ // information. Everyone else gets the b2c user information.
876
891
  accountProfile: async (_: unknown, __: unknown, ctx: GraphqlContext) => {
877
892
  const {
878
893
  account,
@@ -886,7 +901,7 @@ export const Query = {
886
901
  return null
887
902
  }
888
903
 
889
- if (jwt?.isRepresentative) {
904
+ if (resolveB2bUnitId(null, jwt.unitId)) {
890
905
  const sessionData = await commerce.session('').catch(() => null)
891
906
 
892
907
  if (!sessionData) {
@@ -897,13 +912,14 @@ export const Query = {
897
912
  const shopper = sessionData.namespaces.shopper ?? null
898
913
  const authentication = sessionData.namespaces.authentication ?? null
899
914
 
900
- const contractId =
901
- resolveActiveContractIdFromSession(sessionData) ||
902
- jwt?.customerId?.trim() ||
903
- ''
915
+ const contractId = firstNonBlank(
916
+ shopper?.activeContractId?.value,
917
+ authentication?.customerId?.value,
918
+ jwt.customerId
919
+ )
904
920
 
905
- let contract = null
906
921
  if (contractId) {
922
+ let contract = null
907
923
  try {
908
924
  contract = await commerce.masterData.getContractById({ contractId })
909
925
  } catch (err) {
@@ -912,23 +928,21 @@ export const Query = {
912
928
  err
913
929
  )
914
930
  }
915
- }
916
-
917
- const shopperName =
918
- `${(shopper?.firstName?.value ?? '').trim()} ${(shopper?.lastName?.value ?? '').trim()}`.trim()
919
- const name =
920
- resolveActiveContractDisplayName(contract, profile) || shopperName
921
931
 
922
- return {
923
- name: name || '',
924
- email:
925
- profile?.email?.value || authentication?.storeUserEmail?.value || '',
926
- id:
927
- profile?.id?.value ||
928
- authentication?.customerId?.value ||
929
- jwt?.customerId?.trim() ||
930
- '',
931
- // createdAt: '',
932
+ const shopperName =
933
+ `${(shopper?.firstName?.value ?? '').trim()} ${(shopper?.lastName?.value ?? '').trim()}`.trim()
934
+ const name =
935
+ resolveActiveContractDisplayName(contract, profile) || shopperName
936
+
937
+ return {
938
+ name: name || '',
939
+ email:
940
+ profile?.email?.value ||
941
+ authentication?.storeUserEmail?.value ||
942
+ '',
943
+ id: profile?.id?.value || contractId,
944
+ // createdAt: '',
945
+ }
932
946
  }
933
947
  }
934
948
 
@@ -3,12 +3,39 @@ import type {
3
3
  MutationSubscribeToNewsletterArgs,
4
4
  PersonNewsletter,
5
5
  } from '../../../__generated__/schema'
6
+ import { BadRequestError } from '../../errors'
7
+
8
+ const EMAIL_MAX_LENGTH = 254
9
+ const NAME_MAX_LENGTH = 100
10
+ // Character classes exclude `.`, so each domain label is matched once and the
11
+ // pattern stays linear on long inputs.
12
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
6
13
 
7
14
  export const subscribeToNewsletter = async (
8
15
  _: any,
9
16
  { data }: MutationSubscribeToNewsletterArgs,
10
17
  { clients: { commerce } }: GraphqlContext
11
18
  ): Promise<PersonNewsletter | null> => {
12
- const response = await commerce.subscribeToNewsletter(data)
19
+ // The subscription is written with a store credential, so only validated
20
+ // values reach Master Data. See "Create-only writes from anonymous visitors"
21
+ // in https://developers.vtex.com/docs/guides/faststore/api-extensions-authentication-in-api-extensions
22
+ const email = data.email?.trim().toLowerCase() ?? ''
23
+ const name = data.name?.trim() ?? ''
24
+
25
+ if (email.length > EMAIL_MAX_LENGTH || !EMAIL_PATTERN.test(email)) {
26
+ throw new BadRequestError('Invalid email')
27
+ }
28
+
29
+ if (name.length > NAME_MAX_LENGTH) {
30
+ throw new BadRequestError(
31
+ `Invalid name: must have at most ${NAME_MAX_LENGTH} characters`
32
+ )
33
+ }
34
+
35
+ const response = await commerce.subscribeToNewsletter({
36
+ email,
37
+ name: name || undefined,
38
+ })
39
+
13
40
  return { id: response?.Id }
14
41
  }
@@ -22,6 +22,7 @@ import {
22
22
  buildPersonFromProfile,
23
23
  buildSessionSearchParams,
24
24
  getPreciseLocationData,
25
+ resolveB2bUnitId,
25
26
  resolveJwtClaims,
26
27
  resolveSellerInRegion,
27
28
  } from '../utils/validateSessionHelpers'
@@ -187,7 +188,7 @@ export const validateSession = async (
187
188
  oldSession.locale
188
189
  )
189
190
  const marketingData = buildMarketingData(params, oldSession.marketingData)
190
- const { isRepresentative, customerId, unitId } = await resolveJwtClaims(
191
+ const { customerId, unitId } = await resolveJwtClaims(
191
192
  clients,
192
193
  headers?.cookie,
193
194
  account
@@ -211,7 +212,7 @@ export const validateSession = async (
211
212
  const publicData = sessionData?.namespaces.public ?? null
212
213
 
213
214
  let contract = null
214
- if (isRepresentative && profile?.id?.value) {
215
+ if (resolveB2bUnitId(authentication, unitId) && profile?.id?.value) {
215
216
  try {
216
217
  contract = await clients.commerce.masterData.getContractById({
217
218
  contractId: profile.id.value,
@@ -262,7 +263,6 @@ export const validateSession = async (
262
263
  * - Contract data: `profile` namespace (those info will be available inside Faststore's Session `person` object)
263
264
  */
264
265
  b2b: buildB2bSession({
265
- isRepresentative,
266
266
  authentication,
267
267
  shopper,
268
268
  publicData,
@@ -191,6 +191,9 @@ type StoreSession {
191
191
  type StoreB2B {
192
192
  customerId: String!
193
193
  isRepresentative: Boolean
194
+ @deprecated(
195
+ reason: "Always true when `b2b` is set: a B2B session is identified by `unitId` and `customerId`. Check that `b2b` is not null instead. Scheduled for removal in the next major."
196
+ )
194
197
  unitName: String
195
198
  unitId: String
196
199
  firstName: String
@@ -0,0 +1,87 @@
1
+ import { NotFoundError } from '../../errors'
2
+ import type { ProductCluster } from '../clients/search/types/ProductSearchResult'
3
+ import type { GraphqlContext } from '../index'
4
+ import type { EnhancedSku } from './enhanceSku'
5
+ import {
6
+ getSegmentExtraFacets,
7
+ parseSegmentCookie,
8
+ } from './intelligentSearchRequest'
9
+
10
+ const PRODUCT_CLUSTER_IDS_KEY = 'productClusterIds'
11
+ const EXCLUDED_PREFIX = 'not:'
12
+
13
+ export interface ProductClusterRestriction {
14
+ allowed: Set<string>
15
+ excluded: Set<string>
16
+ }
17
+
18
+ /**
19
+ * Collections the shopper's assortment is restricted to, as carried by the
20
+ * `vtex_segment` cookie (`productClusterIds=X` allows, `productClusterIds=not:Y`
21
+ * excludes). Returns `null` when the segment carries no restriction.
22
+ */
23
+ export function getProductClusterRestriction(
24
+ segment: Record<string, unknown>
25
+ ): ProductClusterRestriction | null {
26
+ const allowed = new Set<string>()
27
+ const excluded = new Set<string>()
28
+
29
+ for (const { key, value } of getSegmentExtraFacets(segment)) {
30
+ if (key !== PRODUCT_CLUSTER_IDS_KEY) continue
31
+
32
+ for (const rawId of value.split(',')) {
33
+ const id = rawId.trim()
34
+
35
+ if (id.startsWith(EXCLUDED_PREFIX)) {
36
+ const excludedId = id.slice(EXCLUDED_PREFIX.length)
37
+ if (excludedId) excluded.add(excludedId)
38
+ } else if (id) {
39
+ allowed.add(id)
40
+ }
41
+ }
42
+ }
43
+
44
+ if (allowed.size === 0 && excluded.size === 0) {
45
+ return null
46
+ }
47
+
48
+ return { allowed, excluded }
49
+ }
50
+
51
+ export function isProductInAssortment(
52
+ productClusters: ProductCluster[] | undefined,
53
+ { allowed, excluded }: ProductClusterRestriction
54
+ ): boolean {
55
+ const clusterIds = (productClusters ?? []).map(({ id }) => String(id))
56
+
57
+ if (clusterIds.some((id) => excluded.has(id))) {
58
+ return false
59
+ }
60
+
61
+ return allowed.size === 0 || clusterIds.some((id) => allowed.has(id))
62
+ }
63
+
64
+ /**
65
+ * Listing endpoints get the assortment applied by Intelligent Search through
66
+ * the segment facets, but the `products` endpoint used to fetch a single
67
+ * product does not, so a product outside the shopper's assortment must be
68
+ * rejected here.
69
+ */
70
+ export function assertProductInContractAssortment(
71
+ ctx: GraphqlContext,
72
+ sku: EnhancedSku
73
+ ): void {
74
+ const restriction = getProductClusterRestriction(
75
+ parseSegmentCookie(ctx.headers?.cookie)
76
+ )
77
+
78
+ if (!restriction) {
79
+ return
80
+ }
81
+
82
+ if (!isProductInAssortment(sku.isVariantOf.productClusters, restriction)) {
83
+ throw new NotFoundError(
84
+ `Product ${sku.isVariantOf.productId} is not in the shopper's assortment`
85
+ )
86
+ }
87
+ }
@@ -225,6 +225,16 @@ function extractSegmentData(segment: Record<string, unknown>): {
225
225
  }
226
226
  }
227
227
 
228
+ /**
229
+ * Non-shipping facets carried by the `vtex_segment` cookie (e.g. the B2B
230
+ * contract's `productClusterIds`), in the order they appear in the cookie.
231
+ */
232
+ export function getSegmentExtraFacets(
233
+ segment: Record<string, unknown>
234
+ ): IntelligentSearchFacet[] {
235
+ return extractSegmentData(segment).extraFacets
236
+ }
237
+
228
238
  function appendSegmentParams(
229
239
  params: URLSearchParams,
230
240
  segmentParams: SegmentParams
@@ -107,7 +107,6 @@ export async function resolveJwtClaims(
107
107
  }
108
108
 
109
109
  return {
110
- isRepresentative: isValidJwt ? Boolean(jwt?.isRepresentative) : false,
111
110
  customerId: isValidJwt ? jwt?.customerId : undefined,
112
111
  unitId: isValidJwt ? jwt?.unitId : undefined,
113
112
  }
@@ -140,8 +139,43 @@ function shopperString(value: { value?: unknown } | null | undefined): string {
140
139
  return typeof value?.value === 'string' ? value.value : ''
141
140
  }
142
141
 
142
+ /**
143
+ * First value that is a non-blank string, trimmed. Session values and JWT
144
+ * claims are only nominally typed, so anything else is skipped.
145
+ */
146
+ export function firstNonBlank(...values: unknown[]): string {
147
+ for (const value of values) {
148
+ const trimmed = typeof value === 'string' ? value.trim() : ''
149
+ if (trimmed) return trimmed
150
+ }
151
+
152
+ return ''
153
+ }
154
+
155
+ /**
156
+ * Organizational unit of the shopper, from the session or the JWT claim.
157
+ * Buyer Portal identifies its buyers by the unit; `isRepresentative` alone is
158
+ * not enough, since it can be true outside B2B.
159
+ */
160
+ export function resolveB2bUnitId(
161
+ authentication: { unitId?: { value?: string | null } | null } | null,
162
+ jwtUnitId?: unknown
163
+ ): string {
164
+ return firstNonBlank(authentication?.unitId?.value, jwtUnitId)
165
+ }
166
+
167
+ /**
168
+ * Returns `null` unless the shopper has both an organizational unit and a
169
+ * contract, the rule Buyer Portal uses for a B2B session.
170
+ *
171
+ * The contract falls back from the session's active contract to the session's
172
+ * authentication namespace and then to the JWT claim. Blank values fall
173
+ * through, so an empty session value never hides the claim.
174
+ *
175
+ * The deprecated `isRepresentative` field stays `true` whenever `b2b` is
176
+ * returned, so consumers that still read it keep working.
177
+ */
143
178
  export function buildB2bSession(args: {
144
- isRepresentative: boolean
145
179
  authentication: {
146
180
  customerId?: { value?: string | null } | null
147
181
  unitName?: { value?: string | null } | null
@@ -152,6 +186,7 @@ export function buildB2bSession(args: {
152
186
  firstName?: { value?: unknown } | null
153
187
  lastName?: { value?: unknown } | null
154
188
  organizationManager?: { value?: boolean | null } | null
189
+ activeContractId?: { value?: string | null } | null
155
190
  } | null
156
191
  publicData: { postalCode?: { value?: string | null } | null } | null
157
192
  profile: Parameters<typeof resolveActiveContractDisplayName>[1]
@@ -159,7 +194,14 @@ export function buildB2bSession(args: {
159
194
  customerId?: string
160
195
  unitId?: string
161
196
  }) {
162
- if (!args.isRepresentative) {
197
+ const unitId = resolveB2bUnitId(args.authentication, args.unitId)
198
+ const customerId = firstNonBlank(
199
+ args.shopper?.activeContractId?.value,
200
+ args.authentication?.customerId?.value,
201
+ args.customerId
202
+ )
203
+
204
+ if (!unitId || !customerId) {
163
205
  return null
164
206
  }
165
207
 
@@ -168,9 +210,9 @@ export function buildB2bSession(args: {
168
210
 
169
211
  return {
170
212
  isRepresentative: true,
171
- customerId: args.authentication?.customerId?.value ?? args.customerId ?? '',
213
+ customerId,
172
214
  unitName: args.authentication?.unitName?.value ?? '',
173
- unitId: args.authentication?.unitId?.value ?? args.unitId ?? '',
215
+ unitId,
174
216
  firstName,
175
217
  lastName,
176
218
  userName: `${firstName} ${lastName}`.trim(),