From 44e037cf0dc16338eeba4514b1ee409fcfbde5c5 Mon Sep 17 00:00:00 2001 From: John Dupuy Date: Tue, 9 Jul 2024 15:32:44 -0500 Subject: [PATCH 1/7] added 'quotes' endpoint --- backend/src/client.js | 4 + backend/src/services/index.js | 5 +- backend/src/services/quotes/quotes.class.js | 11 +++ backend/src/services/quotes/quotes.js | 69 +++++++++++++++ backend/src/services/quotes/quotes.schema.js | 88 +++++++++++++++++++ backend/src/services/quotes/quotes.shared.js | 11 +++ .../services/quotes/quotes.subdocs.schema.js | 11 +++ backend/test/services/quotes/quotes.test.js | 11 +++ 8 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 backend/src/services/quotes/quotes.class.js create mode 100644 backend/src/services/quotes/quotes.js create mode 100644 backend/src/services/quotes/quotes.schema.js create mode 100644 backend/src/services/quotes/quotes.shared.js create mode 100644 backend/src/services/quotes/quotes.subdocs.schema.js create mode 100644 backend/test/services/quotes/quotes.test.js diff --git a/backend/src/client.js b/backend/src/client.js index c83f95a9..e13f3869 100644 --- a/backend/src/client.js +++ b/backend/src/client.js @@ -1,6 +1,8 @@ // For more information about this file see https://dove.feathersjs.com/guides/cli/client.html import { feathers } from '@feathersjs/feathers' import authenticationClient from '@feathersjs/authentication-client' +import { quotesClient } from './services/quotes/quotes.shared.js' + import { userEngagementsClient } from './services/user-engagements/user-engagements.shared.js' import { notificationsClient } from './services/notifications/notifications.shared.js' @@ -100,5 +102,7 @@ export const createClient = (connection, authenticationOptions = {}) => { client.configure(userEngagementsClient) + client.configure(quotesClient) + return client } diff --git a/backend/src/services/index.js b/backend/src/services/index.js index c813b223..6a821415 100644 --- a/backend/src/services/index.js +++ b/backend/src/services/index.js @@ -1,3 +1,5 @@ +import { quotes } from './quotes/quotes.js' + import { keywords } from './keywords/keywords.js' import { orgInvites } from './org-invites/org-invites.js' @@ -40,8 +42,9 @@ import { notifications } from './notifications/notifications.js' import { userEngagements } from './user-engagements/user-engagements.js' - export const services = (app) => { + app.configure(quotes) + app.configure(orgSecondaryReferences) app.configure(preferences) diff --git a/backend/src/services/quotes/quotes.class.js b/backend/src/services/quotes/quotes.class.js new file mode 100644 index 00000000..2a9f00f4 --- /dev/null +++ b/backend/src/services/quotes/quotes.class.js @@ -0,0 +1,11 @@ +import { MongoDBService } from '@feathersjs/mongodb' + +// By default calls the standard MongoDB adapter service methods but can be customized with your own functionality. +export class QuotesService extends MongoDBService {} + +export const getOptions = (app) => { + return { + paginate: app.get('paginate'), + Model: app.get('mongodbClient').then((db) => db.collection('quotes')) + } +} diff --git a/backend/src/services/quotes/quotes.js b/backend/src/services/quotes/quotes.js new file mode 100644 index 00000000..6c39ecf3 --- /dev/null +++ b/backend/src/services/quotes/quotes.js @@ -0,0 +1,69 @@ +// For more information about this file see https://dove.feathersjs.com/guides/cli/service.html +import { authenticate } from '@feathersjs/authentication' + +import { hooks as schemaHooks } from '@feathersjs/schema' +import { + quotesDataValidator, + quotesPatchValidator, + quotesQueryValidator, + quotesResolver, + quotesExternalResolver, + quotesDataResolver, + quotesPatchResolver, + quotesQueryResolver +} from './quotes.schema.js' +import { QuotesService, getOptions } from './quotes.class.js' +import { quotesPath, quotesMethods } from './quotes.shared.js' +import {disallow, iff, iffElse, isProvider} from "feathers-hooks-common"; + +export * from './quotes.class.js' +export * from './quotes.schema.js' + +// A configure function that registers the service and its hooks via `app.configure` +export const quotes = (app) => { + // Register our service on the Feathers application + app.use(quotesPath, new QuotesService(getOptions(app)), { + // A list of all methods this service exposes externally + methods: quotesMethods, + // You can add additional custom events to be sent to clients here + events: [] + }) + // Initialize hooks + app.service(quotesPath).hooks({ + around: { + all: [ + authenticate('jwt'), + schemaHooks.resolveExternal(quotesExternalResolver), + schemaHooks.resolveResult(quotesResolver) + ] + }, + before: { + all: [schemaHooks.validateQuery(quotesQueryValidator), schemaHooks.resolveQuery(quotesQueryResolver)], + find: [], + get: [], + create: [ + disallow('external'), + schemaHooks.validateData(quotesDataValidator), + schemaHooks.resolveData(quotesDataResolver) + ], + patch: [ + iff( + isProvider('external'), + iffElse(context => context.data.shouldGenerateQuote, + [], // TODO + [disallow()] + ), + ), + schemaHooks.validateData(quotesPatchValidator), + schemaHooks.resolveData(quotesPatchResolver) + ], + remove: [disallow()] + }, + after: { + all: [] + }, + error: { + all: [] + } + }) +} diff --git a/backend/src/services/quotes/quotes.schema.js b/backend/src/services/quotes/quotes.schema.js new file mode 100644 index 00000000..28ab728f --- /dev/null +++ b/backend/src/services/quotes/quotes.schema.js @@ -0,0 +1,88 @@ +// // For more information about this file see https://dove.feathersjs.com/guides/cli/service.schemas.html +import { resolve } from '@feathersjs/schema' +import { Type, getValidator, querySyntax } from '@feathersjs/typebox' +import { ObjectIdSchema } from '@feathersjs/typebox' +import { dataValidator, queryValidator } from '../../validators.js' +import {CurrencyType} from "../../currencies.js"; +import {ThirdPartyVendorType} from "./quotes.subdocs.schema.js"; + +// Main data model schema +// a document in 'quotes' is a single quotation for a specific file version for a specific source +// these quotes are not "owned" be people or orgs, but are a source of data for details. It is expected +// to be used in to ways: +// * as a source of additional detail for a specific quote stored in the `org-quotes` collection +// * as a cache for other requests with the same conditions +// even if a quote is made on a SharedModel(ShareLink), lookup should be by fileId/versionId +export const quotesSchema = Type.Object( + { + _id: ObjectIdSchema(), + originatingUserId: ObjectIdSchema(), // kept for historical reference; only a logged-in user can request a quote + requestedAt: Type.Number(), // the date the quote was requested + isCacheable: Type.Boolean(), // if false, this quote CANNOT be cached because pricing is based on context; + // such as the delivery address, a per-customer-discount, or an odd promotion + // those details should be stored in `otherParams` or `promotion` + passiveDetail: Type.Any(), // an object (empty is okay) containing details not affecting price + // the following fields determine the "uniqueness" of the quote + createdAt: Type.Optional(Type.Number()), // the date the quote was RECEIVED; used for "aging" the quote + fileId: ObjectIdSchema(), + fileVersionId: ObjectIdSchema(), + source: ThirdPartyVendorType, + pricePerUnit: Type.Optional(Type.Number()), // defaults to USD cents (USD * 100) + priceCurrency: CurrencyType, // defaults to 'USD' for now + quantity: Type.Number(), + shipping: Type.Optional(Type.Any()), // not used at first + otherParams: Type.Any(), // an object. only include params that affect pricing; empty object is ok + promotion: Type.Any(), // an object. empty object is ok + }, + { $id: 'Quotes', additionalProperties: false } +) +export const quotesValidator = getValidator(quotesSchema, dataValidator) +export const quotesResolver = resolve({}) + +export const quotesExternalResolver = resolve({}) + +// Schema for creating new entries +export const quotesDataSchema = Type.Pick(quotesSchema, [ + // note: only the server can issue a CREATE; not an external call + `originatingUserId`, + 'requestedAt', + `isCacheable`, + `passiveDetail`, + `fileId`, + `fileVersionId`, + `source`, + `priceCurrency`, + `quantity`, +], { + $id: 'QuotesData' +}) +export const quotesDataValidator = getValidator(quotesDataSchema, dataValidator) +export const quotesDataResolver = resolve({}) + +// Schema for updating existing entries +export const quotesPatchSchema = Type.Partial(quotesSchema, { + $id: 'QuotesPatch' +}) +export const quotesPatchValidator = getValidator(quotesPatchSchema, dataValidator) +export const quotesPatchResolver = resolve({}) + +// Schema for allowed query properties +export const quotesQueryProperties = Type.Pick(quotesSchema, [ + '_id', + 'createdAt', + 'fileId', + 'fileVersionId', + 'source', + 'quantity', + 'otherParams', +]) +export const quotesQuerySchema = Type.Intersect( + [ + querySyntax(quotesQueryProperties), + // Add additional query properties here + Type.Object({}, { additionalProperties: false }) + ], + { additionalProperties: false } +) +export const quotesQueryValidator = getValidator(quotesQuerySchema, queryValidator) +export const quotesQueryResolver = resolve({}) diff --git a/backend/src/services/quotes/quotes.shared.js b/backend/src/services/quotes/quotes.shared.js new file mode 100644 index 00000000..3ad298d4 --- /dev/null +++ b/backend/src/services/quotes/quotes.shared.js @@ -0,0 +1,11 @@ +export const quotesPath = 'quotes' + +export const quotesMethods = ['find', 'get', 'patch'] + +export const quotesClient = (client) => { + const connection = client.get('connection') + + client.use(quotesPath, connection.service(quotesPath), { + methods: quotesMethods + }) +} diff --git a/backend/src/services/quotes/quotes.subdocs.schema.js b/backend/src/services/quotes/quotes.subdocs.schema.js new file mode 100644 index 00000000..526b5d00 --- /dev/null +++ b/backend/src/services/quotes/quotes.subdocs.schema.js @@ -0,0 +1,11 @@ +import {StringEnum} from "@feathersjs/typebox"; + +export const ThirdPartyVendorTypeMap = { + slant3D: 'Slant 3D' +} + +export const ThirdPartyVendorType = StringEnum( + [ + ThirdPartyVendorTypeMap.slant3D, + ] +) diff --git a/backend/test/services/quotes/quotes.test.js b/backend/test/services/quotes/quotes.test.js new file mode 100644 index 00000000..94c6f505 --- /dev/null +++ b/backend/test/services/quotes/quotes.test.js @@ -0,0 +1,11 @@ +// For more information about this file see https://dove.feathersjs.com/guides/cli/service.test.html +import assert from 'assert' +import { app } from '../../../src/app.js' + +describe('quotes service', () => { + it('registered the service', () => { + const service = app.service('quotes') + + assert.ok(service, 'Registered the service') + }) +}) From 19ce8d698a96389c6e6964abee6cd0adb0ee5832 Mon Sep 17 00:00:00 2001 From: John Dupuy Date: Wed, 10 Jul 2024 17:20:01 -0500 Subject: [PATCH 2/7] added 'org-orders' and began CREATE quotes --- backend/src/client.js | 4 ++ backend/src/services/index.js | 12 ++-- .../services/org-orders/org-orders.class.js | 11 ++++ backend/src/services/org-orders/org-orders.js | 63 +++++++++++++++++++ .../services/org-orders/org-orders.schema.js | 50 +++++++++++++++ .../services/org-orders/org-orders.shared.js | 11 ++++ .../org-orders/org-orders.subdocs.schema.js | 33 ++++++++++ .../quotes/commands/generateImmediateQuote.js | 41 ++++++++++++ backend/src/services/quotes/quotes.distrib.js | 33 ++++++++++ backend/src/services/quotes/quotes.js | 14 ++--- backend/src/services/quotes/quotes.schema.js | 6 +- .../services/org-orders/org-orders.test.js | 11 ++++ 12 files changed, 276 insertions(+), 13 deletions(-) create mode 100644 backend/src/services/org-orders/org-orders.class.js create mode 100644 backend/src/services/org-orders/org-orders.js create mode 100644 backend/src/services/org-orders/org-orders.schema.js create mode 100644 backend/src/services/org-orders/org-orders.shared.js create mode 100644 backend/src/services/org-orders/org-orders.subdocs.schema.js create mode 100644 backend/src/services/quotes/commands/generateImmediateQuote.js create mode 100644 backend/src/services/quotes/quotes.distrib.js create mode 100644 backend/test/services/org-orders/org-orders.test.js diff --git a/backend/src/client.js b/backend/src/client.js index e13f3869..d63067ff 100644 --- a/backend/src/client.js +++ b/backend/src/client.js @@ -1,6 +1,8 @@ // For more information about this file see https://dove.feathersjs.com/guides/cli/client.html import { feathers } from '@feathersjs/feathers' import authenticationClient from '@feathersjs/authentication-client' +import { orgOrdersClient } from './services/org-orders/org-orders.shared.js' + import { quotesClient } from './services/quotes/quotes.shared.js' import { userEngagementsClient } from './services/user-engagements/user-engagements.shared.js' @@ -104,5 +106,7 @@ export const createClient = (connection, authenticationOptions = {}) => { client.configure(quotesClient) + client.configure(orgOrdersClient) + return client } diff --git a/backend/src/services/index.js b/backend/src/services/index.js index 6a821415..16062449 100644 --- a/backend/src/services/index.js +++ b/backend/src/services/index.js @@ -1,5 +1,3 @@ -import { quotes } from './quotes/quotes.js' - import { keywords } from './keywords/keywords.js' import { orgInvites } from './org-invites/org-invites.js' @@ -42,9 +40,11 @@ import { notifications } from './notifications/notifications.js' import { userEngagements } from './user-engagements/user-engagements.js' -export const services = (app) => { - app.configure(quotes) +import { orgOrders } from './org-orders/org-orders.js' + +import { quotes } from './quotes/quotes.js' +export const services = (app) => { app.configure(orgSecondaryReferences) app.configure(preferences) @@ -87,5 +87,9 @@ export const services = (app) => { app.configure(userEngagements) + app.configure(orgOrders) + + app.configure(quotes) + // All services will be registered here } diff --git a/backend/src/services/org-orders/org-orders.class.js b/backend/src/services/org-orders/org-orders.class.js new file mode 100644 index 00000000..69e528b6 --- /dev/null +++ b/backend/src/services/org-orders/org-orders.class.js @@ -0,0 +1,11 @@ +import { MongoDBService } from '@feathersjs/mongodb' + +// By default calls the standard MongoDB adapter service methods but can be customized with your own functionality. +export class OrgOrdersService extends MongoDBService {} + +export const getOptions = (app) => { + return { + paginate: app.get('paginate'), + Model: app.get('mongodbClient').then((db) => db.collection('org-orders')) + } +} diff --git a/backend/src/services/org-orders/org-orders.js b/backend/src/services/org-orders/org-orders.js new file mode 100644 index 00000000..5ae11dc1 --- /dev/null +++ b/backend/src/services/org-orders/org-orders.js @@ -0,0 +1,63 @@ +// For more information about this file see https://dove.feathersjs.com/guides/cli/service.html +import { authenticate } from '@feathersjs/authentication' + +import { hooks as schemaHooks } from '@feathersjs/schema' +import { + orgOrdersDataValidator, + orgOrdersPatchValidator, + orgOrdersQueryValidator, + orgOrdersResolver, + orgOrdersExternalResolver, + orgOrdersDataResolver, + orgOrdersPatchResolver, + orgOrdersQueryResolver +} from './org-orders.schema.js' +import { OrgOrdersService, getOptions } from './org-orders.class.js' +import { orgOrdersPath, orgOrdersMethods } from './org-orders.shared.js' + +export * from './org-orders.class.js' +export * from './org-orders.schema.js' + +// A configure function that registers the service and its hooks via `app.configure` +export const orgOrders = (app) => { + // Register our service on the Feathers application + app.use(orgOrdersPath, new OrgOrdersService(getOptions(app)), { + // A list of all methods this service exposes externally + methods: orgOrdersMethods, + // You can add additional custom events to be sent to clients here + events: [] + }) + // Initialize hooks + app.service(orgOrdersPath).hooks({ + around: { + all: [ + authenticate('jwt'), + schemaHooks.resolveExternal(orgOrdersExternalResolver), + schemaHooks.resolveResult(orgOrdersResolver) + ] + }, + before: { + all: [ + schemaHooks.validateQuery(orgOrdersQueryValidator), + schemaHooks.resolveQuery(orgOrdersQueryResolver) + ], + find: [], + get: [], + create: [ + schemaHooks.validateData(orgOrdersDataValidator), + schemaHooks.resolveData(orgOrdersDataResolver) + ], + patch: [ + schemaHooks.validateData(orgOrdersPatchValidator), + schemaHooks.resolveData(orgOrdersPatchResolver) + ], + remove: [] + }, + after: { + all: [] + }, + error: { + all: [] + } + }) +} diff --git a/backend/src/services/org-orders/org-orders.schema.js b/backend/src/services/org-orders/org-orders.schema.js new file mode 100644 index 00000000..fb4bc458 --- /dev/null +++ b/backend/src/services/org-orders/org-orders.schema.js @@ -0,0 +1,50 @@ +// // For more information about this file see https://dove.feathersjs.com/guides/cli/service.schemas.html +import { resolve } from '@feathersjs/schema' +import { Type, getValidator, querySyntax } from '@feathersjs/typebox' +import { ObjectIdSchema } from '@feathersjs/typebox' +import { dataValidator, queryValidator } from '../../validators.js' +import {fileVersionProductionQuoteSchema, sharedModelProductionQuoteSchema} from "./org-orders.subdocs.schema.js"; + +// Main data model schema +export const orgOrdersSchema = Type.Object( + { + _id: ObjectIdSchema(), + organizationId: ObjectIdSchema(), + productionQuotes: Type.Array(Type.Union([ + fileVersionProductionQuoteSchema, + sharedModelProductionQuoteSchema, + ])), + }, + { $id: 'OrgOrders', additionalProperties: false } +) +export const orgOrdersValidator = getValidator(orgOrdersSchema, dataValidator) +export const orgOrdersResolver = resolve({}) + +export const orgOrdersExternalResolver = resolve({}) + +// Schema for creating new entries +export const orgOrdersDataSchema = Type.Pick(orgOrdersSchema, ['text'], { + $id: 'OrgOrdersData' +}) +export const orgOrdersDataValidator = getValidator(orgOrdersDataSchema, dataValidator) +export const orgOrdersDataResolver = resolve({}) + +// Schema for updating existing entries +export const orgOrdersPatchSchema = Type.Partial(orgOrdersSchema, { + $id: 'OrgOrdersPatch' +}) +export const orgOrdersPatchValidator = getValidator(orgOrdersPatchSchema, dataValidator) +export const orgOrdersPatchResolver = resolve({}) + +// Schema for allowed query properties +export const orgOrdersQueryProperties = Type.Pick(orgOrdersSchema, ['_id', 'text']) +export const orgOrdersQuerySchema = Type.Intersect( + [ + querySyntax(orgOrdersQueryProperties), + // Add additional query properties here + Type.Object({}, { additionalProperties: false }) + ], + { additionalProperties: false } +) +export const orgOrdersQueryValidator = getValidator(orgOrdersQuerySchema, queryValidator) +export const orgOrdersQueryResolver = resolve({}) diff --git a/backend/src/services/org-orders/org-orders.shared.js b/backend/src/services/org-orders/org-orders.shared.js new file mode 100644 index 00000000..9c231be4 --- /dev/null +++ b/backend/src/services/org-orders/org-orders.shared.js @@ -0,0 +1,11 @@ +export const orgOrdersPath = 'org-orders' + +export const orgOrdersMethods = ['find', 'get', 'create', 'patch', 'remove'] + +export const orgOrdersClient = (client) => { + const connection = client.get('connection') + + client.use(orgOrdersPath, connection.service(orgOrdersPath), { + methods: orgOrdersMethods + }) +} diff --git a/backend/src/services/org-orders/org-orders.subdocs.schema.js b/backend/src/services/org-orders/org-orders.subdocs.schema.js new file mode 100644 index 00000000..e362fb35 --- /dev/null +++ b/backend/src/services/org-orders/org-orders.subdocs.schema.js @@ -0,0 +1,33 @@ +import {ObjectIdSchema, Type} from "@feathersjs/typebox"; +import {userSummarySchema} from "../users/users.subdocs.schema.js"; +import {quotesSummarySchema} from "../quotes/quotes.distrib.js"; + +export const QuoteTargetMap = { + fileVersion: 'file-version', + sharedModel: 'shared-model', +} + +export const fileVersionProductionQuoteSchema = Type.Object( + { + quoteTarget: Type.Literal(QuoteTargetMap.fileVersion), + requestedBy: userSummarySchema, + requestedAt: Type.Number(), + daysGoodFor: Type.Number(), + quote: quotesSummarySchema, + quoteFromCache: Type.Boolean(), + notes: Type.String(), + }, +) + +export const sharedModelProductionQuoteSchema = Type.Object( + { + quoteTarget: Type.Literal(QuoteTargetMap.sharedModel), + sharedModelId: ObjectIdSchema(), + requestedBy: userSummarySchema, + requestedAt: Type.Number(), + daysGoodFor: Type.Number(), + quote: quotesSummarySchema, + quoteFromCache: Type.Boolean(), + notes: Type.String(), + }, +) diff --git a/backend/src/services/quotes/commands/generateImmediateQuote.js b/backend/src/services/quotes/commands/generateImmediateQuote.js new file mode 100644 index 00000000..5e29f709 --- /dev/null +++ b/backend/src/services/quotes/commands/generateImmediateQuote.js @@ -0,0 +1,41 @@ +import _ from 'lodash'; +import {ThirdPartyVendorTypeMap} from "../quotes.subdocs.schema.js"; +import {BadRequest} from "@feathersjs/errors"; +import {currencyTypeMap} from "../../../currencies.js"; + +export const generateImmediateQuote = async (context) => { + const { data } = context; + + data.completed = false; + data.cacheable = false; + data.requestedAt = new Date(); + data.createdAt = undefined; + + if (!data.quantity || data.quantity <= 0) { + throw new BadRequest(`unable to process a quantity of ${data.quantity}`); + } + if (data.priceCurrency !== currencyTypeMap.USD) { + throw new BadRequest(`only supporting priceCurrency of ${currencyTypeMap.USD}`); + } + switch (data.source) { + case ThirdPartyVendorTypeMap.slant3D: + // TODO: in later Jira task, we will actually call the Slant 3D service. For now, make something up: + const quoteResult = { + "message": "Slicing successful", + "data": { + "price": "$8.23" + } + } + if (quoteResult.message !== 'Slicing successful') { + throw new BadRequest(`did not successfully process quote: ${quoteResult.message}`); + } + let rawPrice = quoteResult.data?.price || '0'; + let totalPrice = Number(rawPrice.replace(/[^0-9\.-]+/g,"")); + let totalPennies = totalPrice * 100.0; + data.pricePerUnit = Math.round(totalPennies / data.quantity); + data.vendorResult = (quoteResult.message || 'no result returned') + `; original total ${rawPrice}`; + break; + default: + throw new BadRequest(`Support for 3rd party vendor source "${data.source}" not implemented yet`); + } +} diff --git a/backend/src/services/quotes/quotes.distrib.js b/backend/src/services/quotes/quotes.distrib.js new file mode 100644 index 00000000..ecb7f918 --- /dev/null +++ b/backend/src/services/quotes/quotes.distrib.js @@ -0,0 +1,33 @@ +import {ObjectIdSchema, Type} from "@feathersjs/typebox"; +import {ThirdPartyVendorType} from "./quotes.subdocs.schema.js"; +import {CurrencyType} from "../../currencies.js"; + +export const quotesSummarySchema = Type.Object( + { + _id: ObjectIdSchema(), + createdAt: Type.Optional(Type.Number()), // the date the quote was RECEIVED from 3rd party; used for "aging" the quote + fileId: ObjectIdSchema(), + fileVersionId: ObjectIdSchema(), + source: ThirdPartyVendorType, + pricePerUnit: Type.Optional(Type.Number()), // defaults to USD cents (USD * 100) + priceCurrency: CurrencyType, // defaults to 'USD' for now + quantity: Type.Number(), + }, +) + +export function buildQuotesSummary(quote) { + let summary = {}; + if (quote) { + summary = { + _id: quote._id, + createdAt: quote.createdAt || undefined, + fileId: quote.fileId, + fileVersionId: quote.fileVersionId, + source: quote.source, + pricePerUnit: quote.pricePerUnit || undefined, + priceCurrency: quote.priceCurrency, + quantity: quote.quantity, + }; + } + return summary; +} diff --git a/backend/src/services/quotes/quotes.js b/backend/src/services/quotes/quotes.js index 6c39ecf3..2a41ca70 100644 --- a/backend/src/services/quotes/quotes.js +++ b/backend/src/services/quotes/quotes.js @@ -15,6 +15,7 @@ import { import { QuotesService, getOptions } from './quotes.class.js' import { quotesPath, quotesMethods } from './quotes.shared.js' import {disallow, iff, iffElse, isProvider} from "feathers-hooks-common"; +import {generateImmediateQuote} from "./commands/generateImmediateQuote.js"; export * from './quotes.class.js' export * from './quotes.schema.js' @@ -43,17 +44,16 @@ export const quotes = (app) => { get: [], create: [ disallow('external'), + generateImmediateQuote, // for now, this is the only option schemaHooks.validateData(quotesDataValidator), schemaHooks.resolveData(quotesDataResolver) ], patch: [ - iff( - isProvider('external'), - iffElse(context => context.data.shouldGenerateQuote, - [], // TODO - [disallow()] - ), - ), + // iffElse(context => context.data.shouldGenerateImmediateQuote, + // [generateImmediateQuote], + // [disallow()] + // ), + disallow(), // TODO: this will change later as two-step (non-immediate) quotations are supported schemaHooks.validateData(quotesPatchValidator), schemaHooks.resolveData(quotesPatchResolver) ], diff --git a/backend/src/services/quotes/quotes.schema.js b/backend/src/services/quotes/quotes.schema.js index 28ab728f..0cc6a249 100644 --- a/backend/src/services/quotes/quotes.schema.js +++ b/backend/src/services/quotes/quotes.schema.js @@ -16,14 +16,16 @@ import {ThirdPartyVendorType} from "./quotes.subdocs.schema.js"; export const quotesSchema = Type.Object( { _id: ObjectIdSchema(), + completed: Type.Boolean(), // if a true response seen from 3rd party, then this is set true; otherwise false originatingUserId: ObjectIdSchema(), // kept for historical reference; only a logged-in user can request a quote requestedAt: Type.Number(), // the date the quote was requested - isCacheable: Type.Boolean(), // if false, this quote CANNOT be cached because pricing is based on context; + cacheable: Type.Boolean(), // if false, this quote CANNOT be cached because pricing is based on context; // such as the delivery address, a per-customer-discount, or an odd promotion // those details should be stored in `otherParams` or `promotion` passiveDetail: Type.Any(), // an object (empty is okay) containing details not affecting price + vendorResult: Type.Optional(Type.String()), // a user readable string describing the result of the 3rd party vendor request // the following fields determine the "uniqueness" of the quote - createdAt: Type.Optional(Type.Number()), // the date the quote was RECEIVED; used for "aging" the quote + createdAt: Type.Optional(Type.Number()), // the date the quote was RECEIVED (by 3rd pty); used for "aging" the quote fileId: ObjectIdSchema(), fileVersionId: ObjectIdSchema(), source: ThirdPartyVendorType, diff --git a/backend/test/services/org-orders/org-orders.test.js b/backend/test/services/org-orders/org-orders.test.js new file mode 100644 index 00000000..97a5746a --- /dev/null +++ b/backend/test/services/org-orders/org-orders.test.js @@ -0,0 +1,11 @@ +// For more information about this file see https://dove.feathersjs.com/guides/cli/service.test.html +import assert from 'assert' +import { app } from '../../../src/app.js' + +describe('org-orders service', () => { + it('registered the service', () => { + const service = app.service('org-orders') + + assert.ok(service, 'Registered the service') + }) +}) From 77e1565afecf806e0064c1cbc26f968f5630b24a Mon Sep 17 00:00:00 2001 From: John Dupuy Date: Thu, 11 Jul 2024 13:54:42 -0500 Subject: [PATCH 3/7] minor adj to generate quote --- .../services/quotes/commands/generateImmediateQuote.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/services/quotes/commands/generateImmediateQuote.js b/backend/src/services/quotes/commands/generateImmediateQuote.js index 5e29f709..1da1bb4a 100644 --- a/backend/src/services/quotes/commands/generateImmediateQuote.js +++ b/backend/src/services/quotes/commands/generateImmediateQuote.js @@ -11,8 +11,13 @@ export const generateImmediateQuote = async (context) => { data.requestedAt = new Date(); data.createdAt = undefined; - if (!data.quantity || data.quantity <= 0) { - throw new BadRequest(`unable to process a quantity of ${data.quantity}`); + if (data.quantity) { + data.quantity = Math.floor(Number(data.quantity) || 0); // anything is made an int + } else { + throw new BadRequest(`missing quantity`); + } + if (data.quantity <= 0) { + throw new BadRequest(`unable to process a negative quantity: ${data.quantity}`); } if (data.priceCurrency !== currencyTypeMap.USD) { throw new BadRequest(`only supporting priceCurrency of ${currencyTypeMap.USD}`); From 09dc4ae66950e526378049e7bb9ae81dad74aadb Mon Sep 17 00:00:00 2001 From: John Dupuy Date: Wed, 17 Jul 2024 17:29:05 -0500 Subject: [PATCH 4/7] org-orders talks to quotes --- .../org-orders/commands/getNewQuote.js | 60 +++++++++++++++++++ .../services/org-orders/org-orders.distrib.js | 14 +++++ backend/src/services/org-orders/org-orders.js | 9 +++ .../services/org-orders/org-orders.schema.js | 20 +++++-- .../org-orders/org-orders.subdocs.schema.js | 31 +++++++++- .../quotes/commands/generateImmediateQuote.js | 6 +- backend/src/services/quotes/quotes.distrib.js | 4 ++ backend/src/services/quotes/quotes.schema.js | 27 ++++++++- 8 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 backend/src/services/org-orders/commands/getNewQuote.js create mode 100644 backend/src/services/org-orders/org-orders.distrib.js diff --git a/backend/src/services/org-orders/commands/getNewQuote.js b/backend/src/services/org-orders/commands/getNewQuote.js new file mode 100644 index 00000000..2fd05cee --- /dev/null +++ b/backend/src/services/org-orders/commands/getNewQuote.js @@ -0,0 +1,60 @@ +import {BadRequest} from "@feathersjs/errors"; +import { + buildFileVersionProductionQuote, + buildSharedModelProductionQuote, + QuoteTargetMap +} from "../org-orders.subdocs.schema.js"; +import {CurrencyType, currencyTypeMap} from "../../../currencies.js"; +import {ObjectIdSchema, Type} from "@feathersjs/typebox"; +import {ThirdPartyVendorType} from "../../quotes/quotes.subdocs.schema.js"; +import {ObjectId} from "mongodb"; + +export const getNewQuote = async (context) => { + const { data } = context; + const user = context.params.user; + const quotesService = context.app.services('quotes'); + + let fileId = data.fileId; + let versionId = data.versionId; + let sharedModelId = data.sharedModelId; + let quantity = data.quantity || 1; + let priceCurrency = data.priceCurrency || currencyTypeMap.USD; + let targetSource = data.source; + + let quoteType = sharedModelId ? QuoteTargetMap.sharedModel : QuoteTargetMap.fileVersion; + let updatedProductionQuotes = context.beforePatchCopy.productionQuotes; + + let newQuote = await quotesService.create({ + originatingUserId: user._id, + orderId: new ObjectId(), + fileId: fileId, + fileVersionId: versionId, + source: targetSource, + priceCurrency: priceCurrency, + quantity: quantity, + otherParams: {}, + promotion: {}, + }) + + let newQuoteSummary; + switch (quoteType) { + case QuoteTargetMap.fileVersion: + newQuoteSummary = buildFileVersionProductionQuote(newQuote, user); + break; + case QuoteTargetMap.sharedModel: + newQuoteSummary = buildSharedModelProductionQuote(newQuote, user); + break; + default: + throw new BadRequest(`Unknown quote type: ${quoteType}`); + } + updatedProductionQuotes.push(newQuote); + + context.data.productionQuotes = updatedProductionQuotes; + delete context.data.shouldGetNewQuote; + delete context.data.fileId; + delete context.data.versionId; + delete context.data.sharedModelId; + delete context.data.quantity; + delete context.data.priceCurrency; + delete context.data.source; +} diff --git a/backend/src/services/org-orders/org-orders.distrib.js b/backend/src/services/org-orders/org-orders.distrib.js new file mode 100644 index 00000000..d62585f4 --- /dev/null +++ b/backend/src/services/org-orders/org-orders.distrib.js @@ -0,0 +1,14 @@ +import {ObjectId} from "mongodb"; + +export const copyOrInsertOrgOrdersBeforePatch = async (context) => { + // store a copy of the File in `context.beforePatchCopy` to help detect true changes + const orgOrderService = context.app.service('org-orders'); + const orgId = new ObjectId(context.id); + context.beforePatchCopy = await orgOrderService.get(orgId); + if (!context.beforePatchCopy) { + context.beforePatchCopy = await orgOrderService.create({ + _id: orgId, + }); + } + return context; +} diff --git a/backend/src/services/org-orders/org-orders.js b/backend/src/services/org-orders/org-orders.js index 5ae11dc1..23b08e66 100644 --- a/backend/src/services/org-orders/org-orders.js +++ b/backend/src/services/org-orders/org-orders.js @@ -14,6 +14,9 @@ import { } from './org-orders.schema.js' import { OrgOrdersService, getOptions } from './org-orders.class.js' import { orgOrdersPath, orgOrdersMethods } from './org-orders.shared.js' +import {copyOrInsertOrgOrdersBeforePatch} from "./org-orders.distrib.js"; +import {iff, preventChanges} from "feathers-hooks-common"; +import {getNewQuote} from "./commands/getNewQuote.js"; export * from './org-orders.class.js' export * from './org-orders.schema.js' @@ -48,6 +51,12 @@ export const orgOrders = (app) => { schemaHooks.resolveData(orgOrdersDataResolver) ], patch: [ + copyOrInsertOrgOrdersBeforePatch, + preventChanges(false, 'productionQuotes'), + iff( + context => context.data.shouldGetNewQuote, + getNewQuote + ), schemaHooks.validateData(orgOrdersPatchValidator), schemaHooks.resolveData(orgOrdersPatchResolver) ], diff --git a/backend/src/services/org-orders/org-orders.schema.js b/backend/src/services/org-orders/org-orders.schema.js index fb4bc458..9d73c04a 100644 --- a/backend/src/services/org-orders/org-orders.schema.js +++ b/backend/src/services/org-orders/org-orders.schema.js @@ -8,8 +8,7 @@ import {fileVersionProductionQuoteSchema, sharedModelProductionQuoteSchema} from // Main data model schema export const orgOrdersSchema = Type.Object( { - _id: ObjectIdSchema(), - organizationId: ObjectIdSchema(), + _id: ObjectIdSchema(), // this is the Organization's _id productionQuotes: Type.Array(Type.Union([ fileVersionProductionQuoteSchema, sharedModelProductionQuoteSchema, @@ -23,11 +22,20 @@ export const orgOrdersResolver = resolve({}) export const orgOrdersExternalResolver = resolve({}) // Schema for creating new entries -export const orgOrdersDataSchema = Type.Pick(orgOrdersSchema, ['text'], { - $id: 'OrgOrdersData' -}) +export const orgOrdersDataSchema = Type.Pick( + orgOrdersSchema, + ['_id'], // this endpoint REQUIRES that the organization's _id be supplied as the _id for CREATE + {$id: 'OrgOrdersData'} +) export const orgOrdersDataValidator = getValidator(orgOrdersDataSchema, dataValidator) -export const orgOrdersDataResolver = resolve({}) +export const orgOrdersDataResolver = resolve({ + productionQuotes: async (value, _message, _context) => { + if (value) { + return value; + } + return []; + } +}) // Schema for updating existing entries export const orgOrdersPatchSchema = Type.Partial(orgOrdersSchema, { diff --git a/backend/src/services/org-orders/org-orders.subdocs.schema.js b/backend/src/services/org-orders/org-orders.subdocs.schema.js index e362fb35..71325795 100644 --- a/backend/src/services/org-orders/org-orders.subdocs.schema.js +++ b/backend/src/services/org-orders/org-orders.subdocs.schema.js @@ -1,6 +1,7 @@ import {ObjectIdSchema, Type} from "@feathersjs/typebox"; import {userSummarySchema} from "../users/users.subdocs.schema.js"; -import {quotesSummarySchema} from "../quotes/quotes.distrib.js"; +import {buildQuotesSummary, quotesSummarySchema} from "../quotes/quotes.distrib.js"; +import {buildUserSummary} from "../users/users.distrib.js"; export const QuoteTargetMap = { fileVersion: 'file-version', @@ -14,11 +15,23 @@ export const fileVersionProductionQuoteSchema = Type.Object( requestedAt: Type.Number(), daysGoodFor: Type.Number(), quote: quotesSummarySchema, - quoteFromCache: Type.Boolean(), notes: Type.String(), }, ) +export function buildFileVersionProductionQuote(quote, user) { + const userSummary = buildUserSummary(user); + const quoteSummary = buildQuotesSummary(quote); + return { + quoteTarget: QuoteTargetMap.fileVersion, + requestedBy: userSummary, + requestedAt: quote.requestedAt, + daysGoodFor: 99, // TODO + quote: quoteSummary, + notes: '', + } +} + export const sharedModelProductionQuoteSchema = Type.Object( { quoteTarget: Type.Literal(QuoteTargetMap.sharedModel), @@ -27,7 +40,19 @@ export const sharedModelProductionQuoteSchema = Type.Object( requestedAt: Type.Number(), daysGoodFor: Type.Number(), quote: quotesSummarySchema, - quoteFromCache: Type.Boolean(), notes: Type.String(), }, ) + +export function buildSharedModelProductionQuote(quote, user) { + const userSummary = buildUserSummary(user); + const quoteSummary = buildQuotesSummary(quote); + return { + quoteTarget: QuoteTargetMap.sharedModel, + requestedBy: userSummary, + requestedAt: quote.requestedAt, + daysGoodFor: 99, // TODO + quote: quoteSummary, + notes: '', + } +} diff --git a/backend/src/services/quotes/commands/generateImmediateQuote.js b/backend/src/services/quotes/commands/generateImmediateQuote.js index 1da1bb4a..508a83e6 100644 --- a/backend/src/services/quotes/commands/generateImmediateQuote.js +++ b/backend/src/services/quotes/commands/generateImmediateQuote.js @@ -7,7 +7,7 @@ export const generateImmediateQuote = async (context) => { const { data } = context; data.completed = false; - data.cacheable = false; + data.isFromCache = false; data.requestedAt = new Date(); data.createdAt = undefined; @@ -24,7 +24,7 @@ export const generateImmediateQuote = async (context) => { } switch (data.source) { case ThirdPartyVendorTypeMap.slant3D: - // TODO: in later Jira task, we will actually call the Slant 3D service. For now, make something up: + // TODO: in later Jira LENS-194 task, we will actually call the Slant 3D service. For now, make something up: const quoteResult = { "message": "Slicing successful", "data": { @@ -34,11 +34,13 @@ export const generateImmediateQuote = async (context) => { if (quoteResult.message !== 'Slicing successful') { throw new BadRequest(`did not successfully process quote: ${quoteResult.message}`); } + data.createdAt = Date.now() let rawPrice = quoteResult.data?.price || '0'; let totalPrice = Number(rawPrice.replace(/[^0-9\.-]+/g,"")); let totalPennies = totalPrice * 100.0; data.pricePerUnit = Math.round(totalPennies / data.quantity); data.vendorResult = (quoteResult.message || 'no result returned') + `; original total ${rawPrice}`; + data.vendorUrl = "TBD"; break; default: throw new BadRequest(`Support for 3rd party vendor source "${data.source}" not implemented yet`); diff --git a/backend/src/services/quotes/quotes.distrib.js b/backend/src/services/quotes/quotes.distrib.js index ecb7f918..203e8df9 100644 --- a/backend/src/services/quotes/quotes.distrib.js +++ b/backend/src/services/quotes/quotes.distrib.js @@ -5,6 +5,8 @@ import {CurrencyType} from "../../currencies.js"; export const quotesSummarySchema = Type.Object( { _id: ObjectIdSchema(), + orderId: ObjectIdSchema(), + isFromCache: Type.Boolean(), createdAt: Type.Optional(Type.Number()), // the date the quote was RECEIVED from 3rd party; used for "aging" the quote fileId: ObjectIdSchema(), fileVersionId: ObjectIdSchema(), @@ -20,6 +22,8 @@ export function buildQuotesSummary(quote) { if (quote) { summary = { _id: quote._id, + orderId: quote.orderId, + isFromCache: quote.isFromCache, createdAt: quote.createdAt || undefined, fileId: quote.fileId, fileVersionId: quote.fileVersionId, diff --git a/backend/src/services/quotes/quotes.schema.js b/backend/src/services/quotes/quotes.schema.js index 0cc6a249..80364ec4 100644 --- a/backend/src/services/quotes/quotes.schema.js +++ b/backend/src/services/quotes/quotes.schema.js @@ -5,6 +5,7 @@ import { ObjectIdSchema } from '@feathersjs/typebox' import { dataValidator, queryValidator } from '../../validators.js' import {CurrencyType} from "../../currencies.js"; import {ThirdPartyVendorType} from "./quotes.subdocs.schema.js"; +import {refNameHasher} from "../../refNameFunctions.js"; // Main data model schema // a document in 'quotes' is a single quotation for a specific file version for a specific source @@ -16,13 +17,18 @@ import {ThirdPartyVendorType} from "./quotes.subdocs.schema.js"; export const quotesSchema = Type.Object( { _id: ObjectIdSchema(), + orderId: ObjectIdSchema(), completed: Type.Boolean(), // if a true response seen from 3rd party, then this is set true; otherwise false originatingUserId: ObjectIdSchema(), // kept for historical reference; only a logged-in user can request a quote requestedAt: Type.Number(), // the date the quote was requested cacheable: Type.Boolean(), // if false, this quote CANNOT be cached because pricing is based on context; // such as the delivery address, a per-customer-discount, or an odd promotion // those details should be stored in `otherParams` or `promotion` + isFromCache: Type.Boolean(), // if true, then the document you are seeing came from a cache lookup + // always set to true in the DB. Only is set to "true" when a CREATE sends a + // cached response to the requester rather than a new doc. passiveDetail: Type.Any(), // an object (empty is okay) containing details not affecting price + vendorUrl: Type.Optional(Type.String()), vendorResult: Type.Optional(Type.String()), // a user readable string describing the result of the 3rd party vendor request // the following fields determine the "uniqueness" of the quote createdAt: Type.Optional(Type.Number()), // the date the quote was RECEIVED (by 3rd pty); used for "aging" the quote @@ -46,9 +52,9 @@ export const quotesExternalResolver = resolve({}) // Schema for creating new entries export const quotesDataSchema = Type.Pick(quotesSchema, [ // note: only the server can issue a CREATE; not an external call + 'orderId', `originatingUserId`, - 'requestedAt', - `isCacheable`, + `cacheable`, `passiveDetail`, `fileId`, `fileVersionId`, @@ -59,7 +65,22 @@ export const quotesDataSchema = Type.Pick(quotesSchema, [ $id: 'QuotesData' }) export const quotesDataValidator = getValidator(quotesDataSchema, dataValidator) -export const quotesDataResolver = resolve({}) +export const quotesDataResolver = resolve({ + cacheable: async () => true, // TODO: make this smarter in the future + requestedAt: async () => Date.now(), + otherParams: async (value, _message, _context) => { + if (value) { + return value; + } + return {} + }, + promotion: async (value, _message, _context) => { + if (value) { + return value; + } + return {} + }, +}) // Schema for updating existing entries export const quotesPatchSchema = Type.Partial(quotesSchema, { From 9689de21fa24bf5f34cd9a6a962d3b1c32e0dce3 Mon Sep 17 00:00:00 2001 From: John Dupuy Date: Thu, 18 Jul 2024 22:07:58 -0500 Subject: [PATCH 5/7] Basics working; at least on fresh quotes. --- .../org-orders/commands/getNewQuote.js | 4 +- .../services/org-orders/org-orders.distrib.js | 30 ++++++++--- backend/src/services/org-orders/org-orders.js | 54 +++++++++++++++++-- .../services/org-orders/org-orders.shared.js | 2 +- .../org-orders/org-orders.subdocs.schema.js | 3 +- .../quotes/commands/generateImmediateQuote.js | 2 +- backend/src/services/quotes/quotes.js | 4 +- 7 files changed, 82 insertions(+), 17 deletions(-) diff --git a/backend/src/services/org-orders/commands/getNewQuote.js b/backend/src/services/org-orders/commands/getNewQuote.js index 2fd05cee..148edbc4 100644 --- a/backend/src/services/org-orders/commands/getNewQuote.js +++ b/backend/src/services/org-orders/commands/getNewQuote.js @@ -12,7 +12,7 @@ import {ObjectId} from "mongodb"; export const getNewQuote = async (context) => { const { data } = context; const user = context.params.user; - const quotesService = context.app.services('quotes'); + const quotesService = context.app.service('quotes'); let fileId = data.fileId; let versionId = data.versionId; @@ -47,7 +47,7 @@ export const getNewQuote = async (context) => { default: throw new BadRequest(`Unknown quote type: ${quoteType}`); } - updatedProductionQuotes.push(newQuote); + updatedProductionQuotes.push(newQuoteSummary); context.data.productionQuotes = updatedProductionQuotes; delete context.data.shouldGetNewQuote; diff --git a/backend/src/services/org-orders/org-orders.distrib.js b/backend/src/services/org-orders/org-orders.distrib.js index d62585f4..fb1dfc4e 100644 --- a/backend/src/services/org-orders/org-orders.distrib.js +++ b/backend/src/services/org-orders/org-orders.distrib.js @@ -1,14 +1,32 @@ import {ObjectId} from "mongodb"; export const copyOrInsertOrgOrdersBeforePatch = async (context) => { - // store a copy of the File in `context.beforePatchCopy` to help detect true changes + // basically, support "upsert" const orgOrderService = context.app.service('org-orders'); + const orgOrdersDb = await orgOrderService.options.Model; const orgId = new ObjectId(context.id); - context.beforePatchCopy = await orgOrderService.get(orgId); - if (!context.beforePatchCopy) { - context.beforePatchCopy = await orgOrderService.create({ - _id: orgId, - }); + + let ooCopy = await orgOrdersDb.findOne({ _id: orgId }); + if (!ooCopy) { + const orgService = context.app.service('organizations'); + const refOrg = await orgService.get(orgId); + if (!refOrg) { + console.log(`Somebody tried to get a org-order for an organization that does not exist (${orgId})`); + } else { + ooCopy = await orgOrderService.create({ + _id: orgId, + }); + } + } + if (ooCopy) { + switch (context.method) { + case 'patch': + context.beforePatchCopy = ooCopy; + break; + case 'get': + context.result = ooCopy; // we are done. + break; + } } return context; } diff --git a/backend/src/services/org-orders/org-orders.js b/backend/src/services/org-orders/org-orders.js index 23b08e66..ee2ac6dd 100644 --- a/backend/src/services/org-orders/org-orders.js +++ b/backend/src/services/org-orders/org-orders.js @@ -10,13 +10,20 @@ import { orgOrdersExternalResolver, orgOrdersDataResolver, orgOrdersPatchResolver, - orgOrdersQueryResolver + orgOrdersQueryResolver, orgOrdersSchema, orgOrdersDataSchema, orgOrdersPatchSchema, orgOrdersQuerySchema } from './org-orders.schema.js' import { OrgOrdersService, getOptions } from './org-orders.class.js' import { orgOrdersPath, orgOrdersMethods } from './org-orders.shared.js' import {copyOrInsertOrgOrdersBeforePatch} from "./org-orders.distrib.js"; -import {iff, preventChanges} from "feathers-hooks-common"; +import {disallow, iff, preventChanges} from "feathers-hooks-common"; import {getNewQuote} from "./commands/getNewQuote.js"; +import swagger from "feathers-swagger"; +import { + workspaceDataSchema, + workspacePatchSchema, + workspaceQuerySchema, + workspaceSchema +} from "../workspaces/workspaces.schema.js"; export * from './org-orders.class.js' export * from './org-orders.schema.js' @@ -28,7 +35,43 @@ export const orgOrders = (app) => { // A list of all methods this service exposes externally methods: orgOrdersMethods, // You can add additional custom events to be sent to clients here - events: [] + events: [], + docs: swagger.createSwaggerServiceOptions({ + schemas: { orgOrdersSchema, orgOrdersDataSchema, orgOrdersPatchSchema , orgOrdersQuerySchema, }, + docs: { + description: 'Organization orders for quotes and production from 3rd parties', + idType: 'string', + securities: ['all'], + operations: { + get: { + 'parameters': [ + { + 'description': 'Organization OID', + 'in': 'path', + 'name': '_id', + 'schema': { + 'type': 'string' + }, + 'required': true, + }, + ] + }, + patch: { + 'parameters': [ + { + 'description': 'Organization OID', + 'in': 'path', + 'name': '_id', + 'schema': { + 'type': 'string' + }, + 'required': true, + }, + ] + }, + } + } + }) }) // Initialize hooks app.service(orgOrdersPath).hooks({ @@ -45,8 +88,11 @@ export const orgOrders = (app) => { schemaHooks.resolveQuery(orgOrdersQueryResolver) ], find: [], - get: [], + get: [ + copyOrInsertOrgOrdersBeforePatch, + ], create: [ + disallow('external'), schemaHooks.validateData(orgOrdersDataValidator), schemaHooks.resolveData(orgOrdersDataResolver) ], diff --git a/backend/src/services/org-orders/org-orders.shared.js b/backend/src/services/org-orders/org-orders.shared.js index 9c231be4..cb9bf866 100644 --- a/backend/src/services/org-orders/org-orders.shared.js +++ b/backend/src/services/org-orders/org-orders.shared.js @@ -1,6 +1,6 @@ export const orgOrdersPath = 'org-orders' -export const orgOrdersMethods = ['find', 'get', 'create', 'patch', 'remove'] +export const orgOrdersMethods = ['get', 'create', 'patch'] export const orgOrdersClient = (client) => { const connection = client.get('connection') diff --git a/backend/src/services/org-orders/org-orders.subdocs.schema.js b/backend/src/services/org-orders/org-orders.subdocs.schema.js index 71325795..0f1b0ab2 100644 --- a/backend/src/services/org-orders/org-orders.subdocs.schema.js +++ b/backend/src/services/org-orders/org-orders.subdocs.schema.js @@ -22,7 +22,7 @@ export const fileVersionProductionQuoteSchema = Type.Object( export function buildFileVersionProductionQuote(quote, user) { const userSummary = buildUserSummary(user); const quoteSummary = buildQuotesSummary(quote); - return { + const result = { quoteTarget: QuoteTargetMap.fileVersion, requestedBy: userSummary, requestedAt: quote.requestedAt, @@ -30,6 +30,7 @@ export function buildFileVersionProductionQuote(quote, user) { quote: quoteSummary, notes: '', } + return result; } export const sharedModelProductionQuoteSchema = Type.Object( diff --git a/backend/src/services/quotes/commands/generateImmediateQuote.js b/backend/src/services/quotes/commands/generateImmediateQuote.js index 508a83e6..66ede02c 100644 --- a/backend/src/services/quotes/commands/generateImmediateQuote.js +++ b/backend/src/services/quotes/commands/generateImmediateQuote.js @@ -8,7 +8,7 @@ export const generateImmediateQuote = async (context) => { data.completed = false; data.isFromCache = false; - data.requestedAt = new Date(); + data.requestedAt = Date.now(); data.createdAt = undefined; if (data.quantity) { diff --git a/backend/src/services/quotes/quotes.js b/backend/src/services/quotes/quotes.js index 2a41ca70..b2518eb2 100644 --- a/backend/src/services/quotes/quotes.js +++ b/backend/src/services/quotes/quotes.js @@ -45,8 +45,8 @@ export const quotes = (app) => { create: [ disallow('external'), generateImmediateQuote, // for now, this is the only option - schemaHooks.validateData(quotesDataValidator), - schemaHooks.resolveData(quotesDataResolver) + // schemaHooks.validateData(quotesDataValidator), + // schemaHooks.resolveData(quotesDataResolver) ], patch: [ // iffElse(context => context.data.shouldGenerateImmediateQuote, From 3bda15be228938e3cc63aa3026737f8317fd21c6 Mon Sep 17 00:00:00 2001 From: John Dupuy Date: Fri, 19 Jul 2024 15:10:40 -0500 Subject: [PATCH 6/7] added aging to summary --- .../org-orders/org-orders.subdocs.schema.js | 5 ++-- backend/src/services/quotes/quotes.helpers.js | 23 +++++++++++++++++++ .../services/quotes/quotes.subdocs.schema.js | 4 ++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 backend/src/services/quotes/quotes.helpers.js diff --git a/backend/src/services/org-orders/org-orders.subdocs.schema.js b/backend/src/services/org-orders/org-orders.subdocs.schema.js index 0f1b0ab2..69fb021a 100644 --- a/backend/src/services/org-orders/org-orders.subdocs.schema.js +++ b/backend/src/services/org-orders/org-orders.subdocs.schema.js @@ -2,6 +2,7 @@ import {ObjectIdSchema, Type} from "@feathersjs/typebox"; import {userSummarySchema} from "../users/users.subdocs.schema.js"; import {buildQuotesSummary, quotesSummarySchema} from "../quotes/quotes.distrib.js"; import {buildUserSummary} from "../users/users.distrib.js"; +import {calculateRemainingAge} from "../quotes/quotes.helpers.js"; export const QuoteTargetMap = { fileVersion: 'file-version', @@ -26,7 +27,7 @@ export function buildFileVersionProductionQuote(quote, user) { quoteTarget: QuoteTargetMap.fileVersion, requestedBy: userSummary, requestedAt: quote.requestedAt, - daysGoodFor: 99, // TODO + daysGoodFor: calculateRemainingAge(quote), quote: quoteSummary, notes: '', } @@ -52,7 +53,7 @@ export function buildSharedModelProductionQuote(quote, user) { quoteTarget: QuoteTargetMap.sharedModel, requestedBy: userSummary, requestedAt: quote.requestedAt, - daysGoodFor: 99, // TODO + daysGoodFor: calculateRemainingAge(quote), quote: quoteSummary, notes: '', } diff --git a/backend/src/services/quotes/quotes.helpers.js b/backend/src/services/quotes/quotes.helpers.js new file mode 100644 index 00000000..bd2ff75d --- /dev/null +++ b/backend/src/services/quotes/quotes.helpers.js @@ -0,0 +1,23 @@ +import {QUOTE_LIFESPAN} from "./quotes.subdocs.schema.js"; + +export function calculateRemainingAge(quote) { + let remainingSeconds = 0; + const quoteDate = quote.createdAt; + if (!quoteDate) { + return 0; + } + const now = Date.now() + const age = now - quoteDate; + const quoteLength = QUOTE_LIFESPAN[quote.source]; + remainingSeconds = quoteLength - age; + if (remainingSeconds <= 0) { + remainingSeconds = 0; + } + let remainingDays = remainingSeconds / (60 * 60 * 24); + if (remainingDays > 4) { + remainingDays = Math.floor(remainingDays); // (n) whole number + } else { + remainingDays = Math.floor(remainingDays * 10) / 10; // (n.n) tenths + } + return remainingDays; +} diff --git a/backend/src/services/quotes/quotes.subdocs.schema.js b/backend/src/services/quotes/quotes.subdocs.schema.js index 526b5d00..b261b0ae 100644 --- a/backend/src/services/quotes/quotes.subdocs.schema.js +++ b/backend/src/services/quotes/quotes.subdocs.schema.js @@ -4,6 +4,10 @@ export const ThirdPartyVendorTypeMap = { slant3D: 'Slant 3D' } +export const QUOTE_LIFESPAN = { + 'Slant 3D': 30*24*60*60 // 30 days in seconds +} + export const ThirdPartyVendorType = StringEnum( [ ThirdPartyVendorTypeMap.slant3D, From a72fb6f3126a65660ff4755916966bd5726d15ac Mon Sep 17 00:00:00 2001 From: John Dupuy Date: Fri, 19 Jul 2024 20:51:44 -0500 Subject: [PATCH 7/7] made things fully caching --- .../quotes/commands/generateImmediateQuote.js | 57 +++++++++++++++++-- backend/src/services/quotes/quotes.schema.js | 4 +- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/backend/src/services/quotes/commands/generateImmediateQuote.js b/backend/src/services/quotes/commands/generateImmediateQuote.js index 66ede02c..eb803f12 100644 --- a/backend/src/services/quotes/commands/generateImmediateQuote.js +++ b/backend/src/services/quotes/commands/generateImmediateQuote.js @@ -1,16 +1,20 @@ import _ from 'lodash'; -import {ThirdPartyVendorTypeMap} from "../quotes.subdocs.schema.js"; +import {QUOTE_LIFESPAN, ThirdPartyVendorTypeMap} from "../quotes.subdocs.schema.js"; import {BadRequest} from "@feathersjs/errors"; import {currencyTypeMap} from "../../../currencies.js"; +import {agreementCategoryTypeMap} from "../../agreements/agreements.subdocs.js"; +import {ObjectId} from "mongodb"; export const generateImmediateQuote = async (context) => { + // check context.data const { data } = context; - data.completed = false; data.isFromCache = false; data.requestedAt = Date.now(); data.createdAt = undefined; - + data.orderId = new ObjectId(data.orderId); + data.fileId = new ObjectId(data.fileId); + data.fileVersionId = new ObjectId(data.fileVersionId); if (data.quantity) { data.quantity = Math.floor(Number(data.quantity) || 0); // anything is made an int } else { @@ -22,6 +26,51 @@ export const generateImmediateQuote = async (context) => { if (data.priceCurrency !== currencyTypeMap.USD) { throw new BadRequest(`only supporting priceCurrency of ${currencyTypeMap.USD}`); } + let itemFoundFlag = await searchForExistingQuote(context); + if (!itemFoundFlag) { + await getFreshQuote(context); + } +} + +export async function searchForExistingQuote(context) { + const quoteService = context.app.service('quotes'); + const quoteDb = await quoteService.options.Model; + const { data } = context; + const lifeSpanForSource = QUOTE_LIFESPAN[data.source]; + const atLeastOneWeekLeft = lifeSpanForSource - (7 * 24 * 60 * 60); + const oldestDateAllowed = Date.now() - atLeastOneWeekLeft; + const query = { + // eligibility fields + createdAt: {$gt: oldestDateAllowed}, + isFromCache: false, // technically, this search param should NEVER be needed as we don't store copied in DB + // fields to match + fileId: data.fileId, + fileVersionId: data.fileVersionId, + quantity: data.quantity, + priceCurrency: data.priceCurrency, + source: data.source, + otherParams: data.otherParams || {}, + } + const findings = await quoteDb.find(query).sort({createdAt: -1}).limit(1).toArray(); + if (findings.length === 1) { + // setting context.result with a result prevents a new document from being generated + context.result = findings[0]; + context.result.orderId = data.orderId; + context.result.isFromCache = true; + return true; + } + return false; +} + +export async function getFreshQuote(context) { + // it is assumed that context.data has been vetted for legit values + // returns with context.data containing the new quote document + const { data } = context; + data.completed = false; + data.isFromCache = false; + data.requestedAt = Date.now(); + data.createdAt = undefined; + data.cacheable = !(data.otherParams && Object.keys(data.otherParams).length > 0); switch (data.source) { case ThirdPartyVendorTypeMap.slant3D: // TODO: in later Jira LENS-194 task, we will actually call the Slant 3D service. For now, make something up: @@ -45,4 +94,4 @@ export const generateImmediateQuote = async (context) => { default: throw new BadRequest(`Support for 3rd party vendor source "${data.source}" not implemented yet`); } -} +} \ No newline at end of file diff --git a/backend/src/services/quotes/quotes.schema.js b/backend/src/services/quotes/quotes.schema.js index 80364ec4..a0cc5e38 100644 --- a/backend/src/services/quotes/quotes.schema.js +++ b/backend/src/services/quotes/quotes.schema.js @@ -61,12 +61,12 @@ export const quotesDataSchema = Type.Pick(quotesSchema, [ `source`, `priceCurrency`, `quantity`, + 'otherParams', ], { $id: 'QuotesData' }) export const quotesDataValidator = getValidator(quotesDataSchema, dataValidator) export const quotesDataResolver = resolve({ - cacheable: async () => true, // TODO: make this smarter in the future requestedAt: async () => Date.now(), otherParams: async (value, _message, _context) => { if (value) { @@ -93,10 +93,12 @@ export const quotesPatchResolver = resolve({}) export const quotesQueryProperties = Type.Pick(quotesSchema, [ '_id', 'createdAt', + 'isFromCache', 'fileId', 'fileVersionId', 'source', 'quantity', + 'priceCurrency', 'otherParams', ]) export const quotesQuerySchema = Type.Intersect(