@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.
- package/dist/cjs/index.js +207 -41
- package/dist/cjs/index.js.map +1 -1
- package/dist/es/index.mjs +1545 -1138
- package/dist/es/index.mjs.map +1 -1
- package/dist/src/__generated__/schema.d.ts +90 -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 +16 -0
- package/dist/src/platforms/vtex/clients/commerce/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/clients/commerce/types/Quote.d.ts +26 -0
- package/dist/src/platforms/vtex/clients/commerce/types/Quote.d.ts.map +1 -0
- 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 +7 -0
- package/dist/src/platforms/vtex/clients/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/index.d.ts +33 -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 +33 -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 +33 -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 +102 -0
- package/src/platforms/vtex/clients/commerce/Contract.ts +1 -0
- package/src/platforms/vtex/clients/commerce/index.ts +188 -3
- package/src/platforms/vtex/clients/commerce/types/Quote.ts +27 -0
- 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 +137 -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 +68 -0
- package/src/platforms/vtex/typeDefs/quote.graphql +65 -0
- package/src/platforms/vtex/typeDefs/recommendation.graphql +11 -0
- 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 =
|
|
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
|
|
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
|
+
}
|