From 7308946e567ed4e63e1ed3d9d31b30c4f1a73f0d Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Mon, 15 Feb 2021 11:59:37 +0100 Subject: [PATCH] feat: notifications (#172) The Notifications API allows plugins to register Notification Providers which have `sendNotification` and `resendNotification`. Each plugin can listen to any events transmittet over the event bus and the result of the notification send will be persisted in the database to allow for clear communications timeline + ability to resend notifications. --- .../src/services/webshipper-fulfillment.js | 37 +- packages/medusa-interfaces/src/index.js | 1 + .../src/notification-service.js | 28 + .../src/services/sendgrid.js | 629 ++++++++++++++++-- .../src/subscribers/order.js | 187 +----- .../src/subscribers/user.js | 7 - packages/medusa/src/api/routes/admin/index.js | 2 + .../api/routes/admin/notifications/index.js | 48 ++ .../admin/notifications/list-notifications.js | 64 ++ .../notifications/resend-notification.js | 36 + .../admin/orders/create-claim-shipment.js | 38 ++ .../src/api/routes/admin/orders/index.js | 8 + .../api/routes/admin/orders/request-return.js | 8 + .../customers/__tests__/create-customer.js | 2 +- .../customers/__tests__/update-customer.js | 2 +- .../routes/store/customers/create-address.js | 2 +- .../routes/store/customers/create-customer.js | 2 +- .../routes/store/customers/delete-address.js | 2 +- .../routes/store/customers/get-customer.js | 2 +- .../src/api/routes/store/customers/index.js | 2 + .../api/routes/store/customers/list-orders.js | 50 ++ .../routes/store/customers/update-address.js | 2 +- .../routes/store/customers/update-customer.js | 2 +- .../src/api/routes/store/orders/index.js | 34 + packages/medusa/src/loaders/defaults.js | 6 + packages/medusa/src/loaders/plugins.js | 15 + .../migrations/1613146953072-notifications.ts | 49 ++ ...13146953073-product_type_category_tags.ts} | 4 +- .../src/models/notification-provider.ts | 10 + packages/medusa/src/models/notification.ts | 80 +++ .../src/repositories/notification-provider.ts | 7 + .../medusa/src/repositories/notification.ts | 5 + .../src/services/__mocks__/event-bus.js | 3 + .../src/services/__tests__/notification.js | 54 ++ .../medusa/src/services/__tests__/order.js | 16 + .../medusa/src/services/__tests__/swap.js | 20 +- packages/medusa/src/services/claim.js | 2 +- packages/medusa/src/services/customer.js | 6 + packages/medusa/src/services/event-bus.js | 12 +- .../src/services/fulfillment-provider.js | 12 + packages/medusa/src/services/notification.js | 254 +++++++ packages/medusa/src/services/order.js | 9 + packages/medusa/src/services/swap.js | 7 + packages/medusa/src/services/totals.js | 5 +- .../medusa/src/subscribers/notification.js | 15 + scripts/assert-changed-files.sh | 6 +- 46 files changed, 1538 insertions(+), 254 deletions(-) create mode 100644 packages/medusa-interfaces/src/notification-service.js create mode 100644 packages/medusa/src/api/routes/admin/notifications/index.js create mode 100644 packages/medusa/src/api/routes/admin/notifications/list-notifications.js create mode 100644 packages/medusa/src/api/routes/admin/notifications/resend-notification.js create mode 100644 packages/medusa/src/api/routes/admin/orders/create-claim-shipment.js create mode 100644 packages/medusa/src/api/routes/store/customers/list-orders.js create mode 100644 packages/medusa/src/migrations/1613146953072-notifications.ts rename packages/medusa/src/migrations/{1611909563253-product_type_category_tags.ts => 1613146953073-product_type_category_tags.ts} (97%) create mode 100644 packages/medusa/src/models/notification-provider.ts create mode 100644 packages/medusa/src/models/notification.ts create mode 100644 packages/medusa/src/repositories/notification-provider.ts create mode 100644 packages/medusa/src/repositories/notification.ts create mode 100644 packages/medusa/src/services/__tests__/notification.js create mode 100644 packages/medusa/src/services/notification.js create mode 100644 packages/medusa/src/subscribers/notification.js diff --git a/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js b/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js index b010bc8c3c..89142f3bb6 100644 --- a/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js +++ b/packages/medusa-fulfillment-webshipper/src/services/webshipper-fulfillment.js @@ -422,8 +422,41 @@ class WebshipperFulfillmentService extends FulfillmentService { /** * This plugin doesn't support shipment documents. */ - async getShipmentDocuments() { - return [] + async retrieveDocuments(fulfillmentData, documentType) { + switch (documentType) { + case "label": + const labelRelation = fulfillmentData?.relationships?.labels + if (labelRelation) { + const docs = await this.retrieveRelationship(labelRelation) + .then(({ data }) => data) + .catch((_) => []) + + return docs.map((d) => ({ + name: d.attributes.document_type, + base_64: d.attributes.base64, + type: "application/pdf", + })) + } + return [] + + case "invoice": + const docRelation = fulfillmentData?.relationships?.documents + if (docRelation) { + const docs = await this.retrieveRelationship(docRelation) + .then(({ data }) => data) + .catch((_) => []) + + return docs.map((d) => ({ + name: d.attributes.document_type, + base_64: d.attributes.base64, + type: "application/pdf", + })) + } + return [] + + default: + return [] + } } /** diff --git a/packages/medusa-interfaces/src/index.js b/packages/medusa-interfaces/src/index.js index b8e739ddda..94d2d3a774 100644 --- a/packages/medusa-interfaces/src/index.js +++ b/packages/medusa-interfaces/src/index.js @@ -3,4 +3,5 @@ export { default as BaseModel } from "./base-model" export { default as PaymentService } from "./payment-service" export { default as FulfillmentService } from "./fulfillment-service" export { default as FileService } from "./file-service" +export { default as NotificationService } from "./notification-service" export { default as OauthService } from "./oauth-service" diff --git a/packages/medusa-interfaces/src/notification-service.js b/packages/medusa-interfaces/src/notification-service.js new file mode 100644 index 0000000000..39171d99fa --- /dev/null +++ b/packages/medusa-interfaces/src/notification-service.js @@ -0,0 +1,28 @@ +import BaseService from "./base-service" + +/** + * Interface for Notification Providers + * @interface + */ +class BaseNotificationService extends BaseService { + constructor() { + super() + } + + getIdentifier() { + return this.constructor.identifier + } + + /** + * Used to retrieve documents related to a shipment. + */ + sendNotification(event, data) { + throw new Error("Must be overridden by child") + } + + resendNotification(notification, config = {}) { + throw new Error("Must be overridden by child") + } +} + +export default BaseNotificationService diff --git a/packages/medusa-plugin-sendgrid/src/services/sendgrid.js b/packages/medusa-plugin-sendgrid/src/services/sendgrid.js index 2516b3cd46..5479dc2b05 100644 --- a/packages/medusa-plugin-sendgrid/src/services/sendgrid.js +++ b/packages/medusa-plugin-sendgrid/src/services/sendgrid.js @@ -1,7 +1,9 @@ -import { BaseService } from "medusa-interfaces" +import { NotificationService } from "medusa-interfaces" import SendGrid from "@sendgrid/mail" -class SendGridService extends BaseService { +class SendGridService extends NotificationService { + static identifier = "sendgrid" + /** * @param {Object} options - options defined in `medusa-config.js` * e.g. @@ -15,69 +17,217 @@ class SendGridService extends BaseService { * customer_password_reset_template: 1111, * } */ - constructor({}, options) { + constructor( + { + storeService, + orderService, + returnService, + swapService, + claimService, + fulfillmentService, + fulfillmentProviderService, + totalsService, + }, + options + ) { super() this.options_ = options + this.fulfillmentProviderService_ = fulfillmentProviderService + this.storeService_ = storeService + this.orderService_ = orderService + this.claimService_ = claimService + this.returnService_ = returnService + this.swapService_ = swapService + this.fulfillmentService_ = fulfillmentService + this.totalsService_ = totalsService + SendGrid.setApiKey(options.api_key) } - /** - * Sends a transactional email based on an event using SendGrid. - * @param {string} event - event related to the order - * @param {Object} order - the order object sent to SendGrid, that must - * correlate with the structure specificed in the dynamic template - * @returns {Promise} result of the send operation - */ - async transactionalEmail(event, data) { - let templateId + async fetchAttachments(event, data) { switch (event) { - case "order.gift_card_created": - templateId = this.options_.gift_card_created_template - data = { - ...data, - display_value: data.giftcard.rule.value * (1 + data.tax_rate), + case "swap.created": + case "order.return_requested": { + let attachments = [] + const { shipping_method, shipping_data } = data.return_request + if (shipping_method) { + const provider = shipping_method.shipping_option.provider_id + + const lbl = await this.fulfillmentProviderService_.retrieveDocuments( + provider, + shipping_data, + "label" + ) + + attachments = attachments.concat( + lbl.map((d) => ({ + name: "return-label", + base64: d.base_64, + type: d.type, + })) + ) + + const inv = await this.fulfillmentProviderService_.retrieveDocuments( + provider, + shipping_data, + "invoice" + ) + + attachments = attachments.concat( + inv.map((d) => ({ + name: "invoice", + base64: d.base_64, + type: d.type, + })) + ) } - break - case "order.placed": - templateId = this.options_.order_placed_template - break - case "order.updated": - templateId = this.options_.order_updated_template - break - case "order.shipment_created": - templateId = this.options_.order_shipped_template - break - case "order.cancelled": - templateId = this.options_.order_cancelled_template - break - case "order.completed": - templateId = this.options_.order_completed_template - break - case "user.password_reset": - templateId = this.options_.user_password_reset_template - break - case "customer.password_reset": - templateId = this.options_.customer_password_reset_template - break - default: - return - } - try { - if (templateId) { - return SendGrid.send({ - template_id: templateId, - from: this.options_.from, - to: data.email, - dynamic_template_data: data, - }) + + return attachments } - } catch (error) { - throw error + default: + return [] } } + async fetchData(event, eventData, attachmentGenerator) { + switch (event) { + case "order.return_requested": + return this.returnRequestedData(eventData, attachmentGenerator) + case "swap.shipment_created": + return this.swapShipmentCreatedData(eventData, attachmentGenerator) + case "claim.shipment_created": + return this.claimShipmentCreatedData(eventData, attachmentGenerator) + case "order.items_returned": + return this.itemsReturnedData(eventData, attachmentGenerator) + case "order.swap_received": + return this.swapReceivedData(eventData, attachmentGenerator) + case "swap.created": + return this.swapCreatedData(eventData, attachmentGenerator) + case "gift_card.created": + return this.gcCreatedData(eventData, attachmentGenerator) + case "order.gift_card_created": + return this.gcCreatedData(eventData, attachmentGenerator) + case "order.placed": + return this.orderPlacedData(eventData, attachmentGenerator) + case "order.shipment_created": + return this.orderShipmentCreatedData(eventData, attachmentGenerator) + case "order.canceled": + return this.orderCanceledData(eventData, attachmentGenerator) + case "user.password_reset": + return this.userPasswordResetData(eventData, attachmentGenerator) + case "customer.password_reset": + return this.customerPasswordResetData(eventData, attachmentGenerator) + default: + return {} + } + } + + getTemplateId(event) { + switch (event) { + case "order.return_requested": + return this.options_.order_return_requested_template + case "swap.shipment_created": + return this.options_.swap_shipment_created_template + case "claim.shipment_created": + return this.options_.claim_shipment_created_template + case "order.items_returned": + return this.options_.order_items_returned_template + case "order.swap_received": + return this.options_.order_swap_received_template + case "swap.created": + return this.options_.swap_created_template + case "gift_card.created": + return this.options_.gift_card_created_template + case "order.gift_card_created": + return this.options_.gift_card_created_template + case "order.placed": + return this.options_.order_placed_template + case "order.shipment_created": + return this.options_.order_shipped_template + case "order.canceled": + return this.options_.order_canceled_template + case "user.password_reset": + return this.options_.user_password_reset_template + case "customer.password_reset": + return this.options_.customer_password_reset_template + default: + return null + } + } + + async sendNotification(event, eventData, attachmentGenerator) { + let templateId = this.getTemplateId(event) + + if (!templateId) { + return false + } + + const data = await this.fetchData(event, eventData, attachmentGenerator) + const attachments = await this.fetchAttachments(event, data) + + const sendOptions = { + template_id: templateId, + from: this.options_.from, + to: data.email, + dynamic_template_data: data, + has_attachments: attachments?.length, + } + + if (attachments?.length) { + sendOptions.has_attachments = true + sendOptions.attachments = attachments.map((a) => { + return { + content: a.base64, + filename: a.name, + type: a.type, + disposition: "attachment", + contentId: a.name, + } + }) + } + + const status = await SendGrid.send(sendOptions) + .then(() => "sent") + .catch(() => "failed") + + // We don't want heavy docs stored in DB + delete sendOptions.attachments + + return { to: data.email, status, data: sendOptions } + } + + async resendNotification(notification, config) { + const sendOptions = { + ...notification.data, + to: config.to || notification.to, + } + + if (notification.data?.has_attachments) { + const attachs = await this.fetchAttachments( + notification.event_name, + notification.data.dynamic_template_data + ) + + sendOptions.attachments = attachs.map((a) => { + return { + content: a.base64, + filename: a.name, + type: a.type, + disposition: "attachment", + contentId: a.name, + } + }) + } + + const status = await SendGrid.send(sendOptions) + .then(() => "sent") + .catch(() => "failed") + + return { to: sendOptions.to, status, data: sendOptions } + } + /** * Sends an email using SendGrid. * @param {string} templateId - id of template in SendGrid @@ -93,6 +243,381 @@ class SendGridService extends BaseService { throw error } } + + async orderShipmentCreatedData({ id, fulfillment_id }, attachmentGenerator) { + const order = await this.orderService_.retrieve(id, { + select: [ + "shipping_total", + "discount_total", + "tax_total", + "refunded_total", + "gift_card_total", + "subtotal", + "total", + "refundable_amount", + ], + relations: [ + "customer", + "billing_address", + "shipping_address", + "discounts", + "shipping_methods", + "shipping_methods.shipping_option", + "payments", + "fulfillments", + "returns", + "gift_cards", + "gift_card_transactions", + ], + }) + + const shipment = await this.fulfillmentService_.retrieve(fulfillment_id, { + relations: ["items"], + }) + + return { + order, + email: order.email, + fulfillment: shipment, + tracking_number: shipment.tracking_numbers.join(", "), + } + } + + async orderPlacedData({ id }) { + const order = await this.orderService_.retrieve(id, { + select: [ + "shipping_total", + "discount_total", + "tax_total", + "refunded_total", + "gift_card_total", + "subtotal", + "total", + ], + relations: [ + "customer", + "billing_address", + "shipping_address", + "discounts", + "shipping_methods", + "shipping_methods.shipping_option", + "payments", + "fulfillments", + "returns", + "gift_cards", + "gift_card_transactions", + ], + }) + + const { + subtotal, + tax_total, + discount_total, + shipping_total, + gift_card_total, + total, + } = order + + const taxRate = order.tax_rate / 100 + const currencyCode = order.currency_code.toUpperCase() + + const items = this.processItems_(order.items, taxRate, currencyCode) + + let discounts = [] + if (order.discounts) { + discounts = order.discounts.map((discount) => { + return { + is_giftcard: false, + code: discount.code, + descriptor: `${discount.rule.value}${ + discount.rule.type === "percentage" ? "%" : ` ${currencyCode}` + }`, + } + }) + } + + let giftCards = [] + if (order.gift_cards) { + giftCards = order.gift_cards.map((gc) => { + return { + is_giftcard: true, + code: gc.code, + descriptor: `${gc.value} ${currencyCode}`, + } + }) + + discounts.concat(giftCards) + } + + return { + ...order, + has_discounts: order.discounts.length, + has_gift_cards: order.gift_cards.length, + date: order.created_at.toDateString(), + items, + discounts, + subtotal: `${this.humanPrice_(subtotal * (1 + taxRate))} ${currencyCode}`, + gift_card_total: `${this.humanPrice_( + gift_card_total * (1 + taxRate) + )} ${currencyCode}`, + tax_total: `${this.humanPrice_(tax_total)} ${currencyCode}`, + discount_total: `${this.humanPrice_( + discount_total * (1 + taxRate) + )} ${currencyCode}`, + shipping_total: `${this.humanPrice_( + shipping_total * (1 + taxRate) + )} ${currencyCode}`, + total: `${this.humanPrice_(total)} ${currencyCode}`, + } + } + + async gcCreatedData({ id }) { + const giftCard = await this.giftCardService_.retrieve(id, { + relations: ["region", "order"], + }) + + const taxRate = giftCard.region.tax_rate / 100 + + return { + ...giftCard, + email: giftCard.order.email, + display_value: giftCard.value * (1 + taxRate), + } + } + + async returnRequestedData({ id, return_id }) { + // Fetch the return request + const returnRequest = await this.returnService_.retrieve(return_id, { + relations: [ + "items", + "shipping_method", + "shipping_method.shipping_option", + ], + }) + + // Fetch the order + const order = await this.orderService_.retrieve(id, { + relations: ["items", "discounts", "shipping_address"], + }) + + // Calculate which items are in the return + const returnItems = returnRequest.items.map((i) => { + const found = order.items.find((oi) => oi.id === i.item_id) + return { + ...found, + quantity: i.quantity, + } + }) + + const taxRate = order.tax_rate / 100 + const currencyCode = order.currency_code.toUpperCase() + + // Get total of the returned products + const item_subtotal = this.totalsService_.getRefundTotal(order, returnItems) + + // If the return has a shipping method get the price and any attachments + let shippingTotal = 0 + if (returnRequest.shipping_method) { + shippingTotal = returnRequest.shipping_method.price * (1 + taxRate) + } + + return { + has_shipping: !!returnRequest.shipping_method, + email: order.email, + items: this.processItems_(returnItems, taxRate, currencyCode), + subtotal: `${this.humanPrice_(item_subtotal)} ${currencyCode}`, + shipping_total: `${this.humanPrice_(shippingTotal)} ${currencyCode}`, + refund_amount: `${this.humanPrice_( + returnRequest.refund_amount + )} ${currencyCode}`, + return_request: { + ...returnRequest, + refund_amount: `${this.humanPrice_( + returnRequest.refund_amount + )} ${currencyCode}`, + }, + order, + date: returnRequest.updated_at.toDateString(), + } + } + + async swapCreatedData({ id }) { + const store = await this.storeService_.retrieve() + const swap = await this.swapService_.retrieve(id, { + relations: [ + "additional_items", + "return_order", + "return_order.items", + "return_order.shipping_method", + "return_order.shipping_method.shipping_option", + ], + }) + + const swapLink = store.swap_link_template.replace( + /\{cart_id\}/, + swap.cart_id + ) + + const order = await this.orderService_.retrieve(swap.order_id, { + relations: ["items", "discounts", "shipping_address"], + }) + + const taxRate = order.tax_rate / 100 + const currencyCode = order.currency_code.toUpperCase() + + const returnItems = this.processItems_( + swap.return_order.items.map((i) => { + const found = order.items.find((oi) => oi.id === i.item_id) + return { + ...found, + quantity: i.quantity, + } + }), + taxRate, + currencyCode + ) + + const returnTotal = this.totalsService_.getRefundTotal(order, returnItems) + + const constructedOrder = { + ...order, + shipping_methods: [], + items: swap.additional_items, + } + + const additionalTotal = this.totalsService_.getTotal(constructedOrder) + + const refundAmount = swap.return_order.refund_amount + + return { + swap, + order, + return_request: swap.return_order, + date: swap.updated_at.toDateString(), + swap_link: swapLink, + email: order.email, + items: this.processItems_(swap.additional_items, taxRate, currencyCode), + return_items: returnItems, + return_total: `${this.humanPrice_(returnTotal)} ${currencyCode}`, + refund_amount: `${this.humanPrice_(refundAmount)} ${currencyCode}`, + additional_total: `${this.humanPrice_(additionalTotal)} ${currencyCode}`, + } + } + + async itemsReturnedData(data) { + return this.returnRequestedData(data) + } + + async swapShipmentCreatedData({ id, fulfillment_id }) { + const swap = await this.swapService_.retrieve(id, { + relations: [ + "shipping_address", + "shipping_methods", + "additional_items", + "return_order", + "return_order.items", + ], + }) + + const order = await this.orderService_.retrieve(swap.order_id, { + relations: ["items", "discounts"], + }) + + const taxRate = order.tax_rate / 100 + const currencyCode = order.currency_code.toUpperCase() + + const returnItems = this.processItems_( + swap.return_order.items.map((i) => { + const found = order.items.find((oi) => oi.id === i.item_id) + return { + ...found, + quantity: i.quantity, + } + }), + taxRate, + currencyCode + ) + + const returnTotal = this.totalsService_.getRefundTotal(order, returnItems) + + const constructedOrder = { + ...order, + shipping_methods: swap.shipping_methods, + items: swap.additional_items, + } + + const additionalTotal = this.totalsService_.getTotal(constructedOrder) + + const refundAmount = swap.return_order.refund_amount + + const shipment = await this.fulfillmentService_.retrieve(fulfillment_id) + + return { + swap, + order, + items: this.processItems_(swap.additional_items, taxRate, currencyCode), + date: swap.updated_at.toDateString(), + email: order.email, + tax_amount: `${this.humanPrice_( + swap.difference_due * taxRate + )} ${currencyCode}`, + paid_total: `${this.humanPrice_(swap.difference_due)} ${currencyCode}`, + return_total: `${this.humanPrice_(returnTotal)} ${currencyCode}`, + refund_amount: `${this.humanPrice_(refundAmount)} ${currencyCode}`, + additional_total: `${this.humanPrice_(additionalTotal)} ${currencyCode}`, + fulfillment: shipment, + tracking_number: shipment.tracking_numbers.join(", "), + } + } + + async claimShipmentCreatedData({ id, fulfillment_id }) { + const claim = await this.claimService_.retrieve(id, { + relations: ["order", "order.items", "order.shipping_address"], + }) + + const shipment = await this.fulfillmentService_.retrieve(fulfillment_id) + + return { + email: claim.order.email, + claim, + order: claim.order, + fulfillment: shipment, + tracking_number: shipment.tracking_numbers.join(", "), + } + } + + userPasswordResetData(data) { + return data + } + + customerPasswordResetData(data) { + return data + } + + processItems_(items, taxRate, currencyCode) { + return items.map((i) => { + return { + ...i, + thumbnail: this.normalizeThumbUrl_(i.thumbnail), + price: `${this.humanPrice_( + i.unit_price * (1 + taxRate) + )} ${currencyCode}`, + } + }) + } + + humanPrice_(amount) { + return amount ? (amount / 100).toFixed(2) : "0.00" + } + + normalizeThumbUrl_(url) { + if (url.startsWith("http")) { + return url + } else if (url.startsWith("//")) { + return `https:${url}` + } + return url + } } export default SendGridService diff --git a/packages/medusa-plugin-sendgrid/src/subscribers/order.js b/packages/medusa-plugin-sendgrid/src/subscribers/order.js index d58b4c4494..8c09e33dff 100644 --- a/packages/medusa-plugin-sendgrid/src/subscribers/order.js +++ b/packages/medusa-plugin-sendgrid/src/subscribers/order.js @@ -3,187 +3,26 @@ class OrderSubscriber { totalsService, orderService, sendgridService, - eventBusService, + notificationService, fulfillmentService, }) { this.orderService_ = orderService this.totalsService_ = totalsService this.sendgridService_ = sendgridService - this.eventBus_ = eventBusService + this.notificationService_ = notificationService this.fulfillmentService_ = fulfillmentService - this.eventBus_.subscribe( - "order.shipment_created", - async ({ id, fulfillment_id }) => { - const order = await this.orderService_.retrieve(id, { - select: [ - "shipping_total", - "discount_total", - "tax_total", - "refunded_total", - "gift_card_total", - "subtotal", - "total", - "refundable_amount", - ], - relations: [ - "customer", - "billing_address", - "shipping_address", - "discounts", - "shipping_methods", - "shipping_methods.shipping_option", - "payments", - "fulfillments", - "returns", - "gift_cards", - "gift_card_transactions", - "swaps", - "swaps.return_order", - "swaps.payment", - "swaps.shipping_methods", - "swaps.shipping_address", - "swaps.additional_items", - "swaps.fulfillments", - ], - }) - - const shipment = await this.fulfillmentService_.retrieve(fulfillment_id) - - const data = { - ...order, - tracking_number: shipment.tracking_numbers.join(", "), - } - - await this.sendgridService_.transactionalEmail( - "order.shipment_created", - data - ) - } - ) - - this.eventBus_.subscribe("order.gift_card_created", async (order) => { - await this.sendgridService_.transactionalEmail( - "order.gift_card_created", - order - ) - }) - - this.eventBus_.subscribe("order.placed", async (orderObj) => { - try { - const order = await this.orderService_.retrieve(orderObj.id, { - select: [ - "shipping_total", - "discount_total", - "tax_total", - "refunded_total", - "gift_card_total", - "subtotal", - "total", - ], - relations: [ - "customer", - "billing_address", - "shipping_address", - "discounts", - "shipping_methods", - "shipping_methods.shipping_option", - "payments", - "fulfillments", - "returns", - "gift_cards", - "gift_card_transactions", - "swaps", - "swaps.return_order", - "swaps.payment", - "swaps.shipping_methods", - "swaps.shipping_address", - "swaps.additional_items", - "swaps.fulfillments", - ], - }) - - const { - subtotal, - tax_total, - discount_total, - shipping_total, - total, - } = order - - const taxRate = order.tax_rate / 100 - const currencyCode = order.currency_code.toUpperCase() - - const items = order.items.map((i) => { - return { - ...i, - price: `${((i.unit_price / 100) * (1 + taxRate)).toFixed( - 2 - )} ${currencyCode}`, - } - }) - - let discounts = [] - if (order.discounts) { - discounts = order.discounts.map((discount) => { - return { - is_giftcard: false, - code: discount.code, - descriptor: `${discount.rule.value}${ - discount.rule.type === "percentage" ? "%" : ` ${currencyCode}` - }`, - } - }) - } - - let giftCards = [] - if (order.gift_cards) { - giftCards = order.gift_cards.map((gc) => { - return { - is_giftcard: true, - code: gc.code, - descriptor: `${gc.value} ${currencyCode}`, - } - }) - - discounts.concat(giftCards) - } - - const data = { - ...order, - date: order.created_at.toDateString(), - items, - discounts, - subtotal: `${((subtotal / 100) * (1 + taxRate)).toFixed( - 2 - )} ${currencyCode}`, - tax_total: `${(tax_total / 100).toFixed(2)} ${currencyCode}`, - discount_total: `${((discount_total / 100) * (1 + taxRate)).toFixed( - 2 - )} ${currencyCode}`, - shipping_total: `${((shipping_total / 100) * (1 + taxRate)).toFixed( - 2 - )} ${currencyCode}`, - total: `${(total / 100).toFixed(2)} ${currencyCode}`, - } - - await this.sendgridService_.transactionalEmail("order.placed", data) - } catch (error) { - console.log(error) - } - }) - - this.eventBus_.subscribe("order.cancelled", async (order) => { - await this.sendgridService_.transactionalEmail("order.cancelled", order) - }) - - this.eventBus_.subscribe("order.completed", async (order) => { - await this.sendgridService_.transactionalEmail("order.completed", order) - }) - - this.eventBus_.subscribe("order.updated", async (order) => { - await this.sendgridService_.transactionalEmail("order.updated", order) - }) + this.notificationService_.subscribe("order.shipment_created", "sendgrid") + this.notificationService_.subscribe("order.gift_card_created", "sendgrid") + this.notificationService_.subscribe("gift_card.created", "sendgrid") + this.notificationService_.subscribe("order.placed", "sendgrid") + this.notificationService_.subscribe("order.canceled", "sendgrid") + this.notificationService_.subscribe("customer.password_reset", "sendgrid") + this.notificationService_.subscribe("claim.shipment_created", "sendgrid") + this.notificationService_.subscribe("swap.shipment_created", "sendgrid") + this.notificationService_.subscribe("swap.created", "sendgrid") + this.notificationService_.subscribe("order.items_returned", "sendgrid") + this.notificationService_.subscribe("order.return_requested", "sendgrid") } } diff --git a/packages/medusa-plugin-sendgrid/src/subscribers/user.js b/packages/medusa-plugin-sendgrid/src/subscribers/user.js index d9adcc7bba..bc898b3e0c 100644 --- a/packages/medusa-plugin-sendgrid/src/subscribers/user.js +++ b/packages/medusa-plugin-sendgrid/src/subscribers/user.js @@ -10,13 +10,6 @@ class UserSubscriber { data ) }) - - this.eventBus_.subscribe("customer.password_reset", async (data) => { - await this.sendgridService_.transactionalEmail( - "customer.password_reset", - data - ) - }) } } diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 8f15f67a20..7ea852addf 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -19,6 +19,7 @@ import swapRoutes from "./swaps" import returnRoutes from "./returns" import variantRoutes from "./variants" import collectionRoutes from "./collections" +import notificationRoutes from "./notifications" const route = Router() @@ -62,6 +63,7 @@ export default (app, container, config) => { returnRoutes(route) variantRoutes(route) collectionRoutes(route) + notificationRoutes(route) return app } diff --git a/packages/medusa/src/api/routes/admin/notifications/index.js b/packages/medusa/src/api/routes/admin/notifications/index.js new file mode 100644 index 0000000000..a442e895ba --- /dev/null +++ b/packages/medusa/src/api/routes/admin/notifications/index.js @@ -0,0 +1,48 @@ +import { Router } from "express" +import middlewares from "../../../middlewares" + +const route = Router() + +export default app => { + app.use("/notifications", route) + + /** + * List notifications + */ + route.get("/", middlewares.wrap(require("./list-notifications").default)) + + /** + * Resend a notification + */ + route.post( + "/:id/resend", + middlewares.wrap(require("./resend-notification").default) + ) + + return app +} + +export const defaultRelations = ["resends"] +export const allowedRelations = ["resends"] + +export const defaultFields = [ + "id", + "resource_type", + "resource_id", + "event_name", + "to", + "provider_id", + "created_at", + "updated_at", +] + +export const allowedFields = [ + "id", + "resource_type", + "resource_id", + "provider_id", + "event_name", + "to", + "created_at", + "updated_at", +] diff --git a/packages/medusa/src/api/routes/admin/notifications/list-notifications.js b/packages/medusa/src/api/routes/admin/notifications/list-notifications.js new file mode 100644 index 0000000000..373fa6222b --- /dev/null +++ b/packages/medusa/src/api/routes/admin/notifications/list-notifications.js @@ -0,0 +1,64 @@ +import _ from "lodash" +import { defaultRelations, defaultFields } from "./" + +export default async (req, res) => { + try { + const notificationService = req.scope.resolve("notificationService") + + const limit = parseInt(req.query.limit) || 50 + const offset = parseInt(req.query.offset) || 0 + + let selector = {} + + let includeFields = [] + if ("fields" in req.query) { + includeFields = req.query.fields.split(",") + } + + let expandFields = [] + if ("expand" in req.query) { + expandFields = req.query.expand.split(",") + } + + if ("event_name" in req.query) { + const values = req.query.event_name.split(",") + selector.event_name = values.length > 1 ? values : values[0] + } + + if ("resource_type" in req.query) { + const values = req.query.resource_type.split(",") + selector.resource_type = values.length > 1 ? values : values[0] + } + + if ("resource_id" in req.query) { + const values = req.query.resource_id.split(",") + selector.resource_id = values.length > 1 ? values : values[0] + } + + if ("to" in req.query) { + const values = req.query.to.split(",") + selector.to = values.length > 1 ? values : values[0] + } + + if (!("include_resends" in req.query)) { + selector.parent_id = null + } + + const listConfig = { + select: includeFields.length ? includeFields : defaultFields, + relations: expandFields.length ? expandFields : defaultRelations, + skip: offset, + take: limit, + order: { created_at: "DESC" }, + } + + const notifications = await notificationService.list(selector, listConfig) + + const fields = [...listConfig.select, ...listConfig.relations] + const data = notifications.map(o => _.pick(o, fields)) + + res.json({ notifications: data, offset, limit }) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/notifications/resend-notification.js b/packages/medusa/src/api/routes/admin/notifications/resend-notification.js new file mode 100644 index 0000000000..ca9b62b9b5 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/notifications/resend-notification.js @@ -0,0 +1,36 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultFields, defaultRelations } from "./" + +export default async (req, res) => { + const { id } = req.params + + const schema = Validator.object().keys({ + to: Validator.string().optional(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const notificationService = req.scope.resolve("notificationService") + + const config = {} + + if (value.to) { + config.to = value.to + } + + await notificationService.resend(id, config) + + const notification = await notificationService.retrieve(id, { + select: defaultFields, + relations: defaultRelations, + }) + + res.json({ notification }) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/create-claim-shipment.js b/packages/medusa/src/api/routes/admin/orders/create-claim-shipment.js new file mode 100644 index 0000000000..840270e91a --- /dev/null +++ b/packages/medusa/src/api/routes/admin/orders/create-claim-shipment.js @@ -0,0 +1,38 @@ +import { MedusaError, Validator } from "medusa-core-utils" +import { defaultFields, defaultRelations } from "./" + +export default async (req, res) => { + const { id, claim_id } = req.params + + const schema = Validator.object().keys({ + fulfillment_id: Validator.string().required(), + tracking_numbers: Validator.array() + .items(Validator.string()) + .optional(), + }) + + const { value, error } = schema.validate(req.body) + if (error) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details) + } + + try { + const orderService = req.scope.resolve("orderService") + const claimService = req.scope.resolve("claimService") + + await claimService.createShipment( + claim_id, + value.fulfillment_id, + value.tracking_numbers + ) + + const order = await orderService.retrieve(id, { + select: defaultFields, + relations: defaultRelations, + }) + + res.json({ order }) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/admin/orders/index.js b/packages/medusa/src/api/routes/admin/orders/index.js index 0a22df479b..ffbc5023d9 100644 --- a/packages/medusa/src/api/routes/admin/orders/index.js +++ b/packages/medusa/src/api/routes/admin/orders/index.js @@ -161,6 +161,14 @@ export default app => { middlewares.wrap(require("./fulfill-claim").default) ) + /** + * Creates claim fulfillment + */ + route.post( + "/:id/claims/:claim_id/shipments", + middlewares.wrap(require("./create-claim-shipment").default) + ) + /** * Delete metadata key / value pair. */ diff --git a/packages/medusa/src/api/routes/admin/orders/request-return.js b/packages/medusa/src/api/routes/admin/orders/request-return.js index 9cd28a5799..69a323ed27 100644 --- a/packages/medusa/src/api/routes/admin/orders/request-return.js +++ b/packages/medusa/src/api/routes/admin/orders/request-return.js @@ -53,6 +53,7 @@ export default async (req, res) => { try { const orderService = req.scope.resolve("orderService") const returnService = req.scope.resolve("returnService") + const eventBus = req.scope.resolve("eventBusService") let inProgress = true let err = false @@ -98,6 +99,13 @@ export default async (req, res) => { .fulfill(createdReturn.id) } + await eventBus + .withTransaction(manager) + .emit("order.return_requested", { + id, + return_id: createdReturn.id, + }) + return { recovery_point: "return_requested", } diff --git a/packages/medusa/src/api/routes/store/customers/__tests__/create-customer.js b/packages/medusa/src/api/routes/store/customers/__tests__/create-customer.js index 9db832d3b8..ff8606cf3b 100644 --- a/packages/medusa/src/api/routes/store/customers/__tests__/create-customer.js +++ b/packages/medusa/src/api/routes/store/customers/__tests__/create-customer.js @@ -34,7 +34,7 @@ describe("POST /store/customers", () => { expect(CustomerServiceMock.retrieve).toHaveBeenCalledTimes(1) expect(CustomerServiceMock.retrieve).toHaveBeenCalledWith( IdMap.getId("lebron"), - { relations: ["orders", "shipping_addresses"] } + { relations: ["shipping_addresses"] } ) }) diff --git a/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js b/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js index 903231f5ee..35c54520c4 100644 --- a/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js +++ b/packages/medusa/src/api/routes/store/customers/__tests__/update-customer.js @@ -42,7 +42,7 @@ describe("POST /store/customers/:id", () => { expect(CustomerServiceMock.retrieve).toHaveBeenCalledTimes(1) expect(CustomerServiceMock.retrieve).toHaveBeenCalledWith( IdMap.getId("lebron"), - { relations: ["orders", "shipping_addresses"] } + { relations: ["shipping_addresses"] } ) }) diff --git a/packages/medusa/src/api/routes/store/customers/create-address.js b/packages/medusa/src/api/routes/store/customers/create-address.js index a058b8a80d..3cec6d6d00 100644 --- a/packages/medusa/src/api/routes/store/customers/create-address.js +++ b/packages/medusa/src/api/routes/store/customers/create-address.js @@ -17,7 +17,7 @@ export default async (req, res) => { let customer = await customerService.addAddress(id, value.address) customer = await customerService.retrieve(id, { - relations: ["orders", "shipping_addresses"], + relations: ["shipping_addresses"], }) res.status(200).json({ customer }) diff --git a/packages/medusa/src/api/routes/store/customers/create-customer.js b/packages/medusa/src/api/routes/store/customers/create-customer.js index b3c5467087..3d400a121e 100644 --- a/packages/medusa/src/api/routes/store/customers/create-customer.js +++ b/packages/medusa/src/api/routes/store/customers/create-customer.js @@ -27,7 +27,7 @@ export default async (req, res) => { }) customer = await customerService.retrieve(customer.id, { - relations: ["orders", "shipping_addresses"], + relations: ["shipping_addresses"], }) res.status(200).json({ customer }) diff --git a/packages/medusa/src/api/routes/store/customers/delete-address.js b/packages/medusa/src/api/routes/store/customers/delete-address.js index eb53c1aabe..a564f47af5 100644 --- a/packages/medusa/src/api/routes/store/customers/delete-address.js +++ b/packages/medusa/src/api/routes/store/customers/delete-address.js @@ -6,7 +6,7 @@ export default async (req, res) => { let customer = await customerService.removeAddress(id, address_id) customer = await customerService.retrieve(id, { - relations: ["orders", "shipping_addresses"], + relations: ["shipping_addresses"], }) res.json({ customer: data }) diff --git a/packages/medusa/src/api/routes/store/customers/get-customer.js b/packages/medusa/src/api/routes/store/customers/get-customer.js index e2677231d5..7f472d9ab9 100644 --- a/packages/medusa/src/api/routes/store/customers/get-customer.js +++ b/packages/medusa/src/api/routes/store/customers/get-customer.js @@ -3,7 +3,7 @@ export default async (req, res) => { try { const customerService = req.scope.resolve("customerService") const customer = await customerService.retrieve(id, { - relations: ["orders", "shipping_addresses"], + relations: ["shipping_addresses"], }) res.json({ customer }) } catch (err) { diff --git a/packages/medusa/src/api/routes/store/customers/index.js b/packages/medusa/src/api/routes/store/customers/index.js index b2693ca7b7..374bb5a0ba 100644 --- a/packages/medusa/src/api/routes/store/customers/index.js +++ b/packages/medusa/src/api/routes/store/customers/index.js @@ -33,6 +33,8 @@ export default (app, container) => { route.get("/:id", middlewares.wrap(require("./get-customer").default)) route.post("/:id", middlewares.wrap(require("./update-customer").default)) + route.get("/:id/orders", middlewares.wrap(require("./list-orders").default)) + route.post( "/:id/addresses", middlewares.wrap(require("./create-address").default) diff --git a/packages/medusa/src/api/routes/store/customers/list-orders.js b/packages/medusa/src/api/routes/store/customers/list-orders.js new file mode 100644 index 0000000000..673f967109 --- /dev/null +++ b/packages/medusa/src/api/routes/store/customers/list-orders.js @@ -0,0 +1,50 @@ +import _ from "lodash" +import { + defaultRelations, + defaultFields, + allowedFields, + allowedRelations, +} from "../orders" + +export default async (req, res) => { + const { id } = req.params + try { + const orderService = req.scope.resolve("orderService") + + let selector = { + customer_id: id, + } + + const limit = parseInt(req.query.limit) || 10 + const offset = parseInt(req.query.offset) || 0 + + let includeFields = [] + if ("fields" in req.query) { + includeFields = req.query.fields.split(",") + includeFields = includeFields.filter(f => allowedFields.includes(f)) + } + + let expandFields = [] + if ("expand" in req.query) { + expandFields = req.query.expand.split(",") + expandFields = expandFields.filter(f => allowedRelations.includes(f)) + } + + const listConfig = { + select: includeFields.length ? includeFields : defaultFields, + relations: expandFields.length ? expandFields : defaultRelations, + skip: offset, + take: limit, + order: { created_at: "DESC" }, + } + + const [orders, count] = await orderService.listAndCount( + selector, + listConfig + ) + + res.json({ orders, count, offset, limit }) + } catch (error) { + throw error + } +} diff --git a/packages/medusa/src/api/routes/store/customers/update-address.js b/packages/medusa/src/api/routes/store/customers/update-address.js index 334123f40d..2562dc2410 100644 --- a/packages/medusa/src/api/routes/store/customers/update-address.js +++ b/packages/medusa/src/api/routes/store/customers/update-address.js @@ -21,7 +21,7 @@ export default async (req, res) => { ) customer = await customerService.retrieve(id, { - relations: ["orders", "shipping_addresses"], + relations: ["shipping_addresses"], }) res.json({ customer }) diff --git a/packages/medusa/src/api/routes/store/customers/update-customer.js b/packages/medusa/src/api/routes/store/customers/update-customer.js index 9808eea2c5..106fbe1ebd 100644 --- a/packages/medusa/src/api/routes/store/customers/update-customer.js +++ b/packages/medusa/src/api/routes/store/customers/update-customer.js @@ -20,7 +20,7 @@ export default async (req, res) => { let customer = await customerService.update(id, value) customer = await customerService.retrieve(customer.id, { - relations: ["orders", "shipping_addresses"], + relations: ["shipping_addresses"], }) res.status(200).json({ customer }) diff --git a/packages/medusa/src/api/routes/store/orders/index.js b/packages/medusa/src/api/routes/store/orders/index.js index db8fa09032..12ea0c8541 100644 --- a/packages/medusa/src/api/routes/store/orders/index.js +++ b/packages/medusa/src/api/routes/store/orders/index.js @@ -18,6 +18,7 @@ export default app => { export const defaultRelations = [ "shipping_address", + "fulfillments", "items", "items.variant", "items.variant.product", @@ -46,3 +47,36 @@ export const defaultFields = [ "subtotal", "total", ] + +export const allowedRelations = [ + "shipping_address", + "fulfillments", + "billing_address", + "items", + "items.variant", + "items.variant.product", + "shipping_methods", + "discounts", + "customer", + "payments", + "region", +] + +export const allowedFields = [ + "id", + "display_id", + "cart_id", + "customer_id", + "email", + "region_id", + "currency_code", + "tax_rate", + "created_at", + "shipping_total", + "discount_total", + "tax_total", + "refunded_total", + "gift_card_total", + "subtotal", + "total", +] diff --git a/packages/medusa/src/loaders/defaults.js b/packages/medusa/src/loaders/defaults.js index 946588c7d7..d8da4d1f9a 100644 --- a/packages/medusa/src/loaders/defaults.js +++ b/packages/medusa/src/loaders/defaults.js @@ -13,6 +13,12 @@ export default async ({ container }) => { payIds = payProviders.map(p => p.getIdentifier()) await pProviderService.registerInstalledProviders(payIds) + let notiIds + const nProviderService = container.resolve("notificationService") + const notiProviders = container.resolve("notificationProviders") + notiIds = notiProviders.map(p => p.getIdentifier()) + await nProviderService.registerInstalledProviders(notiIds) + let fulfilIds const fProviderService = container.resolve("fulfillmentProviderService") const fulfilProviders = container.resolve("fulfillmentProviders") diff --git a/packages/medusa/src/loaders/plugins.js b/packages/medusa/src/loaders/plugins.js index a244199ccb..6e5f344341 100644 --- a/packages/medusa/src/loaders/plugins.js +++ b/packages/medusa/src/loaders/plugins.js @@ -4,6 +4,7 @@ import { BaseService, PaymentService, FulfillmentService, + NotificationService, FileService, OauthService, } from "medusa-interfaces" @@ -228,6 +229,20 @@ async function registerServices(pluginDetails, container) { ).singleton(), [`fp_${loaded.identifier}`]: aliasTo(name), }) + } else if (loaded.prototype instanceof NotificationService) { + container.registerAdd( + "notificationProviders", + asFunction(cradle => new loaded(cradle, pluginDetails.options)) + ) + + // Add the service directly to the container in order to make simple + // resolution if we already know which payment provider we need to use + container.register({ + [name]: asFunction( + cradle => new loaded(cradle, pluginDetails.options) + ).singleton(), + [`noti_${loaded.identifier}`]: aliasTo(name), + }) } else if (loaded.prototype instanceof FileService) { // Add the service directly to the container in order to make simple // resolution if we already know which payment provider we need to use diff --git a/packages/medusa/src/migrations/1613146953072-notifications.ts b/packages/medusa/src/migrations/1613146953072-notifications.ts new file mode 100644 index 0000000000..2261ee712b --- /dev/null +++ b/packages/medusa/src/migrations/1613146953072-notifications.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class notifications1613146953072 implements MigrationInterface { + name = "notifications1613146953072" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "notification_provider" ("id" character varying NOT NULL, "is_installed" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_0425c2423e2ce9fdfd5c23761d9" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "notification" ("id" character varying NOT NULL, "event_name" character varying, "resource_type" character varying NOT NULL, "resource_id" character varying NOT NULL, "customer_id" character varying, "to" character varying NOT NULL, "data" jsonb NOT NULL, "parent_id" character varying, "provider_id" character varying, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_705b6c7cdf9b2c2ff7ac7872cb7" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_df1494d263740fcfb1d09a98fc" ON "notification" ("resource_type") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_ea6a358d9ce41c16499aae55f9" ON "notification" ("resource_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_b5df0f53a74b9d0c0a2b652c88" ON "notification" ("customer_id") ` + ) + await queryRunner.query( + `ALTER TABLE "notification" ADD CONSTRAINT "FK_b5df0f53a74b9d0c0a2b652c88d" FOREIGN KEY ("customer_id") REFERENCES "customer"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "notification" ADD CONSTRAINT "FK_371db513192c083f48ba63c33be" FOREIGN KEY ("parent_id") REFERENCES "notification"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "notification" ADD CONSTRAINT "FK_0425c2423e2ce9fdfd5c23761d9" FOREIGN KEY ("provider_id") REFERENCES "notification_provider"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_0425c2423e2ce9fdfd5c23761d9"` + ) + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_371db513192c083f48ba63c33be"` + ) + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_b5df0f53a74b9d0c0a2b652c88d"` + ) + await queryRunner.query(`DROP INDEX "IDX_b5df0f53a74b9d0c0a2b652c88"`) + await queryRunner.query(`DROP INDEX "IDX_ea6a358d9ce41c16499aae55f9"`) + await queryRunner.query(`DROP INDEX "IDX_df1494d263740fcfb1d09a98fc"`) + await queryRunner.query(`DROP TABLE "notification"`) + await queryRunner.query(`DROP TABLE "notification_provider"`) + } +} diff --git a/packages/medusa/src/migrations/1611909563253-product_type_category_tags.ts b/packages/medusa/src/migrations/1613146953073-product_type_category_tags.ts similarity index 97% rename from packages/medusa/src/migrations/1611909563253-product_type_category_tags.ts rename to packages/medusa/src/migrations/1613146953073-product_type_category_tags.ts index 811fc62509..72c0abf38b 100644 --- a/packages/medusa/src/migrations/1611909563253-product_type_category_tags.ts +++ b/packages/medusa/src/migrations/1613146953073-product_type_category_tags.ts @@ -1,8 +1,8 @@ import { MigrationInterface, QueryRunner } from "typeorm" -export class productTypeCategoryTags1611909563253 +export class productTypeCategoryTags1613146953073 implements MigrationInterface { - name = "productTypeCategoryTags1611909563253" + name = "productTypeCategoryTags1613146953073" public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( diff --git a/packages/medusa/src/models/notification-provider.ts b/packages/medusa/src/models/notification-provider.ts new file mode 100644 index 0000000000..9adf7f8eed --- /dev/null +++ b/packages/medusa/src/models/notification-provider.ts @@ -0,0 +1,10 @@ +import { Entity, Column, PrimaryColumn } from "typeorm" + +@Entity() +export class NotificationProvider { + @PrimaryColumn() + id: string + + @Column({ default: true }) + is_installed: boolean +} diff --git a/packages/medusa/src/models/notification.ts b/packages/medusa/src/models/notification.ts new file mode 100644 index 0000000000..3cb9c74b6c --- /dev/null +++ b/packages/medusa/src/models/notification.ts @@ -0,0 +1,80 @@ +import { + Entity, + BeforeInsert, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + PrimaryColumn, + OneToMany, + ManyToOne, + JoinColumn, +} from "typeorm" +import { ulid } from "ulid" + +import { Customer } from "./customer" +import { NotificationProvider } from "./notification-provider" + +@Entity() +export class Notification { + @PrimaryColumn() + id: string + + @Column({ nullable: true }) + event_name: string + + @Index() + @Column() + resource_type: string + + @Index() + @Column() + resource_id: string + + @Index() + @Column({ nullable: true }) + customer_id: string + + @ManyToOne(() => Customer) + @JoinColumn({ name: "customer_id" }) + customer: Customer + + @Column() + to: string + + @Column({ type: "jsonb" }) + data: any + + @Column({ nullable: true }) + parent_id: string + + @ManyToOne(() => Notification) + @JoinColumn({ name: "parent_id" }) + parent_notification: Notification + + @OneToMany( + () => Notification, + noti => noti.parent_notification + ) + resends: Notification[] + + @Column({ nullable: true }) + provider_id: string + + @ManyToOne(() => NotificationProvider) + @JoinColumn({ name: "provider_id" }) + provider: NotificationProvider + + @CreateDateColumn({ type: "timestamptz" }) + created_at: Date + + @UpdateDateColumn({ type: "timestamptz" }) + updated_at: Date + + @BeforeInsert() + private beforeInsert() { + if (this.id) return + const id = ulid() + this.id = `noti_${id}` + } +} diff --git a/packages/medusa/src/repositories/notification-provider.ts b/packages/medusa/src/repositories/notification-provider.ts new file mode 100644 index 0000000000..b0f6b44d8c --- /dev/null +++ b/packages/medusa/src/repositories/notification-provider.ts @@ -0,0 +1,7 @@ +import { EntityRepository, Repository } from "typeorm" +import { NotificationProvider } from "../models/notification-provider" + +@EntityRepository(NotificationProvider) +export class NotificationProviderRepository extends Repository< + NotificationProvider +> {} diff --git a/packages/medusa/src/repositories/notification.ts b/packages/medusa/src/repositories/notification.ts new file mode 100644 index 0000000000..beea98ae58 --- /dev/null +++ b/packages/medusa/src/repositories/notification.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm" +import { Notification } from "../models/notification" + +@EntityRepository(Notification) +export class NotificationRepository extends Repository {} diff --git a/packages/medusa/src/services/__mocks__/event-bus.js b/packages/medusa/src/services/__mocks__/event-bus.js index e9031d9428..19eeb4f943 100644 --- a/packages/medusa/src/services/__mocks__/event-bus.js +++ b/packages/medusa/src/services/__mocks__/event-bus.js @@ -1,6 +1,9 @@ export const EventBusServiceMock = { emit: jest.fn(), subscribe: jest.fn(), + withTransaction: function() { + return this + }, } const mock = jest.fn().mockImplementation(() => { diff --git a/packages/medusa/src/services/__tests__/notification.js b/packages/medusa/src/services/__tests__/notification.js new file mode 100644 index 0000000000..f26ab26d15 --- /dev/null +++ b/packages/medusa/src/services/__tests__/notification.js @@ -0,0 +1,54 @@ +import NotificationService from "../notification" +import { IdMap, MockManager, MockRepository } from "medusa-test-utils" + +describe("NotificationService", () => { + describe("send", () => { + const notificationRepository = MockRepository({ create: c => c }) + + const container = { + manager: MockManager, + notificationRepository, + noti_test: { + sendNotification: jest.fn(() => + Promise.resolve({ + to: "test@mail.com", + data: { id: "something" }, + }) + ), + }, + } + + const notificationService = new NotificationService(container) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully calls provider and saves noti", async () => { + await notificationService.send("event.test", { id: "test" }, "test") + + expect(container.noti_test.sendNotification).toHaveBeenCalledTimes(1) + expect(container.noti_test.sendNotification).toHaveBeenCalledWith( + "event.test", + { id: "test" }, + null + ) + + const constructed = { + resource_type: "event", + resource_id: "test", + customer_id: null, + to: "test@mail.com", + data: { id: "something" }, + event_name: "event.test", + provider_id: "test", + } + + expect(notificationRepository.create).toHaveBeenCalledTimes(1) + expect(notificationRepository.create).toHaveBeenCalledWith(constructed) + + expect(notificationRepository.save).toHaveBeenCalledTimes(1) + expect(notificationRepository.save).toHaveBeenCalledWith(constructed) + }) + }) +}) diff --git a/packages/medusa/src/services/__tests__/order.js b/packages/medusa/src/services/__tests__/order.js index 3ee3037e54..61f900a3e0 100644 --- a/packages/medusa/src/services/__tests__/order.js +++ b/packages/medusa/src/services/__tests__/order.js @@ -10,7 +10,23 @@ describe("OrderService", () => { getRefundedTotal: o => { return o.refunded_total || 0 }, + getShippingTotal: o => { + return o.shipping_total || 0 + }, + getGiftCardTotal: o => { + return o.gift_card_total || 0 + }, + getDiscountTotal: o => { + return o.discount_total || 0 + }, + getTaxTotal: o => { + return o.tax_total || 0 + }, + getSubtotal: o => { + return o.subtotal || 0 + }, } + const eventBusService = { emit: jest.fn(), withTransaction: function() { diff --git a/packages/medusa/src/services/__tests__/swap.js b/packages/medusa/src/services/__tests__/swap.js index eb5a7dd31a..5881037232 100644 --- a/packages/medusa/src/services/__tests__/swap.js +++ b/packages/medusa/src/services/__tests__/swap.js @@ -1,6 +1,13 @@ import { IdMap, MockRepository, MockManager } from "medusa-test-utils" import SwapService from "../swap" +const eventBusService = { + emit: jest.fn(), + withTransaction: function() { + return this + }, +} + const generateOrder = (orderId, items, additional = {}) => { return { id: IdMap.getId(orderId), @@ -70,7 +77,9 @@ describe("SwapService", () => { }) it("fails if item is returned", async () => { - const swapService = new SwapService({}) + const swapService = new SwapService({ + eventBusService, + }) const res = () => swapService.validateReturnItems_( { @@ -168,6 +177,7 @@ describe("SwapService", () => { const swapService = new SwapService({ manager: MockManager, + eventBusService, swapRepository: swapRepo, cartService, lineItemService, @@ -236,6 +246,7 @@ describe("SwapService", () => { }) const swapService = new SwapService({ manager: MockManager, + eventBusService, swapRepository: swapRepo, }) const res = swapService.createCart(IdMap.getId("swap-1")) @@ -274,6 +285,7 @@ describe("SwapService", () => { const swapService = new SwapService({ manager: MockManager, + eventBusService, swapRepository: swapRepo, returnService, lineItemService, @@ -358,6 +370,7 @@ describe("SwapService", () => { }) const swapService = new SwapService({ manager: MockManager, + eventBusService, swapRepository: swapRepo, returnService, }) @@ -401,6 +414,7 @@ describe("SwapService", () => { }) const swapService = new SwapService({ manager: MockManager, + eventBusService, swapRepository: swapRepo, returnService, }) @@ -475,6 +489,7 @@ describe("SwapService", () => { }) const swapService = new SwapService({ manager: MockManager, + eventBusService, swapRepository: swapRepo, fulfillmentService, lineItemService, @@ -595,6 +610,7 @@ describe("SwapService", () => { const swapService = new SwapService({ manager: MockManager, + eventBusService, swapRepository: swapRepo, lineItemService, eventBusService, @@ -691,6 +707,7 @@ describe("SwapService", () => { const swapService = new SwapService({ manager: MockManager, + eventBusService, swapRepository: swapRepo, totalsService, paymentProviderService, @@ -770,6 +787,7 @@ describe("SwapService", () => { const swapService = new SwapService({ manager: MockManager, + eventBusService, swapRepository: swapRepo, paymentProviderService, eventBusService, diff --git a/packages/medusa/src/services/claim.js b/packages/medusa/src/services/claim.js index 6f9db7891a..f8aa1db3ae 100644 --- a/packages/medusa/src/services/claim.js +++ b/packages/medusa/src/services/claim.js @@ -99,7 +99,7 @@ class ClaimService extends BaseService { const { claim_items, shipping_methods, metadata } = data if (metadata) { - claim.metadata = this.setMetadata_(claim, update.metadata) + claim.metadata = this.setMetadata_(claim, metadata) await claimRepo.save(claim) } diff --git a/packages/medusa/src/services/customer.js b/packages/medusa/src/services/customer.js index e5ffc6bd47..6db5092f03 100644 --- a/packages/medusa/src/services/customer.js +++ b/packages/medusa/src/services/customer.js @@ -110,6 +110,7 @@ class CustomerService extends BaseService { const token = jwt.sign(payload, secret) // Notify subscribers this.eventBus_.emit(CustomerService.Events.PASSWORD_RESET, { + id: customerId, email: customer.email, first_name: customer.first_name, last_name: customer.last_name, @@ -292,6 +293,7 @@ class CustomerService extends BaseService { const { email, + password, password_hash, billing_address, metadata, @@ -314,6 +316,10 @@ class CustomerService extends BaseService { customer[key] = value } + if (password) { + customer.password_hash = await this.hashPassword_(password) + } + const updated = await customerRepository.save(customer) await this.eventBus_ .withTransaction(manager) diff --git a/packages/medusa/src/services/event-bus.js b/packages/medusa/src/services/event-bus.js index a5db480c43..962fe125c3 100644 --- a/packages/medusa/src/services/event-bus.js +++ b/packages/medusa/src/services/event-bus.js @@ -217,14 +217,18 @@ class EventBusService { */ worker_ = job => { const { eventName, data } = job.data - const observers = this.observers_[eventName] || [] + const eventObservers = this.observers_[eventName] || [] + const wildcardObservers = this.observers_["*"] || [] + + const observers = eventObservers.concat(wildcardObservers) + this.logger_.info( - `Processing ${eventName} which has ${observers.length} subscribers` + `Processing ${eventName} which has ${eventObservers.length} subscribers` ) return Promise.all( observers.map(subscriber => { - return subscriber(data).catch(err => { + return subscriber(data, eventName).catch(err => { this.logger_.warn( `An error occured while processing ${eventName}: ${err}` ) @@ -242,7 +246,7 @@ class EventBusService { return Promise.all( observers.map(subscriber => { - return subscriber(data).catch(err => { + return subscriber(data, eventName).catch(err => { this.logger_.warn( `An error occured while processing ${eventName}: ${err}` ) diff --git a/packages/medusa/src/services/fulfillment-provider.js b/packages/medusa/src/services/fulfillment-provider.js index 3a73adb74c..dbad8ac1c4 100644 --- a/packages/medusa/src/services/fulfillment-provider.js +++ b/packages/medusa/src/services/fulfillment-provider.js @@ -89,6 +89,18 @@ class FulfillmentProviderService { const provider = this.retrieveProvider(option.provider_id) return provider.createReturn(returnOrder) } + + /** + * Fetches documents from the fulfillment provider + * @param {string} providerId - the id of the provider + * @param {object} fulfillmentData - the data relating to the fulfillment + * @param {"invoice" | "label"} documentType - the typ of + * document to fetch + */ + async retrieveDocuments(providerId, fulfillmentData, documentType) { + const provider = this.retrieveProvider(providerId) + return provider.retrieveDocuments(fulfillmentData, documentType) + } } export default FulfillmentProviderService diff --git a/packages/medusa/src/services/notification.js b/packages/medusa/src/services/notification.js new file mode 100644 index 0000000000..df75b7fdc0 --- /dev/null +++ b/packages/medusa/src/services/notification.js @@ -0,0 +1,254 @@ +import { MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" +import _ from "lodash" + +/** + * Provides layer to manipulate orchestrate notifications. + * @implements BaseService + */ +class NotificationService extends BaseService { + constructor(container) { + super() + + const { + manager, + notificationProviderRepository, + notificationRepository, + logger, + } = container + + this.container_ = container + + /** @private @const {EntityManager} */ + this.manager_ = manager + this.logger_ = logger + + /** @private @const {NotificationRepository} */ + this.notificationRepository_ = notificationRepository + this.notificationProviderRepository_ = notificationProviderRepository + + this.subscribers_ = {} + this.attachmentGenerator_ = null + } + + /** + * Registers an attachment generator to the service. The generator can be + * used to generate on demand invoices or other documents. + */ + registerAttachmentGenerator(service) { + this.attachmentGenerator_ = service + } + + /** + * Sets the service's manager to a given transaction manager. + * @parma {EntityManager} transactionManager - the manager to use + * return {NotificationService} a cloned notification service + */ + withTransaction(transactionManager) { + if (!transactionManager) { + return this + } + + const cloned = new LineItemService({ + manager: transactionManager, + notificationRepository: this.notificationRepository_, + }) + + cloned.transactionManager_ = transactionManager + + return cloned + } + + /** + * Takes a list of notification provider ids and persists them in the database. + * @param {Array} providers - a list of provider ids + */ + async registerInstalledProviders(providers) { + const { manager, notificationProviderRepository } = this.container_ + const model = manager.getCustomRepository(notificationProviderRepository) + model.update({}, { is_installed: false }) + for (const p of providers) { + const n = model.create({ id: p, is_installed: true }) + await model.save(n) + } + } + + /** + * Retrieves a list of notifications. + * @param {object} selector - the params to select the notifications by. + * @param {object} config - the configuration to apply to the query + * @return {Array} the notifications that satisfy the query. + */ + async list( + selector, + config = { skip: 0, take: 50, order: { created_at: "DESC" } } + ) { + const notiRepo = this.manager_.getCustomRepository( + this.notificationRepository_ + ) + const query = this.buildQuery_(selector, config) + return notiRepo.find(query) + } + + /** + * Retrieves a notification with a given id + * @param {string} id - the id of the notification + * @return {Notification} the notification + */ + async retrieve(id, config = {}) { + const notiRepository = this.manager_.getCustomRepository( + this.notificationRepository_ + ) + + const validatedId = this.validateId_(id) + const query = this.buildQuery_({ id: validatedId }, config) + + const notification = await notiRepository.findOne(query) + + if (!notification) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Notification with id: ${id} was not found.` + ) + } + + return notification + } + + /** + * Subscribes a given provider to an event. + * @param {string} eventName - the event to subscribe to + * @param {string} providerId - the provider that the event will be sent to + */ + subscribe(eventName, providerId) { + if (typeof providerId !== "string") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "providerId must be a string" + ) + } + + if (this.subscribers_[eventName]) { + this.subscribers_[eventName].push(providerId) + } else { + this.subscribers_[eventName] = [providerId] + } + } + + /** + * Finds a provider with a given id. Will throw a NOT_FOUND error if the + * resolution fails. + * @param {string} id - the id of the provider + * @return {NotificationProvider} the notification provider + */ + retrieveProvider_(id) { + try { + return this.container_[`noti_${id}`] + } catch (err) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Could not find a notification provider with id: ${id}.` + ) + } + } + + /** + * Handles an event by relaying the event data to the subscribing providers. + * The result of the notification send will be persisted in the database in + * order to allow for resends. Will log any errors that are encountered. + * @param {string} eventName - the event to handle + * @param {object} data - the data the event was sent with + */ + handleEvent(eventName, data) { + const subs = this.subscribers_[eventName] + if (!subs) { + return + } + + return Promise.all( + subs.map(async providerId => { + return this.send(eventName, data, providerId).catch(err => { + console.log(err) + this.logger_.warn( + `An error occured while ${providerId} was processing a notification for ${eventName}: ${err.message}` + ) + }) + }) + ) + } + + /** + * Sends a notification, by calling the given provider's sendNotification + * method. Persists the Notification in the database. + * @param {string} event - the name of the event + * @param {object} eventData - the data the event was sent with + * @param {string} providerId - the provider that should hande the event. + * @return {Notification} the created notification + */ + async send(event, eventData, providerId) { + const provider = this.retrieveProvider_(providerId) + const result = await provider.sendNotification( + event, + eventData, + this.attachmentGenerator_ + ) + + if (!result) { + return + } + + const { to, data } = result + const notiRepo = this.manager_.getCustomRepository( + this.notificationRepository_ + ) + + const [resource_type] = event.split(".") + const resource_id = eventData.id + const customer_id = eventData.customer_id || null + + const created = notiRepo.create({ + resource_type, + resource_id, + customer_id, + to, + data, + event_name: event, + provider_id: providerId, + }) + + return notiRepo.save(created) + } + + /** + * Resends a notification by retrieving a prior notification and calling the + * underlying provider's resendNotification method. + * @param {string} id - the id of the notification + * @param {object} config - any configuration that might override the previous + * send + * @return {Notification} the newly created notification + */ + async resend(id, config = {}) { + const notification = await this.retrieve(id) + + const provider = this.retrieveProvider_(notification.provider_id) + const { to, data } = await provider.resendNotification( + notification, + config, + this.attachmentGenerator_ + ) + + const notiRepo = this.manager_.getCustomRepository( + this.notificationRepository_ + ) + const created = notiRepo.create({ + ...notification, + to, + data, + parent_id: id, + }) + + return notiRepo.save(created) + } +} + +export default NotificationService diff --git a/packages/medusa/src/services/order.js b/packages/medusa/src/services/order.js index 19f23a1a95..4e0b430e3d 100644 --- a/packages/medusa/src/services/order.js +++ b/packages/medusa/src/services/order.js @@ -959,7 +959,16 @@ class OrderService extends BaseService { async createFulfillment(orderId, itemsToFulfill, metadata = {}) { return this.atomicPhase_(async manager => { const order = await this.retrieve(orderId, { + select: [ + "subtotal", + "shipping_total", + "discount_total", + "tax_total", + "gift_card_total", + "total", + ], relations: [ + "discounts", "region", "fulfillments", "shipping_address", diff --git a/packages/medusa/src/services/swap.js b/packages/medusa/src/services/swap.js index 9393052c55..bc97b3aff4 100644 --- a/packages/medusa/src/services/swap.js +++ b/packages/medusa/src/services/swap.js @@ -8,6 +8,7 @@ import { MedusaError } from "medusa-core-utils" */ class SwapService extends BaseService { static Events = { + CREATED: "swap.created", SHIPMENT_CREATED: "swap.shipment_created", PAYMENT_COMPLETED: "swap.payment_completed", PAYMENT_CAPTURED: "swap.payment_captured", @@ -247,6 +248,12 @@ class SwapService extends BaseService { order ) + await this.eventBus_ + .withTransaction(manager) + .emit(SwapService.Events.CREATED, { + id: result.id, + }) + return result }) } diff --git a/packages/medusa/src/services/totals.js b/packages/medusa/src/services/totals.js index 7972ffab40..c426e58d89 100644 --- a/packages/medusa/src/services/totals.js +++ b/packages/medusa/src/services/totals.js @@ -94,7 +94,10 @@ class TotalsService extends BaseService { getLineItemRefund(object, lineItem) { const { discounts } = object - const tax_rate = object.tax_rate || object.region.tax_rate + const tax_rate = + typeof object.tax_rate !== "undefined" + ? object.tax_rate + : object.region.tax_rate const taxRate = (tax_rate || 0) / 100 const discount = discounts.find(({ rule }) => rule.type !== "free_shipping") diff --git a/packages/medusa/src/subscribers/notification.js b/packages/medusa/src/subscribers/notification.js new file mode 100644 index 0000000000..a251129321 --- /dev/null +++ b/packages/medusa/src/subscribers/notification.js @@ -0,0 +1,15 @@ +class NotificationSubscriber { + constructor({ eventBusService, notificationService }) { + this.notificationService_ = notificationService + + this.eventBus_ = eventBusService + + this.eventBus_.subscribe("*", this.onEvent) + } + + onEvent = (data, eventName) => { + return this.notificationService_.handleEvent(eventName, data) + } +} + +export default NotificationSubscriber diff --git a/scripts/assert-changed-files.sh b/scripts/assert-changed-files.sh index 63c58d51e5..4f5833cc14 100755 --- a/scripts/assert-changed-files.sh +++ b/scripts/assert-changed-files.sh @@ -23,8 +23,10 @@ fi FILES_COUNT="$(git diff-tree --no-commit-id --name-only -r "$CIRCLE_BRANCH" origin/master | grep -E "$GREP_PATTERN" -c)" -# reset to previous state -git reset --hard HEAD@{1} +if [ "$IS_CI" = true ]; then + # reset to previous state + git reset --hard $CIRCLE_SHA1 +fi if [ "$FILES_COUNT" -eq 0 ]; then echo "0 files matching '$GREP_PATTERN'; exiting and marking successful."