From 344c77f558758bcc09cf91aaff6d7fa692ceffae Mon Sep 17 00:00:00 2001 From: Bruna Santos Date: Tue, 26 Nov 2024 14:09:33 -0300 Subject: [PATCH 01/31] feat: add new markeplace splitting quotes on graphql --- CHANGELOG.md | 3 +++ graphql/appSettings.graphql | 6 ++++++ node/resolvers/mutations/index.ts | 29 +++++++++++++++-------------- node/resolvers/queries/index.ts | 8 ++++++-- node/typings.d.ts | 1 + 5 files changed, 31 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97fa3c3..6f7316f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Add new input quotesManagedBy on appSettings to handle splitting marketplace + ## [2.6.4] - 2024-10-31 ### Fixed 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/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index 5da1f2e..3f35de0 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,6 @@ 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' export const Mutation = { clearCart: async (_: any, params: any, ctx: Context) => { @@ -497,7 +497,7 @@ 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 +533,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..6f90a79 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) => { @@ -334,6 +334,10 @@ export const Query = { return null } + if(settings && !settings?.adminSetup.quotesManagedBy){ + settings.adminSetup.quotesManagedBy = 'MARKETPLACE' + } + return settings }, } diff --git a/node/typings.d.ts b/node/typings.d.ts index 82b16a2..df99a86 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -106,6 +106,7 @@ interface Settings { hasCron?: boolean cronExpression?: string cronWorkspace?: string + quotesManagedBy?: string } schemaVersion: string templateHash: string | null From a9ece27a1e8f623efcc860c7ac68d741b420063c Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Sat, 30 Nov 2024 12:52:10 -0300 Subject: [PATCH 02/31] feat: checking new field quotesManagedBy when value is SELLER --- node/resolvers/mutations/index.ts | 41 ++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index 3f35de0..64d7db7 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -65,15 +65,46 @@ export const Mutation = { }, ctx: Context ) => { + // eslint-disable-next-line no-console + console.dir( + { referenceName, items, subtotal, note, sendToSalesRep }, + { depth: null } + ) + const { clients: { masterdata }, vtex, vtex: { logger }, } = ctx - const { sessionData, storefrontPermissions, segmentData } = vtex as any - const settings = await checkConfig(ctx) + const itemsBySeller: any = {} + + if (settings?.adminSetup.quotesManagedBy === 'SELLER') { + items.forEach((item) => { + if (!itemsBySeller[item.seller]) { + // TODO: criar objeto necessário para criar cotação + itemsBySeller[item.seller] = { + items: [], + referenceName, + note, + sendToSalesRep, + subtotal: 0, + } + } + + itemsBySeller[item.seller].items.push(item) + const sellerSubtotal = + (itemsBySeller[item.seller].subtotal as number) + item.sellingPrice + + itemsBySeller[item.seller].subtotal = sellerSubtotal + }) + + // eslint-disable-next-line no-console + console.dir({ itemsBySeller }, { depth: null }) + } + + const { sessionData, storefrontPermissions, segmentData } = vtex as any checkSession(sessionData) @@ -81,6 +112,7 @@ export const Mutation = { throw new GraphQLError('operation-not-permitted') } + // TODO: o conteúdo entre as linhas 116 e 166 pode ficar em uma função separada const email = sessionData.namespaces.profile.email.value const { role: { slug }, @@ -114,6 +146,7 @@ export const Mutation = { const salesChannel: string = segmentData?.channel + // TODO: criar função que cria este objeto para poder usá-la na separação de cotações por seller const quote = { costCenter: costCenterId, creationDate: nowISO, @@ -497,7 +530,9 @@ export const Mutation = { }, saveAppSettings: async ( _: void, - { input: { cartLifeSpan, quotesManagedBy = 'MARKETPLACE' } }: { input: { cartLifeSpan: number , quotesManagedBy: string} }, + { + input: { cartLifeSpan, quotesManagedBy = 'MARKETPLACE' }, + }: { input: { cartLifeSpan: number; quotesManagedBy: string } }, ctx: Context ) => { const { From d40f7052b69ae6569b0b623b115955fba2c3309b Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Mon, 2 Dec 2024 13:50:27 -0300 Subject: [PATCH 03/31] docs: update CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f7316f..dc45d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Add new input quotesManagedBy on appSettings to handle splitting marketplace +- Add new input quotesManagedBy on appSettings to handle splitting quotes ## [2.6.4] - 2024-10-31 From 58ff10bfb9fb22ded50952180b130aad820fbe20 Mon Sep 17 00:00:00 2001 From: Bruna Santos Date: Wed, 4 Dec 2024 17:41:58 -0300 Subject: [PATCH 04/31] feat: add configuration for quote creation --- node/metrics/createQuote.ts | 2 +- node/resolvers/mutations/index.ts | 185 ++++++++++++++++++------------ node/typings.d.ts | 4 + 3 files changed, 115 insertions(+), 76 deletions(-) diff --git a/node/metrics/createQuote.ts b/node/metrics/createQuote.ts index 77dc7b8..45b3b98 100644 --- a/node/metrics/createQuote.ts +++ b/node/metrics/createQuote.ts @@ -7,7 +7,7 @@ type UserData = { roleId: string } -type SessionData = { +export type SessionData = { namespaces: { profile: { id: { value: string } diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index 64d7db7..eef4b33 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -25,6 +25,94 @@ import { checkSession, } from '../utils/checkPermissions' +interface SellerQuote { + items: QuoteItem[] + referenceName: string + note: string + sendToSalesRep: boolean + subtotal: number +} + +interface QuoteSessionData { + namespaces: { + [key: string]: any // Dynamic keys allowed + } +} + +const createQuoteObject = ({ + sessionData, + storefrontPermissions, + segmentData, + settings, + items, + referenceName, + subtotal, + note, + sendToSalesRep, +}: { + sessionData: QuoteSessionData + storefrontPermissions: string + segmentData?: { channel?: string } + settings?: Settings + items: QuoteItem[] + referenceName: string + subtotal: number + note: string + sendToSalesRep: boolean +}): QuoteWithOptionalId => { + 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, + } +} + export const Mutation = { clearCart: async (_: any, params: any, ctx: Context) => { const { @@ -65,12 +153,6 @@ export const Mutation = { }, ctx: Context ) => { - // eslint-disable-next-line no-console - console.dir( - { referenceName, items, subtotal, note, sendToSalesRep }, - { depth: null } - ) - const { clients: { masterdata }, vtex, @@ -78,13 +160,12 @@ export const Mutation = { } = ctx const settings = await checkConfig(ctx) - const itemsBySeller: any = {} + const itemsBySeller: Record = {} if (settings?.adminSetup.quotesManagedBy === 'SELLER') { - items.forEach((item) => { - if (!itemsBySeller[item.seller]) { - // TODO: criar objeto necessário para criar cotação - itemsBySeller[item.seller] = { + items.forEach(({ seller, sellingPrice, ...itemData }) => { + if (!itemsBySeller[seller]) { + itemsBySeller[seller] = { items: [], referenceName, note, @@ -93,15 +174,10 @@ export const Mutation = { } } - itemsBySeller[item.seller].items.push(item) - const sellerSubtotal = - (itemsBySeller[item.seller].subtotal as number) + item.sellingPrice + itemsBySeller[seller].items.push({ seller, sellingPrice, ...itemData }) - itemsBySeller[item.seller].subtotal = sellerSubtotal + itemsBySeller[seller].subtotal += sellingPrice }) - - // eslint-disable-next-line no-console - console.dir({ itemsBySeller }, { depth: null }) } const { sessionData, storefrontPermissions, segmentData } = vtex as any @@ -112,58 +188,17 @@ export const Mutation = { throw new GraphQLError('operation-not-permitted') } - // TODO: o conteúdo entre as linhas 116 e 166 pode ficar em uma função separada - 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 - - // TODO: criar função que cria este objeto para poder usá-la na separação de cotações por seller - const quote = { - costCenter: costCenterId, - creationDate: nowISO, - creatorEmail: email, - creatorRole: slug, - expirationDate: expirationDateISO, + const quote = createQuoteObject({ + sessionData, + storefrontPermissions, + segmentData, + settings: settings || undefined, items, - lastUpdate, - organization: organizationId, referenceName, - status, subtotal, - updateHistory, - viewedByCustomer: !!sendToSalesRep, - viewedBySales: !sendToSalesRep, - salesChannel, - } + note, + sendToSalesRep, + }) try { const data = await masterdata @@ -177,15 +212,15 @@ export const Mutation = { if (sendToSalesRep) { message(ctx) .quoteCreated({ - costCenter: costCenterId, + costCenter: quote.costCenter, id: data.DocumentId, lastUpdate: { - email, + email: quote.creatorEmail, note, - status: status.toUpperCase(), + status: quote.status.toUpperCase(), }, name: referenceName, - organization: organizationId, + organization: quote.organization, }) .then(() => { logger.info({ @@ -197,16 +232,16 @@ export const Mutation = { const metricsParam = { sessionData, userData: { - orgId: organizationId, - costId: costCenterId, - roleId: slug, + orgId: quote.organization, + costId: quote.costCenter, + roleId: quote.creatorRole, }, costCenterName: 'costCenterData?.getCostCenterById?.name', buyerOrgName: 'organizationData?.getOrganizationById?.name', quoteId: data.DocumentId, quoteReferenceName: referenceName, sendToSalesRep, - creationDate: nowISO, + creationDate: quote.creationDate, } sendCreateQuoteMetric(ctx, metricsParam) diff --git a/node/typings.d.ts b/node/typings.d.ts index df99a86..f5dda59 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -17,6 +17,10 @@ interface Quote { salesChannel: string | null } +interface QuoteWithOptionalId extends Quote { + id?: string +} + interface QuoteUpdate { email: string role: string From 3cae02305b3b0c9c28025512a59bca38dffea5d4 Mon Sep 17 00:00:00 2001 From: Bruna Santos Date: Fri, 6 Dec 2024 15:00:06 -0300 Subject: [PATCH 05/31] feat: add adjustment for quote seller --- node/resolvers/mutations/index.ts | 77 +++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index eef4b33..3ff9a55 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -160,12 +160,15 @@ export const Mutation = { } = ctx const settings = await checkConfig(ctx) - const itemsBySeller: Record = {} + const quoteBySeller: Record = {} + const { sessionData, storefrontPermissions, segmentData } = vtex as any + + checkSession(sessionData) if (settings?.adminSetup.quotesManagedBy === 'SELLER') { items.forEach(({ seller, sellingPrice, ...itemData }) => { - if (!itemsBySeller[seller]) { - itemsBySeller[seller] = { + if (!quoteBySeller[seller]) { + quoteBySeller[seller] = { items: [], referenceName, note, @@ -174,15 +177,71 @@ export const Mutation = { } } - itemsBySeller[seller].items.push({ seller, sellingPrice, ...itemData }) - - itemsBySeller[seller].subtotal += sellingPrice + quoteBySeller[seller].items.push({ seller, sellingPrice, ...itemData }) + quoteBySeller[seller].subtotal += sellingPrice }) - } - const { sessionData, storefrontPermissions, segmentData } = vtex as any + const documentIds = await Promise.all( + Object.entries(quoteBySeller).map(async ([seller, sellerQuote]) => { + const sellerQuoteObject = createQuoteObject({ + sessionData, + storefrontPermissions, + segmentData, + settings: settings || undefined, + items: sellerQuote.items, + referenceName: sellerQuote.referenceName, + subtotal: sellerQuote.subtotal, + note: sellerQuote.note, + sendToSalesRep: sellerQuote.sendToSalesRep, + }) - checkSession(sessionData) + const data = await masterdata.createDocument({ + dataEntity: QUOTE_DATA_ENTITY, + fields: sellerQuoteObject, + schema: SCHEMA_VERSION, + }) + + if (sendToSalesRep) { + await message(ctx).quoteCreated({ + costCenter: sellerQuoteObject.costCenter, + id: data.DocumentId, + lastUpdate: { + email: sellerQuoteObject.creatorEmail, + note: sellerQuote.note, + status: sellerQuoteObject.status.toUpperCase(), + }, + name: sellerQuote.referenceName, + organization: sellerQuoteObject.organization, + }) + + logger.info({ + message: `[Quote created for seller: ${seller}] E-mail sent to sales reps`, + }) + } + + const metricsParam = { + sessionData, + userData: { + orgId: sellerQuoteObject.organization, + costId: sellerQuoteObject.costCenter, + roleId: sellerQuoteObject.creatorRole, + }, + costCenterName: 'costCenterData?.getCostCenterById?.name', + buyerOrgName: 'organizationData?.getOrganizationById?.name', + quoteId: data.DocumentId, + quoteReferenceName: referenceName, + sendToSalesRep, + creationDate: sellerQuoteObject.creationDate, + } + + sendCreateQuoteMetric(ctx, metricsParam) + + return data.DocumentId + }) + ) + + return documentIds + } if (!storefrontPermissions?.permissions?.includes('create-quotes')) { throw new GraphQLError('operation-not-permitted') From df480220069531a08306345fec2226c508919b25 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Fri, 6 Dec 2024 15:33:03 -0300 Subject: [PATCH 06/31] chore: fix prettier errors --- node/resolvers/mutations/index.ts | 4 +++- node/resolvers/queries/index.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index 3f35de0..b4fc750 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -497,7 +497,9 @@ export const Mutation = { }, saveAppSettings: async ( _: void, - { input: { cartLifeSpan, quotesManagedBy = 'MARKETPLACE' } }: { input: { cartLifeSpan: number , quotesManagedBy: string} }, + { + input: { cartLifeSpan, quotesManagedBy = 'MARKETPLACE' }, + }: { input: { cartLifeSpan: number; quotesManagedBy: string } }, ctx: Context ) => { const { diff --git a/node/resolvers/queries/index.ts b/node/resolvers/queries/index.ts index 6f90a79..946e81b 100644 --- a/node/resolvers/queries/index.ts +++ b/node/resolvers/queries/index.ts @@ -334,10 +334,10 @@ export const Query = { return null } - if(settings && !settings?.adminSetup.quotesManagedBy){ + if (settings && !settings?.adminSetup.quotesManagedBy) { settings.adminSetup.quotesManagedBy = 'MARKETPLACE' } - + return settings }, } From 85622cbe339334f7d5a8937c74892c00e983559c Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Fri, 6 Dec 2024 16:35:57 -0300 Subject: [PATCH 07/31] fix: returning ids separated by commas when multiple quotes --- node/resolvers/mutations/index.ts | 2 +- node/resolvers/queries/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index 3ff9a55..245610b 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -240,7 +240,7 @@ export const Mutation = { }) ) - return documentIds + return documentIds.join(',') } if (!storefrontPermissions?.permissions?.includes('create-quotes')) { diff --git a/node/resolvers/queries/index.ts b/node/resolvers/queries/index.ts index 6f90a79..946e81b 100644 --- a/node/resolvers/queries/index.ts +++ b/node/resolvers/queries/index.ts @@ -334,10 +334,10 @@ export const Query = { return null } - if(settings && !settings?.adminSetup.quotesManagedBy){ + if (settings && !settings?.adminSetup.quotesManagedBy) { settings.adminSetup.quotesManagedBy = 'MARKETPLACE' } - + return settings }, } From b3a2c6ce8d9622a9a3bf8f7102458a3eaea8e106 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Mon, 9 Dec 2024 15:44:39 -0300 Subject: [PATCH 08/31] fix: check config and default settings with marketplace option --- node/resolvers/utils/checkConfig.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/node/resolvers/utils/checkConfig.ts b/node/resolvers/utils/checkConfig.ts index e68857d..025b8d8 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: '', @@ -345,7 +346,10 @@ export const checkConfig = async (ctx: Context) => { return null } - if (!settings?.adminSetup?.cartLifeSpan) { + if ( + !settings?.adminSetup?.cartLifeSpan && + !settings?.adminSetup?.quotesManagedBy + ) { settings = defaultSettings changed = true } From ffeadeb7d91cd0a731f2cfa12387f7b1e1ea0073 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Mon, 9 Dec 2024 19:36:11 -0300 Subject: [PATCH 09/31] feat: new fields for quotes managed by seller --- graphql/quote.graphql | 2 ++ node/constants.ts | 12 ++++++++++++ node/resolvers/mutations/index.ts | 16 ++++++++++++---- node/typings.d.ts | 2 ++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/graphql/quote.graphql b/graphql/quote.graphql index 4ce5057..ba1ab85 100644 --- a/graphql/quote.graphql +++ b/graphql/quote.graphql @@ -59,6 +59,8 @@ type Quote { viewedBySales: Boolean viewedByCustomer: Boolean salesChannel: String + seller: String + approvedBySeller: Boolean } type QuoteUpdate { diff --git a/node/constants.ts b/node/constants.ts index 4513036..c8555ac 100644 --- a/node/constants.ts +++ b/node/constants.ts @@ -22,6 +22,8 @@ export const QUOTE_FIELDS = [ 'viewedBySales', 'viewedByCustomer', 'salesChannel', + 'seller', + 'approvedBySeller', ] export const routes = { @@ -134,6 +136,14 @@ export const schema = { title: 'Viewed by Sales', type: 'boolean', }, + seller: { + title: 'Seller', + type: ['null', 'string'], + }, + approvedBySeller: { + title: 'Quote approved by seller', + type: ['null', 'boolean'], + }, }, 'v-cache': false, 'v-default-fields': [ @@ -157,5 +167,7 @@ export const schema = { 'organization', 'costCenter', 'salesChannel', + 'seller', + 'approvedBySeller', ], } diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index 245610b..66b5ad0 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -49,16 +49,20 @@ const createQuoteObject = ({ subtotal, note, sendToSalesRep, + seller, + approvedBySeller, }: { sessionData: QuoteSessionData - storefrontPermissions: string + storefrontPermissions: { role: { slug: string } } segmentData?: { channel?: string } - settings?: Settings + settings?: Settings | null items: QuoteItem[] referenceName: string subtotal: number note: string sendToSalesRep: boolean + seller?: string + approvedBySeller?: boolean }): QuoteWithOptionalId => { const email = sessionData.namespaces.profile.email.value @@ -110,6 +114,8 @@ const createQuoteObject = ({ viewedByCustomer: !!sendToSalesRep, viewedBySales: !sendToSalesRep, salesChannel, + seller, + approvedBySeller, } } @@ -187,12 +193,14 @@ export const Mutation = { sessionData, storefrontPermissions, segmentData, - settings: settings || undefined, + settings, items: sellerQuote.items, referenceName: sellerQuote.referenceName, subtotal: sellerQuote.subtotal, note: sellerQuote.note, sendToSalesRep: sellerQuote.sendToSalesRep, + seller, + approvedBySeller: false, }) const data = await masterdata.createDocument({ @@ -251,7 +259,7 @@ export const Mutation = { sessionData, storefrontPermissions, segmentData, - settings: settings || undefined, + settings, items, referenceName, subtotal, diff --git a/node/typings.d.ts b/node/typings.d.ts index f5dda59..19b1982 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -15,6 +15,8 @@ interface Quote { viewedBySales: boolean viewedByCustomer: boolean salesChannel: string | null + seller?: string | null + approvedBySeller?: boolean | null } interface QuoteWithOptionalId extends Quote { From 1d8b309a027d9f855219ef48eac72d49b9cf2230 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Tue, 10 Dec 2024 01:59:28 -0300 Subject: [PATCH 10/31] feat: new fields to save parent quote; change getQuotes query to filter only parent quotes --- graphql/quote.graphql | 2 + node/constants.ts | 16 ++ node/metrics/createQuote.ts | 12 -- node/package.json | 2 +- node/resolvers/mutations/index.ts | 269 ++++++++-------------------- node/resolvers/queries/index.ts | 3 +- node/resolvers/utils/checkConfig.ts | 10 +- node/resolvers/utils/quotes.ts | 85 +++++++++ node/typings.d.ts | 31 +++- node/yarn.lock | 10 +- 10 files changed, 222 insertions(+), 218 deletions(-) create mode 100644 node/resolvers/utils/quotes.ts diff --git a/graphql/quote.graphql b/graphql/quote.graphql index ba1ab85..78f2b35 100644 --- a/graphql/quote.graphql +++ b/graphql/quote.graphql @@ -61,6 +61,8 @@ type Quote { salesChannel: String seller: String approvedBySeller: Boolean + parentQuote: String + hasChildren: Boolean } type QuoteUpdate { diff --git a/node/constants.ts b/node/constants.ts index c8555ac..f9d1926 100644 --- a/node/constants.ts +++ b/node/constants.ts @@ -24,6 +24,8 @@ export const QUOTE_FIELDS = [ 'salesChannel', 'seller', 'approvedBySeller', + 'parentQuote', + 'hasChildren', ] export const routes = { @@ -144,6 +146,14 @@ export const schema = { title: 'Quote approved by seller', type: ['null', 'boolean'], }, + parentQuote: { + title: 'Parent quote', + type: ['null', 'string'], + }, + hasChildren: { + title: 'Has children', + type: ['null', 'boolean'], + }, }, 'v-cache': false, 'v-default-fields': [ @@ -155,6 +165,10 @@ export const schema = { 'items', 'subtotal', 'status', + 'seller', + 'approvedBySeller', + 'parentQuote', + 'hasChildren', ], 'v-immediate-indexing': true, 'v-indexed': [ @@ -169,5 +183,7 @@ export const schema = { 'salesChannel', 'seller', 'approvedBySeller', + 'parentQuote', + 'hasChildren', ], } diff --git a/node/metrics/createQuote.ts b/node/metrics/createQuote.ts index 45b3b98..3d048ba 100644 --- a/node/metrics/createQuote.ts +++ b/node/metrics/createQuote.ts @@ -7,18 +7,6 @@ type UserData = { roleId: string } -export 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 66b5ad0..1fb34b3 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -24,100 +24,7 @@ import { checkQuoteStatus, checkSession, } from '../utils/checkPermissions' - -interface SellerQuote { - items: QuoteItem[] - referenceName: string - note: string - sendToSalesRep: boolean - subtotal: number -} - -interface QuoteSessionData { - namespaces: { - [key: string]: any // Dynamic keys allowed - } -} - -const createQuoteObject = ({ - sessionData, - storefrontPermissions, - segmentData, - settings, - items, - referenceName, - subtotal, - note, - sendToSalesRep, - seller, - approvedBySeller, -}: { - sessionData: QuoteSessionData - storefrontPermissions: { role: { slug: string } } - segmentData?: { channel?: string } - settings?: Settings | null - items: QuoteItem[] - referenceName: string - subtotal: number - note: string - sendToSalesRep: boolean - seller?: string - approvedBySeller?: boolean -}): QuoteWithOptionalId => { - 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, - approvedBySeller, - } -} +import { createQuoteObject } from '../utils/quotes' export const Mutation = { clearCart: async (_: any, params: any, ctx: Context) => { @@ -166,96 +73,15 @@ export const Mutation = { } = ctx const settings = await checkConfig(ctx) - const quoteBySeller: Record = {} const { sessionData, storefrontPermissions, segmentData } = vtex as any checkSession(sessionData) - if (settings?.adminSetup.quotesManagedBy === 'SELLER') { - items.forEach(({ seller, sellingPrice, ...itemData }) => { - if (!quoteBySeller[seller]) { - quoteBySeller[seller] = { - items: [], - referenceName, - note, - sendToSalesRep, - subtotal: 0, - } - } - - quoteBySeller[seller].items.push({ seller, sellingPrice, ...itemData }) - quoteBySeller[seller].subtotal += sellingPrice - }) - - const documentIds = await Promise.all( - Object.entries(quoteBySeller).map(async ([seller, sellerQuote]) => { - const sellerQuoteObject = createQuoteObject({ - sessionData, - storefrontPermissions, - segmentData, - settings, - items: sellerQuote.items, - referenceName: sellerQuote.referenceName, - subtotal: sellerQuote.subtotal, - note: sellerQuote.note, - sendToSalesRep: sellerQuote.sendToSalesRep, - seller, - approvedBySeller: false, - }) - - const data = await masterdata.createDocument({ - dataEntity: QUOTE_DATA_ENTITY, - fields: sellerQuoteObject, - schema: SCHEMA_VERSION, - }) - - if (sendToSalesRep) { - await message(ctx).quoteCreated({ - costCenter: sellerQuoteObject.costCenter, - id: data.DocumentId, - lastUpdate: { - email: sellerQuoteObject.creatorEmail, - note: sellerQuote.note, - status: sellerQuoteObject.status.toUpperCase(), - }, - name: sellerQuote.referenceName, - organization: sellerQuoteObject.organization, - }) - - logger.info({ - message: `[Quote created for seller: ${seller}] E-mail sent to sales reps`, - }) - } - - const metricsParam = { - sessionData, - userData: { - orgId: sellerQuoteObject.organization, - costId: sellerQuoteObject.costCenter, - roleId: sellerQuoteObject.creatorRole, - }, - costCenterName: 'costCenterData?.getCostCenterById?.name', - buyerOrgName: 'organizationData?.getOrganizationById?.name', - quoteId: data.DocumentId, - quoteReferenceName: referenceName, - sendToSalesRep, - creationDate: sellerQuoteObject.creationDate, - } - - sendCreateQuoteMetric(ctx, metricsParam) - - return data.DocumentId - }) - ) - - return documentIds.join(',') - } - if (!storefrontPermissions?.permissions?.includes('create-quotes')) { throw new GraphQLError('operation-not-permitted') } - const quote = createQuoteObject({ + const parentQuote = createQuoteObject({ sessionData, storefrontPermissions, segmentData, @@ -268,26 +94,83 @@ export const Mutation = { }) try { - const data = await masterdata - .createDocument({ - dataEntity: QUOTE_DATA_ENTITY, - fields: quote, - schema: SCHEMA_VERSION, + const { DocumentId: parentQuoteId } = await masterdata.createDocument({ + dataEntity: QUOTE_DATA_ENTITY, + fields: parentQuote, + schema: SCHEMA_VERSION, + }) + + if (settings?.adminSetup.quotesManagedBy === 'SELLER') { + const quoteBySeller: Record = {} + + items.forEach(({ seller, sellingPrice, ...itemData }) => { + if (!quoteBySeller[seller]) { + quoteBySeller[seller] = { + items: [], + referenceName, + note, + sendToSalesRep, + subtotal: 0, + } + } + + quoteBySeller[seller].items.push({ + seller, + sellingPrice, + ...itemData, + }) + quoteBySeller[seller].subtotal += sellingPrice * itemData.quantity }) - .then((res: any) => res) + + const documentIds = await Promise.all( + Object.entries(quoteBySeller).map(async ([seller, sellerQuote]) => { + const sellerQuoteObject = createQuoteObject({ + sessionData, + storefrontPermissions, + segmentData, + settings, + items: sellerQuote.items, + referenceName: sellerQuote.referenceName, + subtotal: sellerQuote.subtotal, + note: sellerQuote.note, + sendToSalesRep: sellerQuote.sendToSalesRep, + seller, + approvedBySeller: false, + parentQuote: parentQuoteId, + }) + + const data = await masterdata.createDocument({ + dataEntity: QUOTE_DATA_ENTITY, + fields: sellerQuoteObject, + schema: SCHEMA_VERSION, + }) + + return data.DocumentId + }) + ) + + if (documentIds.length) { + await masterdata.updatePartialDocument({ + dataEntity: QUOTE_DATA_ENTITY, + fields: { hasChildren: true }, + id: parentQuoteId, + schema: SCHEMA_VERSION, + }) + } + } if (sendToSalesRep) { message(ctx) .quoteCreated({ - costCenter: quote.costCenter, - id: data.DocumentId, + costCenter: parentQuote.costCenter, + id: parentQuoteId, lastUpdate: { - email: quote.creatorEmail, + email: parentQuote.creatorEmail, note, - status: quote.status.toUpperCase(), + status: parentQuote.status.toUpperCase(), }, name: referenceName, - organization: quote.organization, + organization: parentQuote.organization, }) .then(() => { logger.info({ @@ -299,21 +182,21 @@ export const Mutation = { const metricsParam = { sessionData, userData: { - orgId: quote.organization, - costId: quote.costCenter, - roleId: quote.creatorRole, + 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: quote.creationDate, + creationDate: parentQuote.creationDate, } sendCreateQuoteMetric(ctx, metricsParam) - return data.DocumentId + return parentQuoteId } catch (error) { logger.error({ error, diff --git a/node/resolvers/queries/index.ts b/node/resolvers/queries/index.ts index 946e81b..844f5d5 100644 --- a/node/resolvers/queries/index.ts +++ b/node/resolvers/queries/index.ts @@ -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 diff --git a/node/resolvers/utils/checkConfig.ts b/node/resolvers/utils/checkConfig.ts index 025b8d8..1d23c19 100644 --- a/node/resolvers/utils/checkConfig.ts +++ b/node/resolvers/utils/checkConfig.ts @@ -271,6 +271,8 @@ const checkInitializations = async ({ vtex: { workspace }, } = ctx + const hasSplittingQuoteFields = settings.hasSplittingQuoteFieldsInSchema + if ( !settings?.adminSetup?.hasCron || settings?.adminSetup?.cronExpression !== CRON_EXPRESSION || @@ -291,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 } } diff --git a/node/resolvers/utils/quotes.ts b/node/resolvers/utils/quotes.ts new file mode 100644 index 0000000..8a8cc44 --- /dev/null +++ b/node/resolvers/utils/quotes.ts @@ -0,0 +1,85 @@ +export const createQuoteObject = ({ + sessionData, + storefrontPermissions, + segmentData, + settings, + items, + referenceName, + subtotal, + note, + sendToSalesRep, + seller, + approvedBySeller, + 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 + approvedBySeller?: boolean | null + 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, + approvedBySeller, + parentQuote, + hasChildren, + } +} diff --git a/node/typings.d.ts b/node/typings.d.ts index 19b1982..9b471ec 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -17,10 +17,8 @@ interface Quote { salesChannel: string | null seller?: string | null approvedBySeller?: boolean | null -} - -interface QuoteWithOptionalId extends Quote { - id?: string + parentQuote?: string | null + hasChildren?: boolean | null } interface QuoteUpdate { @@ -114,6 +112,31 @@ interface Settings { 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[] + referenceName: string + note: string + sendToSalesRep: boolean + subtotal: number +} 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: From 36132622865ed0cf20093d1f0d5dd4fcd774bbb2 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Tue, 10 Dec 2024 02:09:55 -0300 Subject: [PATCH 11/31] feat: create client to notify seller quote --- node/clients/SellerQuotesClient.ts | 47 ++++++++++++++++++++++++++++++ node/clients/index.ts | 5 ++++ 2 files changed, 52 insertions(+) create mode 100644 node/clients/SellerQuotesClient.ts diff --git a/node/clients/SellerQuotesClient.ts b/node/clients/SellerQuotesClient.ts new file mode 100644 index 0000000..6426d8c --- /dev/null +++ b/node/clients/SellerQuotesClient.ts @@ -0,0 +1,47 @@ +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, +} + +interface NotifySellerQuoteResponse { + status: string +} + +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 getUrl(account: string) { + const subdomain = this.context.production + ? account + : `${this.context.workspace}--${account}` + + return `http://${subdomain}.myvtex.com/_v/b2b-seller-quotes/notify-quote` + } + + public async notify(account: string, quote: Quote) { + return this.http + .postRaw(this.getUrl(account), quote) + .then((res) => { + // eslint-disable-next-line no-console + console.log('RESPONSE', res) + + return res + }) + } +} 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) + } } From f81db0154d4b775d8a7cd8ea9db07fc5f69c8eb4 Mon Sep 17 00:00:00 2001 From: Bruna Santos Date: Tue, 10 Dec 2024 11:04:32 -0300 Subject: [PATCH 12/31] feat: add changelog --- CHANGELOG.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc45d1f..38b01e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,36 +8,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added + - Add new input quotesManagedBy on appSettings to handle splitting quotes +- Add new quote configuration to handle split quote by seller ## [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 @@ -79,15 +87,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 From 9af7ee0e3407f1bdc1e4cde0a3ab661b9d697b5e Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Wed, 11 Dec 2024 02:06:27 -0300 Subject: [PATCH 13/31] feat: verify and notify seller quote --- CHANGELOG.md | 2 ++ node/clients/SellerQuotesClient.ts | 48 ++++++++++++++++++++++++++---- node/resolvers/mutations/index.ts | 13 +++++++- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc45d1f..b33182f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add new input quotesManagedBy on appSettings to handle splitting quotes +- Verify if seller accept manage quotes +- Notify seller with quote payload if it accepts to manage quotes ## [2.6.4] - 2024-10-31 diff --git a/node/clients/SellerQuotesClient.ts b/node/clients/SellerQuotesClient.ts index 6426d8c..9d8e758 100644 --- a/node/clients/SellerQuotesClient.ts +++ b/node/clients/SellerQuotesClient.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import type { InstanceOptions, IOContext } from '@vtex/api' import { ExternalClient } from '@vtex/api' @@ -9,10 +10,19 @@ const SELLER_CLIENT_OPTIONS: InstanceOptions = { initialBackoffDelay: 100, } +interface VerifyQuoteSettingsResponse { + receiveQuotes: boolean +} + interface NotifySellerQuoteResponse { status: string } +const routes = { + verifyQuoteSettings: '/verify-quote-settings', + notifyNewQuote: '/notify-new-quote', +} + export default class SellerQuotesClient extends ExternalClient { constructor(ctx: IOContext, options?: InstanceOptions) { super('', ctx, { @@ -26,22 +36,48 @@ export default class SellerQuotesClient extends ExternalClient { }) } - private getUrl(account: string) { + private getRoute(account: string, path: string) { const subdomain = this.context.production ? account : `${this.context.workspace}--${account}` - return `http://${subdomain}.myvtex.com/_v/b2b-seller-quotes/notify-quote` + return `http://${subdomain}.myvtex.com/_v/b2b-seller-quotes${path}` } - public async notify(account: string, quote: Quote) { + public async verifyQuoteSettings(account: string) { return this.http - .postRaw(this.getUrl(account), quote) + .get( + this.getRoute(account, routes.verifyQuoteSettings) + ) .then((res) => { - // eslint-disable-next-line no-console - console.log('RESPONSE', res) + console.log('==================================================') + console.log('SUCCESS RESPONSE WHEN VERIFY SELLER:', res) return res }) + .catch((err) => { + console.log('==================================================') + console.log('ERROR WHEN VEFIFY SELLER:', err) + throw err + }) + } + + public async notifyNewQuote(account: string, quote: Quote) { + return this.http + .postRaw( + this.getRoute(account, routes.notifyNewQuote), + quote + ) + .then((res) => { + console.log('==================================================') + console.log('SUCCESS RESPONSE WHEN NOTIFY SELLER:', res) + + return res + }) + .catch((err) => { + console.log('==================================================') + console.log('ERROR WHEN NOTIFY SELLER:', err) + throw err + }) } } diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index 1fb34b3..ae61349 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -124,6 +124,12 @@ export const Mutation = { const documentIds = await Promise.all( Object.entries(quoteBySeller).map(async ([seller, sellerQuote]) => { + const verifyResponse = await ctx.clients.sellerQuotes.verifyQuoteSettings( + seller + ) + + if (!verifyResponse.receiveQuotes) return null + const sellerQuoteObject = createQuoteObject({ sessionData, storefrontPermissions, @@ -145,11 +151,16 @@ export const Mutation = { schema: SCHEMA_VERSION, }) + await ctx.clients.sellerQuotes.notifyNewQuote(seller, { + id: data.DocumentId, + ...sellerQuoteObject, + }) + return data.DocumentId }) ) - if (documentIds.length) { + if (documentIds.filter(Boolean).length) { await masterdata.updatePartialDocument({ dataEntity: QUOTE_DATA_ENTITY, fields: { hasChildren: true }, From caccc46a8a31f3138c7e3f9526a515d753c9280c Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Wed, 11 Dec 2024 10:11:04 -0300 Subject: [PATCH 14/31] chore: removes console.log from SellerQuotesClient --- node/clients/SellerQuotesClient.ts | 39 ++++++------------------------ 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/node/clients/SellerQuotesClient.ts b/node/clients/SellerQuotesClient.ts index 9d8e758..fcd1065 100644 --- a/node/clients/SellerQuotesClient.ts +++ b/node/clients/SellerQuotesClient.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import type { InstanceOptions, IOContext } from '@vtex/api' import { ExternalClient } from '@vtex/api' @@ -45,39 +44,15 @@ export default class SellerQuotesClient extends ExternalClient { } public async verifyQuoteSettings(account: string) { - return this.http - .get( - this.getRoute(account, routes.verifyQuoteSettings) - ) - .then((res) => { - console.log('==================================================') - console.log('SUCCESS RESPONSE WHEN VERIFY SELLER:', res) - - return res - }) - .catch((err) => { - console.log('==================================================') - console.log('ERROR WHEN VEFIFY SELLER:', err) - throw err - }) + return this.http.get( + this.getRoute(account, routes.verifyQuoteSettings) + ) } public async notifyNewQuote(account: string, quote: Quote) { - return this.http - .postRaw( - this.getRoute(account, routes.notifyNewQuote), - quote - ) - .then((res) => { - console.log('==================================================') - console.log('SUCCESS RESPONSE WHEN NOTIFY SELLER:', res) - - return res - }) - .catch((err) => { - console.log('==================================================') - console.log('ERROR WHEN NOTIFY SELLER:', err) - throw err - }) + return this.http.postRaw( + this.getRoute(account, routes.notifyNewQuote), + quote + ) } } From 34901e0f83eb8472975b3eebdd529168e35ed15e Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Sat, 14 Dec 2024 20:07:01 -0300 Subject: [PATCH 15/31] docs: update CHANGELOG and prettier fix on markdown --- CHANGELOG.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc45d1f..dc3f649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,36 +8,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Add new input quotesManagedBy on appSettings to handle splitting quotes + +- Add field quotesManagedBy on appSettings to handle splitting quotes ## [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 @@ -79,15 +86,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 From 70d1c4d2a529d5aef1cabd283ac2d147f0c4bced Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Sat, 14 Dec 2024 20:20:51 -0300 Subject: [PATCH 16/31] fix: right splitting of seller quotes --- CHANGELOG.md | 7 +- node/clients/SellerQuotesClient.ts | 34 +++++---- node/resolvers/mutations/index.ts | 107 ++++++++++++++++------------- node/resolvers/utils/quotes.ts | 59 ++++++++++++++++ 4 files changed, 142 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78286ab..58df8ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add new input quotesManagedBy on appSettings to handle splitting quotes -- Add new quote configuration to handle split quote by seller -- Verify if seller accept manage quotes -- Notify seller with quote payload if it accepts to manage quotes +- 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 diff --git a/node/clients/SellerQuotesClient.ts b/node/clients/SellerQuotesClient.ts index fcd1065..0b0d9d1 100644 --- a/node/clients/SellerQuotesClient.ts +++ b/node/clients/SellerQuotesClient.ts @@ -13,13 +13,14 @@ interface VerifyQuoteSettingsResponse { receiveQuotes: boolean } -interface NotifySellerQuoteResponse { - status: string +interface SellerQuoteNotifyInput { + quoteId: string + marketplaceAccount: string } const routes = { - verifyQuoteSettings: '/verify-quote-settings', - notifyNewQuote: '/notify-new-quote', + verifyQuoteSettings: 'verify-quote-settings', + notifyNewQuote: 'notify-new-quote', } export default class SellerQuotesClient extends ExternalClient { @@ -35,24 +36,29 @@ export default class SellerQuotesClient extends ExternalClient { }) } - private getRoute(account: string, path: string) { + private getRoute(sellerAccount: string, path: string) { const subdomain = this.context.production - ? account - : `${this.context.workspace}--${account}` + ? sellerAccount + : `${this.context.workspace}--${sellerAccount}` - return `http://${subdomain}.myvtex.com/_v/b2b-seller-quotes${path}` + return `http://${subdomain}.myvtex.com/b2b-seller-quotes/_v/0/${path}` } - public async verifyQuoteSettings(account: string) { + public async verifyQuoteSettings(sellerAccount: string) { return this.http.get( - this.getRoute(account, routes.verifyQuoteSettings) + this.getRoute(sellerAccount, routes.verifyQuoteSettings) ) } - public async notifyNewQuote(account: string, quote: Quote) { - return this.http.postRaw( - this.getRoute(account, routes.notifyNewQuote), - quote + public async notifyNewQuote(sellerAccount: string, quoteId: string) { + const payload: SellerQuoteNotifyInput = { + quoteId, + marketplaceAccount: this.context.account, + } + + return this.http.postRaw( + this.getRoute(sellerAccount, routes.notifyNewQuote), + payload ) } } diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index ae61349..f7e30c2 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -24,7 +24,7 @@ import { checkQuoteStatus, checkSession, } from '../utils/checkPermissions' -import { createQuoteObject } from '../utils/quotes' +import { createQuoteObject, processSellerItems } from '../utils/quotes' export const Mutation = { clearCart: async (_: any, params: any, ctx: Context) => { @@ -81,55 +81,68 @@ export const Mutation = { throw new GraphQLError('operation-not-permitted') } - const parentQuote = createQuoteObject({ - sessionData, - storefrontPermissions, - segmentData, - settings, - items, - referenceName, - subtotal, - note, - sendToSalesRep, - }) - try { - const { DocumentId: parentQuoteId } = await masterdata.createDocument({ - dataEntity: QUOTE_DATA_ENTITY, - fields: parentQuote, - schema: SCHEMA_VERSION, - }) + const quoteBySeller: Record = {} if (settings?.adminSetup.quotesManagedBy === 'SELLER') { - const quoteBySeller: Record = {} - - items.forEach(({ seller, sellingPrice, ...itemData }) => { - if (!quoteBySeller[seller]) { - quoteBySeller[seller] = { - items: [], - referenceName, - note, - sendToSalesRep, - subtotal: 0, - } - } + const sellerItems = items.filter( + ({ seller }) => seller && seller !== '1' + ) - quoteBySeller[seller].items.push({ - seller, - sellingPrice, - ...itemData, - }) - quoteBySeller[seller].subtotal += sellingPrice * itemData.quantity + await processSellerItems({ + ctx, + quoteBySeller, + referenceName, + note, + sendToSalesRep, + items: sellerItems, }) + } - const documentIds = await Promise.all( - Object.entries(quoteBySeller).map(async ([seller, sellerQuote]) => { - const verifyResponse = await ctx.clients.sellerQuotes.verifyQuoteSettings( - seller - ) + const hasSellerQuotes = Object.keys(quoteBySeller).length + + const parentQuoteItems = hasSellerQuotes + ? items.filter( + (item) => + !Object.values(quoteBySeller).some((quote) => + quote.items.some((quoteItem) => quoteItem.id === item.id) + ) + ) + : items + + // 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({ + sessionData, + storefrontPermissions, + segmentData, + settings, + items: parentQuoteItems, + referenceName, + subtotal, + note, + sendToSalesRep, + }) - if (!verifyResponse.receiveQuotes) return null + 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({ sessionData, storefrontPermissions, @@ -151,16 +164,16 @@ export const Mutation = { schema: SCHEMA_VERSION, }) - await ctx.clients.sellerQuotes.notifyNewQuote(seller, { - id: data.DocumentId, - ...sellerQuoteObject, - }) + await ctx.clients.sellerQuotes.notifyNewQuote( + seller, + data.DocumentId + ) return data.DocumentId }) ) - if (documentIds.filter(Boolean).length) { + if (sellerQuoteIds.length) { await masterdata.updatePartialDocument({ dataEntity: QUOTE_DATA_ENTITY, fields: { hasChildren: true }, diff --git a/node/resolvers/utils/quotes.ts b/node/resolvers/utils/quotes.ts index 8a8cc44..9e614c7 100644 --- a/node/resolvers/utils/quotes.ts +++ b/node/resolvers/utils/quotes.ts @@ -1,3 +1,62 @@ +export async function processSellerItems({ + ctx, + quoteBySeller, + referenceName, + note, + sendToSalesRep, + items, + index = 0, +}: { + ctx: Context + quoteBySeller: Record + referenceName: string + note: string + sendToSalesRep: boolean + items: QuoteItem[] + index?: number +}): Promise { + if (index >= items.length) return + + const item = items[index] + const { seller } = item + + const next = async () => + processSellerItems({ + ctx, + quoteBySeller, + referenceName, + note, + sendToSalesRep, + items, + index: index + 1, + }) + + const verifyResponse = await ctx.clients.sellerQuotes + .verifyQuoteSettings(seller) + .catch(() => null) + + if (!verifyResponse?.receiveQuotes) { + await next() + + return + } + + if (!quoteBySeller[seller]) { + quoteBySeller[seller] = { + items: [], + referenceName, + note, + sendToSalesRep, + subtotal: 0, + } + } + + quoteBySeller[seller].items.push(item) + quoteBySeller[seller].subtotal += item.sellingPrice * item.quantity + + await next() +} + export const createQuoteObject = ({ sessionData, storefrontPermissions, From 69d2076079286bbd4005d1f6390ff9f08813e350 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Sun, 15 Dec 2024 00:45:56 -0300 Subject: [PATCH 17/31] feat: refactor on creating seller quote map; avoid double check of seller --- node/clients/SellerQuotesClient.ts | 9 ------ node/resolvers/mutations/index.ts | 48 ++++++++++++++---------------- node/resolvers/utils/quotes.ts | 48 +++++++++++++----------------- node/typings.d.ts | 14 +++++++-- 4 files changed, 54 insertions(+), 65 deletions(-) diff --git a/node/clients/SellerQuotesClient.ts b/node/clients/SellerQuotesClient.ts index 0b0d9d1..827c388 100644 --- a/node/clients/SellerQuotesClient.ts +++ b/node/clients/SellerQuotesClient.ts @@ -9,15 +9,6 @@ const SELLER_CLIENT_OPTIONS: InstanceOptions = { initialBackoffDelay: 100, } -interface VerifyQuoteSettingsResponse { - receiveQuotes: boolean -} - -interface SellerQuoteNotifyInput { - quoteId: string - marketplaceAccount: string -} - const routes = { verifyQuoteSettings: 'verify-quote-settings', notifyNewQuote: 'notify-new-quote', diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index f7e30c2..c55abd9 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -24,7 +24,11 @@ import { checkQuoteStatus, checkSession, } from '../utils/checkPermissions' -import { createQuoteObject, processSellerItems } from '../utils/quotes' +import { + createItemComparator, + createQuoteObject, + splitItemsBySeller, +} from '../utils/quotes' export const Mutation = { clearCart: async (_: any, params: any, ctx: Context) => { @@ -82,19 +86,15 @@ export const Mutation = { } try { - const quoteBySeller: Record = {} + let quoteBySeller: SellerQuoteMap = {} if (settings?.adminSetup.quotesManagedBy === 'SELLER') { const sellerItems = items.filter( ({ seller }) => seller && seller !== '1' ) - await processSellerItems({ + quoteBySeller = await splitItemsBySeller({ ctx, - quoteBySeller, - referenceName, - note, - sendToSalesRep, items: sellerItems, }) } @@ -105,15 +105,25 @@ export const Mutation = { ? items.filter( (item) => !Object.values(quoteBySeller).some((quote) => - quote.items.some((quoteItem) => quoteItem.id === item.id) + quote.items.some(createItemComparator(item)) ) ) : items + const quoteCommonFields = { + sessionData, + storefrontPermissions, + segmentData, + settings, + referenceName, + note, + sendToSalesRep, + } + // 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). + // it to subtotal in createQuoteObject -> `subtotal: adjustedSubtotal` // // const adjustedSubtotal = hasSellerQuotes // ? Object.values(quoteBySeller).reduce( @@ -121,17 +131,10 @@ export const Mutation = { // subtotal // ) // : subtotal - const parentQuote = createQuoteObject({ - sessionData, - storefrontPermissions, - segmentData, - settings, + ...quoteCommonFields, items: parentQuoteItems, - referenceName, subtotal, - note, - sendToSalesRep, }) const { DocumentId: parentQuoteId } = await masterdata.createDocument({ @@ -144,15 +147,8 @@ export const Mutation = { const sellerQuoteIds = await Promise.all( Object.entries(quoteBySeller).map(async ([seller, sellerQuote]) => { const sellerQuoteObject = createQuoteObject({ - sessionData, - storefrontPermissions, - segmentData, - settings, - items: sellerQuote.items, - referenceName: sellerQuote.referenceName, - subtotal: sellerQuote.subtotal, - note: sellerQuote.note, - sendToSalesRep: sellerQuote.sendToSalesRep, + ...quoteCommonFields, + ...sellerQuote, seller, approvedBySeller: false, parentQuote: parentQuoteId, diff --git a/node/resolvers/utils/quotes.ts b/node/resolvers/utils/quotes.ts index 9e614c7..7ec7d6d 100644 --- a/node/resolvers/utils/quotes.ts +++ b/node/resolvers/utils/quotes.ts @@ -1,60 +1,54 @@ -export async function processSellerItems({ +export async function splitItemsBySeller({ ctx, - quoteBySeller, - referenceName, - note, - sendToSalesRep, items, + quoteBySeller = {}, index = 0, }: { ctx: Context - quoteBySeller: Record - referenceName: string - note: string - sendToSalesRep: boolean items: QuoteItem[] + quoteBySeller?: SellerQuoteMap index?: number -}): Promise { - if (index >= items.length) return +}): Promise { + if (index >= items.length) return quoteBySeller const item = items[index] const { seller } = item const next = async () => - processSellerItems({ + splitItemsBySeller({ ctx, - quoteBySeller, - referenceName, - note, - sendToSalesRep, items, + quoteBySeller, index: index + 1, }) - const verifyResponse = await ctx.clients.sellerQuotes - .verifyQuoteSettings(seller) - .catch(() => null) + // 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 + return quoteBySeller } if (!quoteBySeller[seller]) { - quoteBySeller[seller] = { - items: [], - referenceName, - note, - sendToSalesRep, - subtotal: 0, - } + 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 = ({ diff --git a/node/typings.d.ts b/node/typings.d.ts index 9b471ec..b4585b8 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -135,8 +135,16 @@ interface SessionData { interface SellerQuoteInput { items: QuoteItem[] - referenceName: string - note: string - sendToSalesRep: boolean subtotal: number } + +type SellerQuoteMap = Record + +interface VerifyQuoteSettingsResponse { + receiveQuotes: boolean +} + +interface SellerQuoteNotifyInput { + quoteId: string + marketplaceAccount: string +} From fbb652920b29896d6d2c0b1b9575d72b75e4aa17 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Sun, 15 Dec 2024 00:52:54 -0300 Subject: [PATCH 18/31] refactor: seller quotes client constants --- node/clients/SellerQuotesClient.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/node/clients/SellerQuotesClient.ts b/node/clients/SellerQuotesClient.ts index 827c388..b784916 100644 --- a/node/clients/SellerQuotesClient.ts +++ b/node/clients/SellerQuotesClient.ts @@ -9,7 +9,9 @@ const SELLER_CLIENT_OPTIONS: InstanceOptions = { initialBackoffDelay: 100, } -const routes = { +const BASE_PATH = 'b2b-seller-quotes/_v/0' + +const ROUTES = { verifyQuoteSettings: 'verify-quote-settings', notifyNewQuote: 'notify-new-quote', } @@ -32,12 +34,12 @@ export default class SellerQuotesClient extends ExternalClient { ? sellerAccount : `${this.context.workspace}--${sellerAccount}` - return `http://${subdomain}.myvtex.com/b2b-seller-quotes/_v/0/${path}` + return `http://${subdomain}.myvtex.com/${BASE_PATH}/${path}` } public async verifyQuoteSettings(sellerAccount: string) { return this.http.get( - this.getRoute(sellerAccount, routes.verifyQuoteSettings) + this.getRoute(sellerAccount, ROUTES.verifyQuoteSettings) ) } @@ -48,7 +50,7 @@ export default class SellerQuotesClient extends ExternalClient { } return this.http.postRaw( - this.getRoute(sellerAccount, routes.notifyNewQuote), + this.getRoute(sellerAccount, ROUTES.notifyNewQuote), payload ) } From 021b3591cf247bd195d2c54a825b1f0e81feccd5 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Sun, 15 Dec 2024 17:08:47 -0300 Subject: [PATCH 19/31] feat: send creationDate on notify seller quote --- node/clients/SellerQuotesClient.ts | 7 ++++++- node/resolvers/mutations/index.ts | 3 ++- node/typings.d.ts | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/node/clients/SellerQuotesClient.ts b/node/clients/SellerQuotesClient.ts index b784916..74bc681 100644 --- a/node/clients/SellerQuotesClient.ts +++ b/node/clients/SellerQuotesClient.ts @@ -43,9 +43,14 @@ export default class SellerQuotesClient extends ExternalClient { ) } - public async notifyNewQuote(sellerAccount: string, quoteId: string) { + public async notifyNewQuote( + sellerAccount: string, + quoteId: string, + creationDate: string + ) { const payload: SellerQuoteNotifyInput = { quoteId, + creationDate, marketplaceAccount: this.context.account, } diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index c55abd9..a4197a3 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -162,7 +162,8 @@ export const Mutation = { await ctx.clients.sellerQuotes.notifyNewQuote( seller, - data.DocumentId + data.DocumentId, + sellerQuoteObject.creationDate ) return data.DocumentId diff --git a/node/typings.d.ts b/node/typings.d.ts index b4585b8..fc34590 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -147,4 +147,5 @@ interface VerifyQuoteSettingsResponse { interface SellerQuoteNotifyInput { quoteId: string marketplaceAccount: string + creationDate: string } From a69ae200d8f2477d5b5fad42be68ee2613dee6d7 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Sun, 15 Dec 2024 19:06:05 -0300 Subject: [PATCH 20/31] feat: provides a route for seller get a quote by id at marketplace --- node/index.ts | 9 +++- node/resolvers/routes/index.ts | 18 ++++++- .../resolvers/routes/seller/getSellerQuote.ts | 47 +++++++++++++++++++ node/resolvers/routes/seller/utils.ts | 31 ++++++++++++ node/service.json | 12 +++++ 5 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 node/resolvers/routes/seller/getSellerQuote.ts create mode 100644 node/resolvers/routes/seller/utils.ts diff --git a/node/index.ts b/node/index.ts index 6612227..c25eff4 100644 --- a/node/index.ts +++ b/node/index.ts @@ -39,7 +39,14 @@ const clients: ClientsConfig = { declare global { // We declare a global Context type just to avoid re-writing ServiceContext in every handler and resolver - type Context = ServiceContext + type Context = ServiceContext< + Clients, + RecorderState & { + seller?: string + } + > + + type NextFn = () => Promise // The shape of our State object found in `ctx.state`. This is used as state bag to communicate between middlewares. interface State { diff --git a/node/resolvers/routes/index.ts b/node/resolvers/routes/index.ts index 262cbc0..60cbaee 100644 --- a/node/resolvers/routes/index.ts +++ b/node/resolvers/routes/index.ts @@ -1,5 +1,18 @@ -import { processQueue } from '../../utils/Queue' +import { method } from '@vtex/api' + import { getAppId } from '../../constants' +import { processQueue } from '../../utils/Queue' +import { getSellerQuote } from './seller/getSellerQuote' +import { + setSellerResponseMetadata, + validateSellerRequest, +} from './seller/utils' + +function createSellerHandlers( + mainHandler: (ctx: Context, next: NextFn) => Promise +) { + return [validateSellerRequest, mainHandler, setSellerResponseMetadata] +} export const Routes = { host: async (ctx: Context) => { @@ -18,4 +31,7 @@ export const Routes = { ctx.response.body = { date, appId: getAppId() } ctx.response.status = 200 }, + getSellerQuote: method({ + GET: createSellerHandlers(getSellerQuote), + }), } diff --git a/node/resolvers/routes/seller/getSellerQuote.ts b/node/resolvers/routes/seller/getSellerQuote.ts new file mode 100644 index 0000000..da81b4c --- /dev/null +++ b/node/resolvers/routes/seller/getSellerQuote.ts @@ -0,0 +1,47 @@ +import { NotFoundError, UserInputError } from '@vtex/api' + +import { + QUOTE_DATA_ENTITY, + QUOTE_FIELDS, + SCHEMA_VERSION, +} from '../../../constants' +import { + costCenterName as getCostCenterName, + organizationName as getOrganizationName, +} from '../../fieldResolvers' +import { invalidParam } from './utils' + +export async function getSellerQuote(ctx: Context, next: NextFn) { + const { id } = ctx.vtex.route.params + const { seller } = ctx.state + + if (!seller || invalidParam(id)) { + throw new UserInputError('get-seller-quote-invalid-params') + } + + const [quote] = await ctx.clients.masterdata.searchDocuments({ + dataEntity: QUOTE_DATA_ENTITY, + fields: QUOTE_FIELDS, + pagination: { page: 1, pageSize: 1 }, + schema: SCHEMA_VERSION, + where: `id=${id} AND seller=${seller}`, + }) + + if (!quote) { + throw new NotFoundError('seller-quote-not-found') + } + + const { organization, costCenter } = quote + + const organizationName = await getOrganizationName( + { organization }, + null, + ctx + ) + + const costCenterName = await getCostCenterName({ costCenter }, null, ctx) + + ctx.body = { ...quote, organizationName, costCenterName } + + await next() +} diff --git a/node/resolvers/routes/seller/utils.ts b/node/resolvers/routes/seller/utils.ts new file mode 100644 index 0000000..dbb564e --- /dev/null +++ b/node/resolvers/routes/seller/utils.ts @@ -0,0 +1,31 @@ +import { ForbiddenError } from '@vtex/api' + +const USER_AGENT_REGEX = /^vtex\.b2b-seller-quotes@\d+.\d+.\d+$/ + +export function invalidParam( + param?: string | string[] +): param is string[] | undefined { + return !param || Array.isArray(param) +} + +export async function validateSellerRequest(ctx: Context, next: NextFn) { + const { seller } = ctx.vtex.route.params + + if ( + invalidParam(seller) || + seller !== ctx.headers['x-vtex-origin-account'] || + !ctx.headers['user-agent']?.match(USER_AGENT_REGEX) + ) { + throw new ForbiddenError('request-not-allowed') + } + + ctx.state.seller = seller + + await next() +} + +export async function setSellerResponseMetadata(ctx: Context) { + ctx.set('Access-Control-Allow-Origin', '*') + ctx.set('Content-Type', 'application/json') + ctx.status = 200 +} diff --git a/node/service.json b/node/service.json index e63a435..8647dff 100644 --- a/node/service.json +++ b/node/service.json @@ -32,6 +32,18 @@ "principals": ["vrn:apps:*:*:*:app/vtex.scheduler@*"] } ] + }, + "getSellerQuote": { + "path": "/b2b-quotes-graphql/_v/0/get-seller-quote/:seller/:id", + "public": true, + "access": "authorized", + "policies": [ + { + "effect": "allow", + "actions": ["get"], + "principals": ["vrn:apps:*:*:*:app/vtex.b2b-seller-quotes@*"] + } + ] } } } From eeac6ab984a3a928d7619f84df1e8867b663cf02 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Sun, 15 Dec 2024 19:07:09 -0300 Subject: [PATCH 21/31] docs: update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58df8ce..a2181a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 +- Provides a route for seller get a quote by id at marketplace ## [2.6.4] - 2024-10-31 From 9a7c79212d0fecdf62e80be56968e377d03ab766 Mon Sep 17 00:00:00 2001 From: Tiago Freire Date: Tue, 17 Dec 2024 15:09:32 +0000 Subject: [PATCH 22/31] refactor: separating get seller quote into smaller functions --- .../resolvers/routes/seller/getSellerQuote.ts | 40 ++--------- node/resolvers/utils/quotes.ts | 69 +++++++++++++++++++ 2 files changed, 75 insertions(+), 34 deletions(-) diff --git a/node/resolvers/routes/seller/getSellerQuote.ts b/node/resolvers/routes/seller/getSellerQuote.ts index da81b4c..9633f55 100644 --- a/node/resolvers/routes/seller/getSellerQuote.ts +++ b/node/resolvers/routes/seller/getSellerQuote.ts @@ -1,47 +1,19 @@ -import { NotFoundError, UserInputError } from '@vtex/api' +import { UserInputError } from '@vtex/api' -import { - QUOTE_DATA_ENTITY, - QUOTE_FIELDS, - SCHEMA_VERSION, -} from '../../../constants' -import { - costCenterName as getCostCenterName, - organizationName as getOrganizationName, -} from '../../fieldResolvers' +import { getFullSellerQuote } from '../../utils/quotes' import { invalidParam } from './utils' export async function getSellerQuote(ctx: Context, next: NextFn) { const { id } = ctx.vtex.route.params - const { seller } = ctx.state - if (!seller || invalidParam(id)) { + if (invalidParam(id)) { throw new UserInputError('get-seller-quote-invalid-params') } - const [quote] = await ctx.clients.masterdata.searchDocuments({ - dataEntity: QUOTE_DATA_ENTITY, - fields: QUOTE_FIELDS, - pagination: { page: 1, pageSize: 1 }, - schema: SCHEMA_VERSION, - where: `id=${id} AND seller=${seller}`, - }) + const { seller } = ctx.state as { seller: string } + const quote = await getFullSellerQuote(ctx, seller, id) - if (!quote) { - throw new NotFoundError('seller-quote-not-found') - } - - const { organization, costCenter } = quote - - const organizationName = await getOrganizationName( - { organization }, - null, - ctx - ) - - const costCenterName = await getCostCenterName({ costCenter }, null, ctx) - - ctx.body = { ...quote, organizationName, costCenterName } + ctx.body = quote await next() } diff --git a/node/resolvers/utils/quotes.ts b/node/resolvers/utils/quotes.ts index 7ec7d6d..bf371ca 100644 --- a/node/resolvers/utils/quotes.ts +++ b/node/resolvers/utils/quotes.ts @@ -1,3 +1,15 @@ +import { NotFoundError } from '@vtex/api' + +import { + QUOTE_DATA_ENTITY, + QUOTE_FIELDS, + SCHEMA_VERSION, +} from '../../constants' +import { + costCenterName as getCostCenterName, + organizationName as getOrganizationName, +} from '../fieldResolvers' + export async function splitItemsBySeller({ ctx, items, @@ -136,3 +148,60 @@ export const createQuoteObject = ({ hasChildren, } } + +type GetQuotesArgs = { + ctx: Context + where?: string + sort?: string + page?: number + pageSize?: number +} + +export async function getQuotes({ + ctx, + page = 1, + pageSize = 1, + where, + sort, +}: GetQuotesArgs) { + return ctx.clients.masterdata.searchDocuments({ + dataEntity: QUOTE_DATA_ENTITY, + fields: QUOTE_FIELDS, + schema: SCHEMA_VERSION, + pagination: { page, pageSize }, + where, + sort, + }) +} + +export async function getSellerQuote(ctx: Context, seller: string, id: string) { + const [quote] = await getQuotes({ + ctx, + where: `id=${id} AND seller=${seller}`, + }) + + if (!quote) { + throw new NotFoundError('seller-quote-not-found') + } + + return quote +} + +export async function getFullSellerQuote( + ctx: Context, + seller: string, + id: string +) { + const quote = await getSellerQuote(ctx, id, seller) + const { organization, costCenter } = quote + + const organizationName = await getOrganizationName( + { organization }, + null, + ctx + ) + + const costCenterName = await getCostCenterName({ costCenter }, null, ctx) + + return { ...quote, organizationName, costCenterName } +} From 139e69631059a131416cc4317c6ac036e26888b8 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Tue, 17 Dec 2024 16:43:01 -0300 Subject: [PATCH 23/31] refactor: function to get org anc cost center names --- node/resolvers/utils/quotes.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/node/resolvers/utils/quotes.ts b/node/resolvers/utils/quotes.ts index bf371ca..fcee5b5 100644 --- a/node/resolvers/utils/quotes.ts +++ b/node/resolvers/utils/quotes.ts @@ -151,10 +151,10 @@ export const createQuoteObject = ({ type GetQuotesArgs = { ctx: Context - where?: string - sort?: string page?: number pageSize?: number + where?: string + sort?: string } export async function getQuotes({ @@ -187,21 +187,25 @@ export async function getSellerQuote(ctx: Context, seller: string, id: string) { return quote } +export async function getOrganizationData(ctx: Context, quote: Quote) { + const [organizationName, costCenterName] = await Promise.all([ + getOrganizationName({ organization: quote.organization }, null, ctx), + getCostCenterName({ costCenter: quote.costCenter }, null, ctx), + ]) + + return { organizationName, costCenterName } +} + export async function getFullSellerQuote( ctx: Context, seller: string, id: string ) { const quote = await getSellerQuote(ctx, id, seller) - const { organization, costCenter } = quote - - const organizationName = await getOrganizationName( - { organization }, - null, - ctx + const { organizationName, costCenterName } = await getOrganizationData( + ctx, + quote ) - const costCenterName = await getCostCenterName({ costCenter }, null, ctx) - return { ...quote, organizationName, costCenterName } } From 25be1ceacea0a8bf4a93fef4d13b09b376ae8b5d Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Tue, 17 Dec 2024 17:43:17 -0300 Subject: [PATCH 24/31] refactor: create service class for seller quotes --- node/index.ts | 3 +- .../resolvers/routes/seller/getSellerQuote.ts | 4 +- node/resolvers/routes/seller/utils.ts | 4 +- node/resolvers/utils/quotes.ts | 73 ------------------- node/resolvers/utils/sellerQuotesService.ts | 66 +++++++++++++++++ 5 files changed, 72 insertions(+), 78 deletions(-) create mode 100644 node/resolvers/utils/sellerQuotesService.ts diff --git a/node/index.ts b/node/index.ts index c25eff4..48bd61b 100644 --- a/node/index.ts +++ b/node/index.ts @@ -11,6 +11,7 @@ import { Clients } from './clients' import { orderHandler } from './middlewares/order' import { resolvers } from './resolvers' import { schemaDirectives } from './resolvers/directives' +import type SellerQuotesService from './resolvers/utils/sellerQuotesService' const TIMEOUT_MS = 5000 @@ -42,7 +43,7 @@ declare global { type Context = ServiceContext< Clients, RecorderState & { - seller?: string + sellerQuotesService?: SellerQuotesService } > diff --git a/node/resolvers/routes/seller/getSellerQuote.ts b/node/resolvers/routes/seller/getSellerQuote.ts index 9633f55..f1bc7d2 100644 --- a/node/resolvers/routes/seller/getSellerQuote.ts +++ b/node/resolvers/routes/seller/getSellerQuote.ts @@ -1,6 +1,5 @@ import { UserInputError } from '@vtex/api' -import { getFullSellerQuote } from '../../utils/quotes' import { invalidParam } from './utils' export async function getSellerQuote(ctx: Context, next: NextFn) { @@ -10,8 +9,7 @@ export async function getSellerQuote(ctx: Context, next: NextFn) { throw new UserInputError('get-seller-quote-invalid-params') } - const { seller } = ctx.state as { seller: string } - const quote = await getFullSellerQuote(ctx, seller, id) + const quote = await ctx.state.sellerQuotesService?.getFullSellerQuote(id) ctx.body = quote diff --git a/node/resolvers/routes/seller/utils.ts b/node/resolvers/routes/seller/utils.ts index dbb564e..c496a16 100644 --- a/node/resolvers/routes/seller/utils.ts +++ b/node/resolvers/routes/seller/utils.ts @@ -1,5 +1,7 @@ import { ForbiddenError } from '@vtex/api' +import SellerQuotesService from '../../utils/sellerQuotesService' + const USER_AGENT_REGEX = /^vtex\.b2b-seller-quotes@\d+.\d+.\d+$/ export function invalidParam( @@ -19,7 +21,7 @@ export async function validateSellerRequest(ctx: Context, next: NextFn) { throw new ForbiddenError('request-not-allowed') } - ctx.state.seller = seller + ctx.state.sellerQuotesService = new SellerQuotesService(ctx, seller) await next() } diff --git a/node/resolvers/utils/quotes.ts b/node/resolvers/utils/quotes.ts index fcee5b5..7ec7d6d 100644 --- a/node/resolvers/utils/quotes.ts +++ b/node/resolvers/utils/quotes.ts @@ -1,15 +1,3 @@ -import { NotFoundError } from '@vtex/api' - -import { - QUOTE_DATA_ENTITY, - QUOTE_FIELDS, - SCHEMA_VERSION, -} from '../../constants' -import { - costCenterName as getCostCenterName, - organizationName as getOrganizationName, -} from '../fieldResolvers' - export async function splitItemsBySeller({ ctx, items, @@ -148,64 +136,3 @@ export const createQuoteObject = ({ hasChildren, } } - -type GetQuotesArgs = { - ctx: Context - page?: number - pageSize?: number - where?: string - sort?: string -} - -export async function getQuotes({ - ctx, - page = 1, - pageSize = 1, - where, - sort, -}: GetQuotesArgs) { - return ctx.clients.masterdata.searchDocuments({ - dataEntity: QUOTE_DATA_ENTITY, - fields: QUOTE_FIELDS, - schema: SCHEMA_VERSION, - pagination: { page, pageSize }, - where, - sort, - }) -} - -export async function getSellerQuote(ctx: Context, seller: string, id: string) { - const [quote] = await getQuotes({ - ctx, - where: `id=${id} AND seller=${seller}`, - }) - - if (!quote) { - throw new NotFoundError('seller-quote-not-found') - } - - return quote -} - -export async function getOrganizationData(ctx: Context, quote: Quote) { - const [organizationName, costCenterName] = await Promise.all([ - getOrganizationName({ organization: quote.organization }, null, ctx), - getCostCenterName({ costCenter: quote.costCenter }, null, ctx), - ]) - - return { organizationName, costCenterName } -} - -export async function getFullSellerQuote( - ctx: Context, - seller: string, - id: string -) { - const quote = await getSellerQuote(ctx, id, seller) - const { organizationName, costCenterName } = await getOrganizationData( - ctx, - quote - ) - - return { ...quote, organizationName, costCenterName } -} diff --git a/node/resolvers/utils/sellerQuotesService.ts b/node/resolvers/utils/sellerQuotesService.ts new file mode 100644 index 0000000..6883847 --- /dev/null +++ b/node/resolvers/utils/sellerQuotesService.ts @@ -0,0 +1,66 @@ +import { NotFoundError } from '@vtex/api' + +import { + QUOTE_DATA_ENTITY, + QUOTE_FIELDS, + SCHEMA_VERSION, +} from '../../constants' +import { + costCenterName as getCostCenterName, + organizationName as getOrganizationName, +} from '../fieldResolvers' + +type GetQuotesArgs = { + page?: number + pageSize?: number + where?: string + sort?: string +} + +export default class SellerQuotesService { + constructor(private readonly ctx: Context, private readonly seller: string) {} + + private async getSellerQuotes({ + page = 1, + pageSize = 1, + where, + sort, + }: GetQuotesArgs) { + return this.ctx.clients.masterdata.searchDocuments({ + dataEntity: QUOTE_DATA_ENTITY, + fields: QUOTE_FIELDS, + schema: SCHEMA_VERSION, + pagination: { page, pageSize }, + where: `seller=${this.seller} AND (${where})`, + sort, + }) + } + + private async getSellerQuote(id: string) { + const [quote] = await this.getSellerQuotes({ where: `id=${id}` }) + + if (!quote) { + throw new NotFoundError('seller-quote-not-found') + } + + return quote + } + + private async getOrganizationData(quote: Quote) { + const [organizationName, costCenterName] = await Promise.all([ + getOrganizationName({ organization: quote.organization }, null, this.ctx), + getCostCenterName({ costCenter: quote.costCenter }, null, this.ctx), + ]) + + return { organizationName, costCenterName } + } + + public async getFullSellerQuote(id: string) { + const quote = await this.getSellerQuote(id) + const { organizationName, costCenterName } = await this.getOrganizationData( + quote + ) + + return { ...quote, organizationName, costCenterName } + } +} From f6a11178e50e1875592b04970d14d11546457d18 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Tue, 17 Dec 2024 20:28:02 -0300 Subject: [PATCH 25/31] fix: removing approvedBySeller field from quote entity --- graphql/quote.graphql | 1 - node/constants.ts | 7 ------- node/resolvers/mutations/index.ts | 1 - node/resolvers/utils/quotes.ts | 3 --- node/typings.d.ts | 1 - 5 files changed, 13 deletions(-) diff --git a/graphql/quote.graphql b/graphql/quote.graphql index 78f2b35..1fa53b9 100644 --- a/graphql/quote.graphql +++ b/graphql/quote.graphql @@ -60,7 +60,6 @@ type Quote { viewedByCustomer: Boolean salesChannel: String seller: String - approvedBySeller: Boolean parentQuote: String hasChildren: Boolean } diff --git a/node/constants.ts b/node/constants.ts index f9d1926..01dc677 100644 --- a/node/constants.ts +++ b/node/constants.ts @@ -23,7 +23,6 @@ export const QUOTE_FIELDS = [ 'viewedByCustomer', 'salesChannel', 'seller', - 'approvedBySeller', 'parentQuote', 'hasChildren', ] @@ -142,10 +141,6 @@ export const schema = { title: 'Seller', type: ['null', 'string'], }, - approvedBySeller: { - title: 'Quote approved by seller', - type: ['null', 'boolean'], - }, parentQuote: { title: 'Parent quote', type: ['null', 'string'], @@ -166,7 +161,6 @@ export const schema = { 'subtotal', 'status', 'seller', - 'approvedBySeller', 'parentQuote', 'hasChildren', ], @@ -182,7 +176,6 @@ export const schema = { 'costCenter', 'salesChannel', 'seller', - 'approvedBySeller', 'parentQuote', 'hasChildren', ], diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index a4197a3..affc0dc 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -150,7 +150,6 @@ export const Mutation = { ...quoteCommonFields, ...sellerQuote, seller, - approvedBySeller: false, parentQuote: parentQuoteId, }) diff --git a/node/resolvers/utils/quotes.ts b/node/resolvers/utils/quotes.ts index 7ec7d6d..4fddbea 100644 --- a/node/resolvers/utils/quotes.ts +++ b/node/resolvers/utils/quotes.ts @@ -62,7 +62,6 @@ export const createQuoteObject = ({ note, sendToSalesRep, seller, - approvedBySeller, parentQuote, hasChildren, }: { @@ -76,7 +75,6 @@ export const createQuoteObject = ({ note: string sendToSalesRep: boolean seller?: string - approvedBySeller?: boolean | null parentQuote?: string | null hasChildren?: boolean | null }): Omit => { @@ -131,7 +129,6 @@ export const createQuoteObject = ({ viewedBySales: !sendToSalesRep, salesChannel, seller, - approvedBySeller, parentQuote, hasChildren, } diff --git a/node/typings.d.ts b/node/typings.d.ts index fc34590..00bba8b 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -16,7 +16,6 @@ interface Quote { viewedByCustomer: boolean salesChannel: string | null seller?: string | null - approvedBySeller?: boolean | null parentQuote?: string | null hasChildren?: boolean | null } From babbb9aee291f9b2f860af37de090ee50e4506a2 Mon Sep 17 00:00:00 2001 From: Tiago de Andrade Freire Date: Tue, 17 Dec 2024 21:10:55 -0300 Subject: [PATCH 26/31] refactor: renaming seller quotes service to controller --- node/index.ts | 12 ++++++------ node/resolvers/routes/seller/getSellerQuote.ts | 2 +- node/resolvers/routes/seller/utils.ts | 4 ++-- ...lerQuotesService.ts => sellerQuotesController.ts} | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) rename node/resolvers/utils/{sellerQuotesService.ts => sellerQuotesController.ts} (97%) diff --git a/node/index.ts b/node/index.ts index 48bd61b..450571f 100644 --- a/node/index.ts +++ b/node/index.ts @@ -1,6 +1,7 @@ import type { ClientsConfig, EventContext, + IOContext, ParamsContext, RecorderState, ServiceContext, @@ -11,7 +12,7 @@ import { Clients } from './clients' import { orderHandler } from './middlewares/order' import { resolvers } from './resolvers' import { schemaDirectives } from './resolvers/directives' -import type SellerQuotesService from './resolvers/utils/sellerQuotesService' +import type SellerQuotesController from './resolvers/utils/sellerQuotesController' const TIMEOUT_MS = 5000 @@ -40,12 +41,11 @@ const clients: ClientsConfig = { declare global { // We declare a global Context type just to avoid re-writing ServiceContext in every handler and resolver - type Context = ServiceContext< - Clients, - RecorderState & { - sellerQuotesService?: SellerQuotesService + type Context = ServiceContext & { + vtex: IOContext & { + sellerQuotesController?: SellerQuotesController } - > + } type NextFn = () => Promise diff --git a/node/resolvers/routes/seller/getSellerQuote.ts b/node/resolvers/routes/seller/getSellerQuote.ts index f1bc7d2..869272c 100644 --- a/node/resolvers/routes/seller/getSellerQuote.ts +++ b/node/resolvers/routes/seller/getSellerQuote.ts @@ -9,7 +9,7 @@ export async function getSellerQuote(ctx: Context, next: NextFn) { throw new UserInputError('get-seller-quote-invalid-params') } - const quote = await ctx.state.sellerQuotesService?.getFullSellerQuote(id) + const quote = await ctx.vtex.sellerQuotesController?.getFullSellerQuote(id) ctx.body = quote diff --git a/node/resolvers/routes/seller/utils.ts b/node/resolvers/routes/seller/utils.ts index c496a16..d83e83a 100644 --- a/node/resolvers/routes/seller/utils.ts +++ b/node/resolvers/routes/seller/utils.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from '@vtex/api' -import SellerQuotesService from '../../utils/sellerQuotesService' +import SellerQuotesController from '../../utils/sellerQuotesController' const USER_AGENT_REGEX = /^vtex\.b2b-seller-quotes@\d+.\d+.\d+$/ @@ -21,7 +21,7 @@ export async function validateSellerRequest(ctx: Context, next: NextFn) { throw new ForbiddenError('request-not-allowed') } - ctx.state.sellerQuotesService = new SellerQuotesService(ctx, seller) + ctx.vtex.sellerQuotesController = new SellerQuotesController(ctx, seller) await next() } diff --git a/node/resolvers/utils/sellerQuotesService.ts b/node/resolvers/utils/sellerQuotesController.ts similarity index 97% rename from node/resolvers/utils/sellerQuotesService.ts rename to node/resolvers/utils/sellerQuotesController.ts index 6883847..8a0617a 100644 --- a/node/resolvers/utils/sellerQuotesService.ts +++ b/node/resolvers/utils/sellerQuotesController.ts @@ -17,7 +17,7 @@ type GetQuotesArgs = { sort?: string } -export default class SellerQuotesService { +export default class SellerQuotesController { constructor(private readonly ctx: Context, private readonly seller: string) {} private async getSellerQuotes({ From 440e180aaec5cc1490a2a3bb0d8a04bca9adca43 Mon Sep 17 00:00:00 2001 From: Guido Bernal Date: Wed, 18 Dec 2024 10:07:33 -0300 Subject: [PATCH 27/31] feat: get seller quotes paginated --- node/resolvers/routes/index.ts | 4 + .../routes/seller/getSellerQuotesPaginated.ts | 85 +++++++++++++++++++ node/service.json | 12 +++ package.json | 3 +- 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 node/resolvers/routes/seller/getSellerQuotesPaginated.ts diff --git a/node/resolvers/routes/index.ts b/node/resolvers/routes/index.ts index 60cbaee..24a1be6 100644 --- a/node/resolvers/routes/index.ts +++ b/node/resolvers/routes/index.ts @@ -3,6 +3,7 @@ import { method } from '@vtex/api' import { getAppId } from '../../constants' import { processQueue } from '../../utils/Queue' import { getSellerQuote } from './seller/getSellerQuote' +import { getSellerQuotesPaginated } from './seller/getSellerQuotesPaginated' import { setSellerResponseMetadata, validateSellerRequest, @@ -34,4 +35,7 @@ export const Routes = { getSellerQuote: method({ GET: createSellerHandlers(getSellerQuote), }), + getSellerQuotesPaginated: method({ + GET: createSellerHandlers(getSellerQuotesPaginated), + }), } diff --git a/node/resolvers/routes/seller/getSellerQuotesPaginated.ts b/node/resolvers/routes/seller/getSellerQuotesPaginated.ts new file mode 100644 index 0000000..41258b2 --- /dev/null +++ b/node/resolvers/routes/seller/getSellerQuotesPaginated.ts @@ -0,0 +1,85 @@ +import { NotFoundError, UserInputError } from '@vtex/api' +import pLimit from 'p-limit' + +import { + QUOTE_DATA_ENTITY, + QUOTE_FIELDS, + SCHEMA_VERSION, +} from '../../../constants' +import { + costCenterName as getCostCenterName, + organizationName as getOrganizationName, +} from '../../fieldResolvers' + +export async function getSellerQuotesPaginated(ctx: Context, next: NextFn) { + const { seller } = ctx.state + + if (!seller) { + throw new UserInputError('get-seller-quote-invalid-params') + } + + // default page = 1 + const page = parseInt( + Array.isArray(ctx.query.page) ? ctx.query.page[0] : ctx.query.page || '1', + 10 + ) + + // default pageSize = 15 + const pageSize = parseInt( + Array.isArray(ctx.query.pageSize) + ? ctx.query.pageSize[0] + : ctx.query.pageSize || '15', + 10 + ) + + if (page < 1 || pageSize < 1) { + throw new UserInputError('get-seller-quote-invalid-pagination-params') + } + + const { + data, + pagination, + } = await ctx.clients.masterdata.searchDocumentsWithPaginationInfo({ + dataEntity: QUOTE_DATA_ENTITY, + fields: QUOTE_FIELDS, + pagination: { page, pageSize }, + schema: SCHEMA_VERSION, + where: `seller=${seller}`, + }) + + if (!data || !data.length) { + throw new NotFoundError('seller-quotes-not-found') + } + + const limit = pLimit(15) + const enrichedQuotes = await Promise.all( + data.map((quote) => + limit(async () => { + const organizationName = await getOrganizationName( + { organization: quote.organization }, + null, + ctx + ) + + const costCenterName = await getCostCenterName( + { costCenter: quote.costCenter }, + null, + ctx + ) + + return { ...quote, organizationName, costCenterName } + }) + ) + ) + + ctx.body = { + data: enrichedQuotes, + pagination: { + page, + pageSize, + total: pagination.total, + }, + } + + await next() +} diff --git a/node/service.json b/node/service.json index 8647dff..653982a 100644 --- a/node/service.json +++ b/node/service.json @@ -44,6 +44,18 @@ "principals": ["vrn:apps:*:*:*:app/vtex.b2b-seller-quotes@*"] } ] + }, + "getSellerQuotesPaginated": { + "path": "/b2b-quotes-graphql/_v/0/get-seller-quotes-paginated/:seller", + "public": true, + "access": "authorized", + "policies": [ + { + "effect": "allow", + "actions": ["get"], + "principals": ["vrn:apps:*:*:*:app/vtex.b2b-seller-quotes@*"] + } + ] } } } diff --git a/package.json b/package.json index f39693a..31a2e10 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "husky": "4.2.3", "lint-staged": "10.1.1", "prettier": "2.0.2", - "typescript": "3.8.3" + "typescript": "3.8.3", + "p-limit": "^6.1.0" }, "version": "0.0.1" } From 5d6c1c50121626d3fd20d9fca35dc9b490b8ea88 Mon Sep 17 00:00:00 2001 From: Guido Bernal Date: Wed, 18 Dec 2024 18:21:38 -0300 Subject: [PATCH 28/31] feat: use seller quote controller --- .../routes/seller/getSellerQuotesPaginated.ts | 78 ++----------------- .../resolvers/utils/sellerQuotesController.ts | 49 +++++++++--- 2 files changed, 46 insertions(+), 81 deletions(-) diff --git a/node/resolvers/routes/seller/getSellerQuotesPaginated.ts b/node/resolvers/routes/seller/getSellerQuotesPaginated.ts index 41258b2..42638dd 100644 --- a/node/resolvers/routes/seller/getSellerQuotesPaginated.ts +++ b/node/resolvers/routes/seller/getSellerQuotesPaginated.ts @@ -1,85 +1,23 @@ -import { NotFoundError, UserInputError } from '@vtex/api' -import pLimit from 'p-limit' - -import { - QUOTE_DATA_ENTITY, - QUOTE_FIELDS, - SCHEMA_VERSION, -} from '../../../constants' -import { - costCenterName as getCostCenterName, - organizationName as getOrganizationName, -} from '../../fieldResolvers' - export async function getSellerQuotesPaginated(ctx: Context, next: NextFn) { - const { seller } = ctx.state + const { query } = ctx - if (!seller) { - throw new UserInputError('get-seller-quote-invalid-params') - } - - // default page = 1 const page = parseInt( - Array.isArray(ctx.query.page) ? ctx.query.page[0] : ctx.query.page || '1', + Array.isArray(query.page) ? query.page[0] : query.page || '1', 10 ) - // default pageSize = 15 const pageSize = parseInt( - Array.isArray(ctx.query.pageSize) - ? ctx.query.pageSize[0] - : ctx.query.pageSize || '15', + Array.isArray(query.pageSize) ? query.pageSize[0] : query.pageSize || '15', 10 ) - if (page < 1 || pageSize < 1) { - throw new UserInputError('get-seller-quote-invalid-pagination-params') - } - - const { - data, - pagination, - } = await ctx.clients.masterdata.searchDocumentsWithPaginationInfo({ - dataEntity: QUOTE_DATA_ENTITY, - fields: QUOTE_FIELDS, - pagination: { page, pageSize }, - schema: SCHEMA_VERSION, - where: `seller=${seller}`, - }) + const validPage = page >= 0 ? page : 1 + const validPageSize = pageSize >= 0 ? pageSize : 15 - if (!data || !data.length) { - throw new NotFoundError('seller-quotes-not-found') - } - - const limit = pLimit(15) - const enrichedQuotes = await Promise.all( - data.map((quote) => - limit(async () => { - const organizationName = await getOrganizationName( - { organization: quote.organization }, - null, - ctx - ) - - const costCenterName = await getCostCenterName( - { costCenter: quote.costCenter }, - null, - ctx - ) - - return { ...quote, organizationName, costCenterName } - }) - ) + ctx.body = await ctx.vtex.sellerQuotesController?.getSellerQuotesPaginated( + validPage, + validPageSize ) - ctx.body = { - data: enrichedQuotes, - pagination: { - page, - pageSize, - total: pagination.total, - }, - } - await next() } diff --git a/node/resolvers/utils/sellerQuotesController.ts b/node/resolvers/utils/sellerQuotesController.ts index 8a0617a..6237eed 100644 --- a/node/resolvers/utils/sellerQuotesController.ts +++ b/node/resolvers/utils/sellerQuotesController.ts @@ -1,4 +1,5 @@ import { NotFoundError } from '@vtex/api' +import pLimit from 'p-limit' import { QUOTE_DATA_ENTITY, @@ -23,21 +24,24 @@ export default class SellerQuotesController { private async getSellerQuotes({ page = 1, pageSize = 1, - where, - sort, + where = '', + sort = '', }: GetQuotesArgs) { - return this.ctx.clients.masterdata.searchDocuments({ - dataEntity: QUOTE_DATA_ENTITY, - fields: QUOTE_FIELDS, - schema: SCHEMA_VERSION, - pagination: { page, pageSize }, - where: `seller=${this.seller} AND (${where})`, - sort, - }) + return this.ctx.clients.masterdata.searchDocumentsWithPaginationInfo( + { + dataEntity: QUOTE_DATA_ENTITY, + fields: QUOTE_FIELDS, + schema: SCHEMA_VERSION, + pagination: { page, pageSize }, + where: `seller=${this.seller} AND (${where})`, + sort, + } + ) } private async getSellerQuote(id: string) { - const [quote] = await this.getSellerQuotes({ where: `id=${id}` }) + const { data } = await this.getSellerQuotes({ where: `id=${id}` }) + const quote = data[0] if (!quote) { throw new NotFoundError('seller-quote-not-found') @@ -63,4 +67,27 @@ export default class SellerQuotesController { return { ...quote, organizationName, costCenterName } } + + public async getSellerQuotesPaginated(page: number, pageSize: number) { + const { data, pagination } = await this.getSellerQuotes({ page, pageSize }) + + const limit = pLimit(15) + const enrichedQuotes = await Promise.all( + data.map((quote) => + limit(async () => { + const { + organizationName, + costCenterName, + } = await this.getOrganizationData(quote) + + return { ...quote, organizationName, costCenterName } + }) + ) + ) + + return { + data: enrichedQuotes, + pagination, + } + } } From 3541144016ddb1d0ef15e7b4c06330ee515e3597 Mon Sep 17 00:00:00 2001 From: Guido Bernal Date: Wed, 18 Dec 2024 18:40:55 -0300 Subject: [PATCH 29/31] docs: add changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2181a2..586e4f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Provides a route for seller get paginated list of quote at marketplace + +### 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 From d5673ffd345bc5164c6c5004a6e416342c92de2d Mon Sep 17 00:00:00 2001 From: Guido Bernal Date: Wed, 18 Dec 2024 18:42:48 -0300 Subject: [PATCH 30/31] chore: fix misspell --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 586e4f1..fc90e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Provides a route for seller get paginated list of quote at marketplace +- Provides a route for seller get paginated list of quotes at marketplace ### Added From 42de2741806436393835aaf85b1dd8e8ad3ceb43 Mon Sep 17 00:00:00 2001 From: Guido Bernal Date: Thu, 19 Dec 2024 17:40:15 -0300 Subject: [PATCH 31/31] fix: use array destructuring --- node/resolvers/utils/sellerQuotesController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/resolvers/utils/sellerQuotesController.ts b/node/resolvers/utils/sellerQuotesController.ts index 6237eed..850e6f4 100644 --- a/node/resolvers/utils/sellerQuotesController.ts +++ b/node/resolvers/utils/sellerQuotesController.ts @@ -41,7 +41,7 @@ export default class SellerQuotesController { private async getSellerQuote(id: string) { const { data } = await this.getSellerQuotes({ where: `id=${id}` }) - const quote = data[0] + const [quote] = data if (!quote) { throw new NotFoundError('seller-quote-not-found')