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.
This commit is contained in:
Sebastian Rindom
2021-02-15 11:59:37 +01:00
committed by GitHub
parent 4229e241d0
commit 7308946e56
46 changed files with 1538 additions and 254 deletions
@@ -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