@faststore/api 4.5.0-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.
- package/dist/cjs/index.js +96 -42
- package/dist/cjs/index.js.map +1 -1
- package/dist/es/index.mjs +1267 -1091
- package/dist/es/index.mjs.map +1 -1
- package/dist/src/__generated__/schema.d.ts +40 -0
- package/dist/src/__generated__/schema.d.ts.map +1 -1
- package/dist/src/platforms/vtex/clients/commerce/Contract.d.ts +1 -0
- package/dist/src/platforms/vtex/clients/commerce/Contract.d.ts.map +1 -1
- package/dist/src/platforms/vtex/clients/commerce/index.d.ts +12 -0
- package/dist/src/platforms/vtex/clients/commerce/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/clients/commerce/types/RecommendationResult.d.ts +25 -0
- package/dist/src/platforms/vtex/clients/commerce/types/RecommendationResult.d.ts.map +1 -0
- package/dist/src/platforms/vtex/clients/commerce/types/Session.d.ts +10 -0
- package/dist/src/platforms/vtex/clients/commerce/types/Session.d.ts.map +1 -1
- package/dist/src/platforms/vtex/clients/index.d.ts +4 -0
- package/dist/src/platforms/vtex/clients/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/index.d.ts +7 -0
- package/dist/src/platforms/vtex/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/resolvers/getOrderEntryOperation.d.ts.map +1 -1
- package/dist/src/platforms/vtex/resolvers/index.d.ts +7 -0
- package/dist/src/platforms/vtex/resolvers/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/resolvers/mutation.d.ts +1 -0
- package/dist/src/platforms/vtex/resolvers/mutation.d.ts.map +1 -1
- package/dist/src/platforms/vtex/resolvers/query.d.ts +7 -1
- package/dist/src/platforms/vtex/resolvers/query.d.ts.map +1 -1
- package/dist/src/platforms/vtex/resolvers/recommendations.d.ts +18 -0
- package/dist/src/platforms/vtex/resolvers/recommendations.d.ts.map +1 -0
- package/dist/src/platforms/vtex/resolvers/startRecommendationSession.d.ts +11 -0
- package/dist/src/platforms/vtex/resolvers/startRecommendationSession.d.ts.map +1 -0
- package/dist/src/platforms/vtex/resolvers/validateSession.d.ts.map +1 -1
- package/dist/src/platforms/vtex/utils/contract.d.ts +59 -0
- package/dist/src/platforms/vtex/utils/contract.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__generated__/schema.ts +47 -0
- package/src/platforms/vtex/clients/commerce/Contract.ts +1 -0
- package/src/platforms/vtex/clients/commerce/index.ts +88 -3
- package/src/platforms/vtex/clients/commerce/types/RecommendationResult.ts +27 -0
- package/src/platforms/vtex/clients/commerce/types/Session.ts +9 -0
- package/src/platforms/vtex/resolvers/mutation.ts +2 -0
- package/src/platforms/vtex/resolvers/query.ts +60 -3
- package/src/platforms/vtex/resolvers/recommendations.ts +80 -0
- package/src/platforms/vtex/resolvers/startRecommendationSession.ts +30 -0
- package/src/platforms/vtex/resolvers/validateSession.ts +2 -1
- package/src/platforms/vtex/typeDefs/mutation.graphql +4 -0
- package/src/platforms/vtex/typeDefs/organization.graphql +18 -0
- package/src/platforms/vtex/typeDefs/query.graphql +21 -0
- package/src/platforms/vtex/typeDefs/recommendation.graphql +11 -0
- 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 =
|
|
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
|
|
202
|
+
contractName: resolveActiveContractDisplayName(contract, profile),
|
|
202
203
|
organizationManager: shopper?.organizationManager?.value ?? false,
|
|
203
204
|
}
|
|
204
205
|
: null,
|
|
@@ -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,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
|
+
}
|