diff --git a/CHANGELOG.md b/CHANGELOG.md index 97fa3c3..58df8ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,34 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add field quotesManagedBy on appSettings to handle splitting quotes +- Process splitting quote by seller if it accepts to manage quotes +- Notify seller with quote reference data as payload + ## [2.6.4] - 2024-10-31 ### Fixed + - Only update Status, LastUpdate and UpdateHistory, in expired quotes ## [2.6.3] - 2024-10-30 ### Fixed + - Set viewedByCustomer value False when value is null ## [2.6.2] - 2024-10-02 ### Added + - Add audit access metrics to all graphql APIs ## [2.6.1] - 2024-09-09 ### Fixed + - Set viewedByCustomer value corectly on quote creation ## [2.6.0] - 2024-09-04 ### Added + - Add getQuoteEnabledForUser query to be used by the b2b-quotes app ## [2.5.4] - 2024-08-20 ### Fixed + - Use listUsersPaginated internally instead of deprecated listUsers ## [2.5.3] - 2024-06-10 @@ -76,15 +88,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.3.1] - 2023-09-13 ### Fixed + - Use the account to get the token in the header and send it to clear the cart and order ## [2.3.0] - 2023-08-14 ### Added + - Send metrics to Analytics (Create Quote and Send Message events) -- Send use quote metrics to Analytics - +- Send use quote metrics to Analytics + ### Removed + - [ENGINEERS-1247] - Disable cypress tests in PR level ### Changed diff --git a/graphql/appSettings.graphql b/graphql/appSettings.graphql index 5757cf7..c6e146c 100644 --- a/graphql/appSettings.graphql +++ b/graphql/appSettings.graphql @@ -3,7 +3,13 @@ type AppSettings { } type AdminSetup { cartLifeSpan: Int + quotesManagedBy: QuotesManagedBy! } input AppSettingsInput { cartLifeSpan: Int + quotesManagedBy: QuotesManagedBy } +enum QuotesManagedBy { + MARKETPLACE + SELLER +} \ No newline at end of file diff --git a/graphql/quote.graphql b/graphql/quote.graphql index 4ce5057..1fa53b9 100644 --- a/graphql/quote.graphql +++ b/graphql/quote.graphql @@ -59,6 +59,9 @@ type Quote { viewedBySales: Boolean viewedByCustomer: Boolean salesChannel: String + seller: String + parentQuote: String + hasChildren: Boolean } type QuoteUpdate { diff --git a/node/clients/SellerQuotesClient.ts b/node/clients/SellerQuotesClient.ts new file mode 100644 index 0000000..74bc681 --- /dev/null +++ b/node/clients/SellerQuotesClient.ts @@ -0,0 +1,62 @@ +import type { InstanceOptions, IOContext } from '@vtex/api' +import { ExternalClient } from '@vtex/api' + +const SELLER_CLIENT_OPTIONS: InstanceOptions = { + retries: 5, + timeout: 5000, + exponentialTimeoutCoefficient: 2, + exponentialBackoffCoefficient: 2, + initialBackoffDelay: 100, +} + +const BASE_PATH = 'b2b-seller-quotes/_v/0' + +const ROUTES = { + verifyQuoteSettings: 'verify-quote-settings', + notifyNewQuote: 'notify-new-quote', +} + +export default class SellerQuotesClient extends ExternalClient { + constructor(ctx: IOContext, options?: InstanceOptions) { + super('', ctx, { + ...options, + ...SELLER_CLIENT_OPTIONS, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + VtexIdclientAutCookie: ctx.authToken, + }, + }) + } + + private getRoute(sellerAccount: string, path: string) { + const subdomain = this.context.production + ? sellerAccount + : `${this.context.workspace}--${sellerAccount}` + + return `http://${subdomain}.myvtex.com/${BASE_PATH}/${path}` + } + + public async verifyQuoteSettings(sellerAccount: string) { + return this.http.get( + this.getRoute(sellerAccount, ROUTES.verifyQuoteSettings) + ) + } + + public async notifyNewQuote( + sellerAccount: string, + quoteId: string, + creationDate: string + ) { + const payload: SellerQuoteNotifyInput = { + quoteId, + creationDate, + marketplaceAccount: this.context.account, + } + + return this.http.postRaw( + this.getRoute(sellerAccount, ROUTES.notifyNewQuote), + payload + ) + } +} diff --git a/node/clients/index.ts b/node/clients/index.ts index c872deb..b8af4a6 100644 --- a/node/clients/index.ts +++ b/node/clients/index.ts @@ -13,6 +13,7 @@ import OrdersClient from './OrdersClient' import Organizations from './organizations' import StorefrontPermissions from './storefrontPermissions' import VtexId from './vtexId' +import SellerQuotesClient from './SellerQuotesClient' export const getTokenToHeader = (ctx: IOContext) => { // provide authToken (app token) as an admin token as this is a call @@ -86,4 +87,8 @@ export class Clients extends IOClients { public get identity() { return this.getOrSet('identity', Identity) } + + public get sellerQuotes() { + return this.getOrSet('sellerQuotes', SellerQuotesClient) + } } diff --git a/node/constants.ts b/node/constants.ts index 4513036..01dc677 100644 --- a/node/constants.ts +++ b/node/constants.ts @@ -22,6 +22,9 @@ export const QUOTE_FIELDS = [ 'viewedBySales', 'viewedByCustomer', 'salesChannel', + 'seller', + 'parentQuote', + 'hasChildren', ] export const routes = { @@ -134,6 +137,18 @@ export const schema = { title: 'Viewed by Sales', type: 'boolean', }, + seller: { + title: 'Seller', + type: ['null', 'string'], + }, + parentQuote: { + title: 'Parent quote', + type: ['null', 'string'], + }, + hasChildren: { + title: 'Has children', + type: ['null', 'boolean'], + }, }, 'v-cache': false, 'v-default-fields': [ @@ -145,6 +160,9 @@ export const schema = { 'items', 'subtotal', 'status', + 'seller', + 'parentQuote', + 'hasChildren', ], 'v-immediate-indexing': true, 'v-indexed': [ @@ -157,5 +175,8 @@ export const schema = { 'organization', 'costCenter', 'salesChannel', + 'seller', + 'parentQuote', + 'hasChildren', ], } diff --git a/node/metrics/createQuote.ts b/node/metrics/createQuote.ts index 77dc7b8..3d048ba 100644 --- a/node/metrics/createQuote.ts +++ b/node/metrics/createQuote.ts @@ -7,18 +7,6 @@ type UserData = { roleId: string } -type SessionData = { - namespaces: { - profile: { - id: { value: string } - email: { value: string } - } - account: { - accountName: { value: string } - } - } -} - type CreateQuoteMetricParam = { sessionData: SessionData sendToSalesRep: boolean diff --git a/node/package.json b/node/package.json index 914af65..fe0660a 100644 --- a/node/package.json +++ b/node/package.json @@ -9,7 +9,7 @@ "ramda": "^0.25.0", "atob": "^2.1.2", "axios": "0.27.2", - "@vtex/api": "6.47.0" + "@vtex/api": "6.48.0" }, "devDependencies": { "@types/atob": "^2.1.2", diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index 5da1f2e..affc0dc 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -1,5 +1,18 @@ import { indexBy, map, prop } from 'ramda' +import { + APP_NAME, + QUOTE_DATA_ENTITY, + QUOTE_FIELDS, + routes, + SCHEMA_VERSION, +} from '../../constants' +import { sendCreateQuoteMetric } from '../../metrics/createQuote' +import type { UseQuoteMetricsParams } from '../../metrics/useQuote' +import { sendUseQuoteMetric } from '../../metrics/useQuote' +import { isEmail } from '../../utils' +import GraphQLError from '../../utils/GraphQLError' +import message from '../../utils/message' import { checkAndCreateQuotesConfig, checkConfig, @@ -11,19 +24,11 @@ import { checkQuoteStatus, checkSession, } from '../utils/checkPermissions' -import { isEmail } from '../../utils' -import GraphQLError from '../../utils/GraphQLError' -import message from '../../utils/message' import { - APP_NAME, - QUOTE_DATA_ENTITY, - QUOTE_FIELDS, - routes, - SCHEMA_VERSION, -} from '../../constants' -import { sendCreateQuoteMetric } from '../../metrics/createQuote' -import type { UseQuoteMetricsParams } from '../../metrics/useQuote' -import { sendUseQuoteMetric } from '../../metrics/useQuote' + createItemComparator, + createQuoteObject, + splitItemsBySeller, +} from '../utils/quotes' export const Mutation = { clearCart: async (_: any, params: any, ctx: Context) => { @@ -71,9 +76,8 @@ export const Mutation = { vtex: { logger }, } = ctx - const { sessionData, storefrontPermissions, segmentData } = vtex as any - const settings = await checkConfig(ctx) + const { sessionData, storefrontPermissions, segmentData } = vtex as any checkSession(sessionData) @@ -81,78 +85,112 @@ export const Mutation = { throw new GraphQLError('operation-not-permitted') } - const email = sessionData.namespaces.profile.email.value - const { - role: { slug }, - } = storefrontPermissions + try { + let quoteBySeller: SellerQuoteMap = {} - const { - organization: { value: organizationId }, - costcenter: { value: costCenterId }, - } = sessionData.namespaces['storefront-permissions'] + if (settings?.adminSetup.quotesManagedBy === 'SELLER') { + const sellerItems = items.filter( + ({ seller }) => seller && seller !== '1' + ) - const now = new Date() - const nowISO = now.toISOString() - const expirationDate = new Date() + quoteBySeller = await splitItemsBySeller({ + ctx, + items: sellerItems, + }) + } - expirationDate.setDate( - expirationDate.getDate() + (settings?.adminSetup?.cartLifeSpan ?? 30) - ) - const expirationDateISO = expirationDate.toISOString() + const hasSellerQuotes = Object.keys(quoteBySeller).length - const status = sendToSalesRep ? 'pending' : 'ready' - const lastUpdate = nowISO - const updateHistory = [ - { - date: nowISO, - email, + const parentQuoteItems = hasSellerQuotes + ? items.filter( + (item) => + !Object.values(quoteBySeller).some((quote) => + quote.items.some(createItemComparator(item)) + ) + ) + : items + + const quoteCommonFields = { + sessionData, + storefrontPermissions, + segmentData, + settings, + referenceName, note, - role: slug, - status, - }, - ] - - const salesChannel: string = segmentData?.channel - - const quote = { - costCenter: costCenterId, - creationDate: nowISO, - creatorEmail: email, - creatorRole: slug, - expirationDate: expirationDateISO, - items, - lastUpdate, - organization: organizationId, - referenceName, - status, - subtotal, - updateHistory, - viewedByCustomer: !!sendToSalesRep, - viewedBySales: !sendToSalesRep, - salesChannel, - } + sendToSalesRep, + } - try { - const data = await masterdata - .createDocument({ - dataEntity: QUOTE_DATA_ENTITY, - fields: quote, - schema: SCHEMA_VERSION, - }) - .then((res: any) => res) + // We believe that parent quote should contain the overall subtotal. + // If for some reason it is necessary to subtract the subtotal from + // sellers quotes, we can use the adjustedSubtotal below, assigning + // it to subtotal in createQuoteObject -> `subtotal: adjustedSubtotal` + // + // const adjustedSubtotal = hasSellerQuotes + // ? Object.values(quoteBySeller).reduce( + // (acc, quote) => acc - quote.subtotal, + // subtotal + // ) + // : subtotal + const parentQuote = createQuoteObject({ + ...quoteCommonFields, + items: parentQuoteItems, + subtotal, + }) + + const { DocumentId: parentQuoteId } = await masterdata.createDocument({ + dataEntity: QUOTE_DATA_ENTITY, + fields: parentQuote, + schema: SCHEMA_VERSION, + }) + + if (hasSellerQuotes) { + const sellerQuoteIds = await Promise.all( + Object.entries(quoteBySeller).map(async ([seller, sellerQuote]) => { + const sellerQuoteObject = createQuoteObject({ + ...quoteCommonFields, + ...sellerQuote, + seller, + parentQuote: parentQuoteId, + }) + + const data = await masterdata.createDocument({ + dataEntity: QUOTE_DATA_ENTITY, + fields: sellerQuoteObject, + schema: SCHEMA_VERSION, + }) + + await ctx.clients.sellerQuotes.notifyNewQuote( + seller, + data.DocumentId, + sellerQuoteObject.creationDate + ) + + return data.DocumentId + }) + ) + + if (sellerQuoteIds.length) { + await masterdata.updatePartialDocument({ + dataEntity: QUOTE_DATA_ENTITY, + fields: { hasChildren: true }, + id: parentQuoteId, + schema: SCHEMA_VERSION, + }) + } + } if (sendToSalesRep) { message(ctx) .quoteCreated({ - costCenter: costCenterId, - id: data.DocumentId, + costCenter: parentQuote.costCenter, + id: parentQuoteId, lastUpdate: { - email, + email: parentQuote.creatorEmail, note, - status: status.toUpperCase(), + status: parentQuote.status.toUpperCase(), }, name: referenceName, - organization: organizationId, + organization: parentQuote.organization, }) .then(() => { logger.info({ @@ -164,21 +202,21 @@ export const Mutation = { const metricsParam = { sessionData, userData: { - orgId: organizationId, - costId: costCenterId, - roleId: slug, + orgId: parentQuote.organization, + costId: parentQuote.costCenter, + roleId: parentQuote.creatorRole, }, costCenterName: 'costCenterData?.getCostCenterById?.name', buyerOrgName: 'organizationData?.getOrganizationById?.name', - quoteId: data.DocumentId, + quoteId: parentQuoteId, quoteReferenceName: referenceName, sendToSalesRep, - creationDate: nowISO, + creationDate: parentQuote.creationDate, } sendCreateQuoteMetric(ctx, metricsParam) - return data.DocumentId + return parentQuoteId } catch (error) { logger.error({ error, @@ -497,7 +535,9 @@ export const Mutation = { }, saveAppSettings: async ( _: void, - { input: { cartLifeSpan } }: { input: { cartLifeSpan: number } }, + { + input: { cartLifeSpan, quotesManagedBy = 'MARKETPLACE' }, + }: { input: { cartLifeSpan: number; quotesManagedBy: string } }, ctx: Context ) => { const { @@ -533,6 +573,7 @@ export const Mutation = { adminSetup: { ...settings.adminSetup, cartLifeSpan, + quotesManagedBy, }, } diff --git a/node/resolvers/queries/index.ts b/node/resolvers/queries/index.ts index 9e24369..844f5d5 100644 --- a/node/resolvers/queries/index.ts +++ b/node/resolvers/queries/index.ts @@ -1,5 +1,3 @@ -import { checkConfig } from '../utils/checkConfig' -import GraphQLError from '../../utils/GraphQLError' import { APP_NAME, B2B_USER_DATA_ENTITY, @@ -8,6 +6,8 @@ import { QUOTE_FIELDS, SCHEMA_VERSION, } from '../../constants' +import GraphQLError from '../../utils/GraphQLError' +import { checkConfig } from '../utils/checkConfig' // This function checks if given email is an user part of a buyer org. export const isUserPartOfBuyerOrg = async (email: string, ctx: Context) => { @@ -57,7 +57,8 @@ const buildWhereStatement = async ({ userCostCenterId: string userSalesChannel?: string }) => { - const whereArray = [] + // only the main quotes must be fetched + const whereArray = ['(parentQuote is null)'] // if user only has permission to access their organization's quotes, // hard-code that organization into the masterdata search @@ -334,6 +335,10 @@ export const Query = { return null } + if (settings && !settings?.adminSetup.quotesManagedBy) { + settings.adminSetup.quotesManagedBy = 'MARKETPLACE' + } + return settings }, } diff --git a/node/resolvers/utils/checkConfig.ts b/node/resolvers/utils/checkConfig.ts index e68857d..1d23c19 100644 --- a/node/resolvers/utils/checkConfig.ts +++ b/node/resolvers/utils/checkConfig.ts @@ -13,6 +13,7 @@ export const defaultSettings: Settings = { adminSetup: { allowManualPrice: false, cartLifeSpan: 30, + quotesManagedBy: 'MARKETPLACE', hasCron: false, }, schemaVersion: '', @@ -270,6 +271,8 @@ const checkInitializations = async ({ vtex: { workspace }, } = ctx + const hasSplittingQuoteFields = settings.hasSplittingQuoteFieldsInSchema + if ( !settings?.adminSetup?.hasCron || settings?.adminSetup?.cronExpression !== CRON_EXPRESSION || @@ -290,12 +293,16 @@ const checkInitializations = async ({ } } - if (settings?.schemaVersion !== SCHEMA_VERSION) { + if (settings?.schemaVersion !== SCHEMA_VERSION || !hasSplittingQuoteFields) { const oldSchemaVersion = settings?.schemaVersion settings = await initializeSchema(settings, ctx) - if (settings.schemaVersion !== oldSchemaVersion) { + const mustUpdateSettings = + settings.schemaVersion !== oldSchemaVersion || !hasSplittingQuoteFields + + if (mustUpdateSettings) { + settings.hasSplittingQuoteFieldsInSchema = true changed = true } } @@ -345,7 +352,10 @@ export const checkConfig = async (ctx: Context) => { return null } - if (!settings?.adminSetup?.cartLifeSpan) { + if ( + !settings?.adminSetup?.cartLifeSpan && + !settings?.adminSetup?.quotesManagedBy + ) { settings = defaultSettings changed = true } diff --git a/node/resolvers/utils/quotes.ts b/node/resolvers/utils/quotes.ts new file mode 100644 index 0000000..4fddbea --- /dev/null +++ b/node/resolvers/utils/quotes.ts @@ -0,0 +1,135 @@ +export async function splitItemsBySeller({ + ctx, + items, + quoteBySeller = {}, + index = 0, +}: { + ctx: Context + items: QuoteItem[] + quoteBySeller?: SellerQuoteMap + index?: number +}): Promise { + if (index >= items.length) return quoteBySeller + + const item = items[index] + const { seller } = item + + const next = async () => + splitItemsBySeller({ + ctx, + items, + quoteBySeller, + index: index + 1, + }) + + // The ternary check is to not request again from the same seller + const verifyResponse = quoteBySeller[seller] + ? { receiveQuotes: true } + : await ctx.clients.sellerQuotes + .verifyQuoteSettings(seller) + .catch(() => null) + + if (!verifyResponse?.receiveQuotes) { + await next() + + return quoteBySeller + } + + if (!quoteBySeller[seller]) { + quoteBySeller[seller] = { items: [], subtotal: 0 } + } + + quoteBySeller[seller].items.push(item) + quoteBySeller[seller].subtotal += item.sellingPrice * item.quantity + + await next() + + return quoteBySeller +} + +export function createItemComparator(item: T) { + return ({ id, seller }: T) => item.id === id && item.seller === seller +} + +export const createQuoteObject = ({ + sessionData, + storefrontPermissions, + segmentData, + settings, + items, + referenceName, + subtotal, + note, + sendToSalesRep, + seller, + parentQuote, + hasChildren, +}: { + sessionData: SessionData + storefrontPermissions: { role: { slug: string } } + segmentData?: { channel?: string } + settings?: Settings | null + items: QuoteItem[] + referenceName: string + subtotal: number + note: string + sendToSalesRep: boolean + seller?: string + parentQuote?: string | null + hasChildren?: boolean | null +}): Omit => { + const email = sessionData.namespaces.profile.email.value + + const { + role: { slug }, + } = storefrontPermissions + + const { + organization: { value: organizationId }, + costcenter: { value: costCenterId }, + } = sessionData.namespaces['storefront-permissions'] + + const now = new Date() + const nowISO = now.toISOString() + const expirationDate = new Date() + + expirationDate.setDate( + expirationDate.getDate() + (settings?.adminSetup?.cartLifeSpan ?? 30) + ) + const expirationDateISO = expirationDate.toISOString() + + const status = sendToSalesRep ? 'pending' : 'ready' + const lastUpdate = nowISO + const updateHistory = [ + { + date: nowISO, + email, + note, + role: slug, + status, + }, + ] + + const salesChannel: string = segmentData?.channel ?? '' + + return { + costCenter: costCenterId, + creationDate: nowISO, + creatorEmail: email, + creatorRole: slug, + expirationDate: expirationDateISO, + items, + lastUpdate, + organization: organizationId, + referenceName, + status, + subtotal, + updateHistory, + viewedByCustomer: !!sendToSalesRep, + viewedBySales: !sendToSalesRep, + salesChannel, + seller, + parentQuote, + hasChildren, + } +} diff --git a/node/typings.d.ts b/node/typings.d.ts index 82b16a2..00bba8b 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -15,6 +15,9 @@ interface Quote { viewedBySales: boolean viewedByCustomer: boolean salesChannel: string | null + seller?: string | null + parentQuote?: string | null + hasChildren?: boolean | null } interface QuoteUpdate { @@ -106,7 +109,42 @@ interface Settings { hasCron?: boolean cronExpression?: string cronWorkspace?: string + quotesManagedBy?: string } + hasSplittingQuoteFieldsInSchema?: boolean schemaVersion: string templateHash: string | null } + +interface SessionData { + namespaces: { + profile: { + id: { value: string } + email: { value: string } + } + account: { + accountName: { value: string } + } + 'storefront-permissions': { + organization: { value: string } + costcenter: { value: string } + } + } +} + +interface SellerQuoteInput { + items: QuoteItem[] + subtotal: number +} + +type SellerQuoteMap = Record + +interface VerifyQuoteSettingsResponse { + receiveQuotes: boolean +} + +interface SellerQuoteNotifyInput { + quoteId: string + marketplaceAccount: string + creationDate: string +} diff --git a/node/yarn.lock b/node/yarn.lock index 704ce0e..d572997 100644 --- a/node/yarn.lock +++ b/node/yarn.lock @@ -178,10 +178,10 @@ "@types/mime" "^1" "@types/node" "*" -"@vtex/api@6.47.0": - version "6.47.0" - resolved "https://registry.yarnpkg.com/@vtex/api/-/api-6.47.0.tgz#6910455d593d8bb76f1f4f2b7660023853fda35e" - integrity sha512-t9gt7Q89EMbSj3rLhho+49Fv+/lQgiy8EPVRgtmmXFp1J4v8hIAZF7GPjCPie111KVs4eG0gfZFpmhA5dafKNA== +"@vtex/api@6.48.0": + version "6.48.0" + resolved "https://registry.yarnpkg.com/@vtex/api/-/api-6.48.0.tgz#67f9f11d197d543d4f854b057d31a8d6999241e9" + integrity sha512-mAdT7gbV0/BwiuqUkNH1E7KZqTUczT5NbBBZcPJq5kmTr73PUjbR9wh//70ryJo2EAdHlqIgqgwsCVpozenlhg== dependencies: "@types/koa" "^2.11.0" "@types/koa-compose" "^3.2.3" @@ -1522,7 +1522,7 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -stats-lite@vtex/node-stats-lite#dist: +"stats-lite@github:vtex/node-stats-lite#dist": version "2.2.0" resolved "https://codeload.github.com/vtex/node-stats-lite/tar.gz/1b0d39cc41ef7aaecfd541191f877887a2044797" dependencies: