Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6a13c8c
chore: lern force-publish release
lemagnetic Jun 1, 2026
e9b2105
fix: fix vtex message
lemagnetic Jun 1, 2026
516e784
[no ci] Release: 4.2.1
lemagnetic Jun 1, 2026
da907bc
chore: revert accidental 4.2.1 release (#3375) [skip ci]
lemagnetic Jun 1, 2026
a0c982a
feat: add B2B contract switcher to the FastStore account drawer
rodrigo-tavares Jun 8, 2026
2fa360f
feat(core): wire contract switcher to real SDK hooks and remove UI mocks
rodrigo-tavares Jun 12, 2026
54bd372
fix: address contract switcher PR review feedback
rodrigo-tavares Jun 23, 2026
ba36ccd
chore: merge dev and add contract switcher SDD spec
rodrigo-tavares Jun 23, 2026
18dfa15
fix(core): resolve Sonar issues in contract switcher
rodrigo-tavares Jun 24, 2026
1dbcbdd
refactor(api): load availableContracts via buyer-portal store-front BFF
rodrigo-tavares Jun 25, 2026
04c5060
feat(core): wire contract switch to session and switch-properties
rodrigo-tavares Jun 25, 2026
8c75f56
chore: merge dev and resolve contract switcher conflicts
rodrigo-tavares Jun 25, 2026
95c634d
fix: correct availableContracts cacheControl and local-only dev config
rodrigo-tavares Jun 26, 2026
c3b4a20
style(api): format availableContracts cacheControl directive
rodrigo-tavares Jun 26, 2026
beb90a7
fix(core): add switchError to ContractSwitcherContentProps
rodrigo-tavares Jun 26, 2026
17aaa9d
fix(core): resolve Sonar issues and raise new-code test coverage
rodrigo-tavares Jun 26, 2026
d9f0430
style(core): compare globalThis.window with undefined directly
rodrigo-tavares Jun 26, 2026
4a9c204
test: raise contract switcher new-code coverage for Sonar gate
rodrigo-tavares Jun 26, 2026
bc6d1f5
fix(core): apply auth cookie from contract switch response
rodrigo-tavares Jun 26, 2026
6072feb
fix(core): address CodeRabbit review for contract switcher
rodrigo-tavares Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/api/src/__generated__/schema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 71 additions & 0 deletions packages/api/src/platforms/vtex/resolvers/query.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import pLimit from 'p-limit'
import type {
ProcessOrderAuthorizationRule,
QueryAllCollectionsArgs,
QueryAllProductsArgs,
QueryAvailableContractsArgs,
QueryCollectionArgs,
QueryListUserOrdersArgs,
QueryPickupPointsArgs,
Expand All @@ -14,6 +16,7 @@ import type {
QuerySellersArgs,
QueryShippingArgs,
QueryUserOrderArgs,
StoreContract,
UserOrderFromList,
} from '../../../__generated__/schema'
import {
Expand Down Expand Up @@ -44,6 +47,9 @@ import { SORT_MAP } from '../utils/sort'
import { FACET_CROSS_SELLING_MAP } from './../utils/facets'
import { StoreCollection } from './collection'

// Caps concurrent MasterData lookups while resolving contract corporate names.
const CONCURRENT_CONTRACT_REQUESTS_MAX = 5

export const Query = {
product: async (
_: unknown,
Expand Down Expand Up @@ -656,6 +662,71 @@ export const Query = {
// createdAt: '',
}
},
// only b2b users
// Lists the contracts associated with the buyer's Organization Unit, resolved to
// human-readable corporate names. The list is governed: it is derived from the
// Org Unit's own scopes, so only contracts the unit is associated with are returned.
availableContracts: async (
_: unknown,
{ orgUnitId }: QueryAvailableContractsArgs,
ctx: GraphqlContext
): Promise<StoreContract[]> => {
if (!orgUnitId) {
throw new BadRequestError('Missing orgUnitId')
}

const {
clients: { commerce },
} = ctx

// 1. Governed source: the contract IDs associated with this Org Unit.
const scopesByUnit = await commerce.units.getScopesByOrgUnit({ orgUnitId })

const contractIds = Array.from(
new Set((scopesByUnit?.scopes ?? []).flatMap((scope) => scope?.ids ?? []))
).filter(Boolean)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (contractIds.length === 0) {
return []
}

// 2. The currently active contract id. For B2B representatives the active
// contract is exposed as the session profile id (same mapping used by
// `accountProfile`). Failures here only affect the `isActive` flag.
const sessionData = await commerce.session('').catch(() => null)
const activeContractId = sessionData?.namespaces.profile?.id?.value ?? ''

// 3. Resolve each contract id to its corporate name. Per-contract failures are
// isolated so a single bad lookup never breaks the whole list.
const limit = pLimit(CONCURRENT_CONTRACT_REQUESTS_MAX)
const resolved = await Promise.all(
contractIds.map((contractId) =>
limit(async (): Promise<StoreContract | null> => {
try {
const contract = await commerce.masterData.getContractById({
contractId,
})

if (!contract?.corporateName) {
return null
}

return {
id: contractId,
corporateName: contract.corporateName,
isActive: contractId === activeContractId,
}
} catch {
return null
}
})
)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return resolved.filter((contract): contract is StoreContract =>
Boolean(contract)
)
},
pickupPoints: async (
_: unknown,
{ geoCoordinates }: QueryPickupPointsArgs,
Expand Down
18 changes: 18 additions & 0 deletions packages/api/src/platforms/vtex/typeDefs/organization.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,21 @@ input IStoreOrganization {
"""
identifier: String!
}

"""
A commercial contract available to a buyer's Organization Unit.
"""
type StoreContract {
"""
Contract identifier (the contract/scope ID associated with the Organization Unit).
"""
id: ID!
"""
Human-readable corporate name of the contract (resolved from MasterData).
"""
corporateName: String!
"""
Indicates whether this contract is the one currently active in the session.
"""
isActive: Boolean!
}
14 changes: 14 additions & 0 deletions packages/api/src/platforms/vtex/typeDefs/query.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,20 @@ type Query {
@auth
@cacheControl(scope: "private", sMaxAge: 300, staleWhileRevalidate: 3600)

"""
Lists the commercial contracts associated with the given Organization Unit,
resolved to human-readable corporate names. Governed: only contracts associated
with the authenticated buyer's Organization Unit are returned.
"""
availableContracts(
"""
The Organization Unit identifier whose contracts will be listed.
"""
orgUnitId: String!
): [StoreContract!]!
@auth
@cacheControl(scope: "private", sMaxAge: 300, staleWhileRevalidate: 3600)

"""
Returns a list of pickup points near to the given geo coordinates.
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { Query } from '../../../../../src/platforms/vtex/resolvers/query'

const getScopesByOrgUnit = vi.fn()
const getContractById = vi.fn()
const session = vi.fn()

const makeCtx = () =>
({
clients: {
commerce: {
units: { getScopesByOrgUnit },
masterData: { getContractById },
session,
},
},
}) as any

const availableContracts = (Query as any).availableContracts

beforeEach(() => {
vi.clearAllMocks()
// Default: active contract id resolves to "b" via the session profile id.
session.mockResolvedValue({
namespaces: { profile: { id: { value: 'b' } } },
})
})

describe('Query.availableContracts', () => {
it('throws when orgUnitId is missing', async () => {
await expect(
availableContracts(null, { orgUnitId: '' }, makeCtx())
).rejects.toThrow(/orgUnitId/i)
expect(getScopesByOrgUnit).not.toHaveBeenCalled()
})

it('lists the Org Unit contracts by corporate name and flags the active one', async () => {
getScopesByOrgUnit.mockResolvedValue({
organizationUnitId: 'unit-1',
scopes: [{ scope: 'contract', ids: ['a', 'b', 'c'] }],
})
getContractById.mockImplementation(
({ contractId }: { contractId: string }) =>
Promise.resolve({ corporateName: `Corp ${contractId.toUpperCase()}` })
)

const result = await availableContracts(
null,
{ orgUnitId: 'unit-1' },
makeCtx()
)

expect(getScopesByOrgUnit).toHaveBeenCalledWith({ orgUnitId: 'unit-1' })
// Governance + name resolution: corporate names, never raw IDs.
expect(result).toEqual([
{ id: 'a', corporateName: 'Corp A', isActive: false },
{ id: 'b', corporateName: 'Corp B', isActive: true },
{ id: 'c', corporateName: 'Corp C', isActive: false },
])
})

it('returns an empty list when the Org Unit has no contracts', async () => {
getScopesByOrgUnit.mockResolvedValue({
organizationUnitId: 'unit-1',
scopes: [],
})

const result = await availableContracts(
null,
{ orgUnitId: 'unit-1' },
makeCtx()
)

expect(result).toEqual([])
expect(getContractById).not.toHaveBeenCalled()
})

it('isolates per-contract resolution failures', async () => {
getScopesByOrgUnit.mockResolvedValue({
organizationUnitId: 'unit-1',
scopes: [{ scope: 'contract', ids: ['a', 'b'] }],
})
getContractById.mockImplementation(
({ contractId }: { contractId: string }) =>
contractId === 'a'
? Promise.reject(new Error('MasterData down'))
: Promise.resolve({ corporateName: 'Corp B' })
)

const result = await availableContracts(
null,
{ orgUnitId: 'unit-1' },
makeCtx()
)

expect(result).toEqual([
{ id: 'b', corporateName: 'Corp B', isActive: true },
])
})

it('skips contracts that have no corporate name', async () => {
getScopesByOrgUnit.mockResolvedValue({
organizationUnitId: 'unit-1',
scopes: [{ scope: 'contract', ids: ['a', 'b'] }],
})
getContractById.mockImplementation(
({ contractId }: { contractId: string }) =>
contractId === 'a'
? Promise.resolve({ corporateName: '' })
: Promise.resolve({ corporateName: 'Corp B' })
)

const result = await availableContracts(
null,
{ orgUnitId: 'unit-1' },
makeCtx()
)

expect(result).toEqual([
{ id: 'b', corporateName: 'Corp B', isActive: true },
])
})

it('deduplicates contract ids across scopes', async () => {
getScopesByOrgUnit.mockResolvedValue({
organizationUnitId: 'unit-1',
scopes: [
{ scope: 'contract', ids: ['a', 'b'] },
{ scope: 'price', ids: ['b'] },
],
})
getContractById.mockImplementation(
({ contractId }: { contractId: string }) =>
Promise.resolve({ corporateName: `Corp ${contractId.toUpperCase()}` })
)

const result = await availableContracts(
null,
{ orgUnitId: 'unit-1' },
makeCtx()
)

expect(result).toHaveLength(2)
expect(getContractById).toHaveBeenCalledTimes(2)
})
})
6 changes: 6 additions & 0 deletions packages/core/@generated/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Documents = {
"\n query ServerProfileQuery {\n accountProfile {\n name\n email\n id\n }\n }\n": typeof types.ServerProfileQueryDocument,
"\n query ServerSecurity {\n accountProfile {\n name\n }\n userDetails {\n email\n }\n }\n": typeof types.ServerSecurityDocument,
"\n query ServerUserDetailsQuery {\n accountProfile {\n name\n }\n userDetails {\n username\n name\n email\n phone\n role\n orgUnit\n }\n }\n": typeof types.ServerUserDetailsQueryDocument,
"\n query AvailableContractsQuery($orgUnitId: String!) {\n availableContracts(orgUnitId: $orgUnitId) {\n id\n corporateName\n isActive\n }\n }\n": typeof types.AvailableContractsQueryDocument,
"\n mutation CancelOrderMutation($data: IUserOrderCancel!) {\n cancelOrder(data: $data) {\n data\n }\n }\n": typeof types.CancelOrderMutationDocument,
"\n mutation ProcessOrderAuthorizationMutation($data: IProcessOrderAuthorization!) {\n processOrderAuthorization(data: $data) {\n isPendingForOtherAuthorizer\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n }\n }\n": typeof types.ProcessOrderAuthorizationMutationDocument,
"\n query ValidateUser {\n validateUser {\n isValid\n }\n }\n": typeof types.ValidateUserDocument,
Expand Down Expand Up @@ -80,6 +81,7 @@ const documents: Documents = {
"\n query ServerProfileQuery {\n accountProfile {\n name\n email\n id\n }\n }\n": types.ServerProfileQueryDocument,
"\n query ServerSecurity {\n accountProfile {\n name\n }\n userDetails {\n email\n }\n }\n": types.ServerSecurityDocument,
"\n query ServerUserDetailsQuery {\n accountProfile {\n name\n }\n userDetails {\n username\n name\n email\n phone\n role\n orgUnit\n }\n }\n": types.ServerUserDetailsQueryDocument,
"\n query AvailableContractsQuery($orgUnitId: String!) {\n availableContracts(orgUnitId: $orgUnitId) {\n id\n corporateName\n isActive\n }\n }\n": types.AvailableContractsQueryDocument,
"\n mutation CancelOrderMutation($data: IUserOrderCancel!) {\n cancelOrder(data: $data) {\n data\n }\n }\n": types.CancelOrderMutationDocument,
"\n mutation ProcessOrderAuthorizationMutation($data: IProcessOrderAuthorization!) {\n processOrderAuthorization(data: $data) {\n isPendingForOtherAuthorizer\n ruleForAuthorization {\n orderAuthorizationId\n dimensionId\n rule {\n id\n name\n status\n doId\n authorizedEmails\n priority\n trigger {\n condition {\n conditionType\n description\n lessThan\n greatherThan\n expression\n }\n effect {\n description\n effectType\n funcPath\n }\n }\n timeout\n notification\n scoreInterval {\n accept\n deny\n }\n authorizationData {\n requireAllApprovals\n authorizers {\n id\n email\n type\n authorizationDate\n }\n }\n isUserAuthorized\n isUserNextAuthorizer\n }\n }\n }\n }\n": types.ProcessOrderAuthorizationMutationDocument,
"\n query ValidateUser {\n validateUser {\n isValid\n }\n }\n": types.ValidateUserDocument,
Expand Down Expand Up @@ -189,6 +191,10 @@ export function gql(source: "\n query ServerSecurity {\n accountProfile {\n
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(source: "\n query ServerUserDetailsQuery {\n accountProfile {\n name\n }\n userDetails {\n username\n name\n email\n phone\n role\n orgUnit\n }\n }\n"): typeof import('./graphql').ServerUserDetailsQueryDocument;
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(source: "\n query AvailableContractsQuery($orgUnitId: String!) {\n availableContracts(orgUnitId: $orgUnitId) {\n id\n corporateName\n isActive\n }\n }\n"): typeof import('./graphql').AvailableContractsQueryDocument;
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand Down
Loading
Loading