diff --git a/backend/src/client.js b/backend/src/client.js index c83f95a9..d63067ff 100644 --- a/backend/src/client.js +++ b/backend/src/client.js @@ -1,6 +1,10 @@ // 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' import { notificationsClient } from './services/notifications/notifications.shared.js' @@ -100,5 +104,9 @@ export const createClient = (connection, authenticationOptions = {}) => { client.configure(userEngagementsClient) + client.configure(quotesClient) + + client.configure(orgOrdersClient) + return client } diff --git a/backend/src/services/index.js b/backend/src/services/index.js index c813b223..16062449 100644 --- a/backend/src/services/index.js +++ b/backend/src/services/index.js @@ -40,6 +40,9 @@ import { notifications } from './notifications/notifications.js' import { userEngagements } from './user-engagements/user-engagements.js' +import { orgOrders } from './org-orders/org-orders.js' + +import { quotes } from './quotes/quotes.js' export const services = (app) => { app.configure(orgSecondaryReferences) @@ -84,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/commands/getNewQuote.js b/backend/src/services/org-orders/commands/getNewQuote.js new file mode 100644 index 00000000..148edbc4 --- /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.service('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(newQuoteSummary); + + 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.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.distrib.js b/backend/src/services/org-orders/org-orders.distrib.js new file mode 100644 index 00000000..fb1dfc4e --- /dev/null +++ b/backend/src/services/org-orders/org-orders.distrib.js @@ -0,0 +1,32 @@ +import {ObjectId} from "mongodb"; + +export const copyOrInsertOrgOrdersBeforePatch = async (context) => { + // basically, support "upsert" + const orgOrderService = context.app.service('org-orders'); + const orgOrdersDb = await orgOrderService.options.Model; + const orgId = new ObjectId(context.id); + + 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 new file mode 100644 index 00000000..ee2ac6dd --- /dev/null +++ b/backend/src/services/org-orders/org-orders.js @@ -0,0 +1,118 @@ +// 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, 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 {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' + +// 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: [], + 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({ + around: { + all: [ + authenticate('jwt'), + schemaHooks.resolveExternal(orgOrdersExternalResolver), + schemaHooks.resolveResult(orgOrdersResolver) + ] + }, + before: { + all: [ + schemaHooks.validateQuery(orgOrdersQueryValidator), + schemaHooks.resolveQuery(orgOrdersQueryResolver) + ], + find: [], + get: [ + copyOrInsertOrgOrdersBeforePatch, + ], + create: [ + disallow('external'), + schemaHooks.validateData(orgOrdersDataValidator), + schemaHooks.resolveData(orgOrdersDataResolver) + ], + patch: [ + copyOrInsertOrgOrdersBeforePatch, + preventChanges(false, 'productionQuotes'), + iff( + context => context.data.shouldGetNewQuote, + getNewQuote + ), + 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..9d73c04a --- /dev/null +++ b/backend/src/services/org-orders/org-orders.schema.js @@ -0,0 +1,58 @@ +// // 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(), // this is the Organization's _id + 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, + ['_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({ + productionQuotes: async (value, _message, _context) => { + if (value) { + return value; + } + return []; + } +}) + +// 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..cb9bf866 --- /dev/null +++ b/backend/src/services/org-orders/org-orders.shared.js @@ -0,0 +1,11 @@ +export const orgOrdersPath = 'org-orders' + +export const orgOrdersMethods = ['get', 'create', 'patch'] + +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..69fb021a --- /dev/null +++ b/backend/src/services/org-orders/org-orders.subdocs.schema.js @@ -0,0 +1,60 @@ +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', + sharedModel: 'shared-model', +} + +export const fileVersionProductionQuoteSchema = Type.Object( + { + quoteTarget: Type.Literal(QuoteTargetMap.fileVersion), + requestedBy: userSummarySchema, + requestedAt: Type.Number(), + daysGoodFor: Type.Number(), + quote: quotesSummarySchema, + notes: Type.String(), + }, +) + +export function buildFileVersionProductionQuote(quote, user) { + const userSummary = buildUserSummary(user); + const quoteSummary = buildQuotesSummary(quote); + const result = { + quoteTarget: QuoteTargetMap.fileVersion, + requestedBy: userSummary, + requestedAt: quote.requestedAt, + daysGoodFor: calculateRemainingAge(quote), + quote: quoteSummary, + notes: '', + } + return result; +} + +export const sharedModelProductionQuoteSchema = Type.Object( + { + quoteTarget: Type.Literal(QuoteTargetMap.sharedModel), + sharedModelId: ObjectIdSchema(), + requestedBy: userSummarySchema, + requestedAt: Type.Number(), + daysGoodFor: Type.Number(), + quote: quotesSummarySchema, + 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: calculateRemainingAge(quote), + quote: quoteSummary, + notes: '', + } +} diff --git a/backend/src/services/quotes/commands/generateImmediateQuote.js b/backend/src/services/quotes/commands/generateImmediateQuote.js new file mode 100644 index 00000000..eb803f12 --- /dev/null +++ b/backend/src/services/quotes/commands/generateImmediateQuote.js @@ -0,0 +1,97 @@ +import _ from 'lodash'; +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 { + 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}`); + } + 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: + const quoteResult = { + "message": "Slicing successful", + "data": { + "price": "$8.23" + } + } + 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`); + } +} \ No newline at end of file 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.distrib.js b/backend/src/services/quotes/quotes.distrib.js new file mode 100644 index 00000000..203e8df9 --- /dev/null +++ b/backend/src/services/quotes/quotes.distrib.js @@ -0,0 +1,37 @@ +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(), + 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(), + 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, + orderId: quote.orderId, + isFromCache: quote.isFromCache, + 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.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.js b/backend/src/services/quotes/quotes.js new file mode 100644 index 00000000..b2518eb2 --- /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"; +import {generateImmediateQuote} from "./commands/generateImmediateQuote.js"; + +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'), + generateImmediateQuote, // for now, this is the only option + // schemaHooks.validateData(quotesDataValidator), + // schemaHooks.resolveData(quotesDataResolver) + ], + patch: [ + // 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) + ], + 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..a0cc5e38 --- /dev/null +++ b/backend/src/services/quotes/quotes.schema.js @@ -0,0 +1,113 @@ +// // 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"; +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 +// 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(), + 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 + 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 + 'orderId', + `originatingUserId`, + `cacheable`, + `passiveDetail`, + `fileId`, + `fileVersionId`, + `source`, + `priceCurrency`, + `quantity`, + 'otherParams', +], { + $id: 'QuotesData' +}) +export const quotesDataValidator = getValidator(quotesDataSchema, dataValidator) +export const quotesDataResolver = resolve({ + 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, { + $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', + 'isFromCache', + 'fileId', + 'fileVersionId', + 'source', + 'quantity', + 'priceCurrency', + '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..b261b0ae --- /dev/null +++ b/backend/src/services/quotes/quotes.subdocs.schema.js @@ -0,0 +1,15 @@ +import {StringEnum} from "@feathersjs/typebox"; + +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, + ] +) 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') + }) +}) 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') + }) +})