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."