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
@@ -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 []
}
}
/**
+1
View File
@@ -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"
@@ -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
@@ -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
@@ -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")
}
}
@@ -10,13 +10,6 @@ class UserSubscriber {
data
)
})
this.eventBus_.subscribe("customer.password_reset", async (data) => {
await this.sendgridService_.transactionalEmail(
"customer.password_reset",
data
)
})
}
}
@@ -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
}
@@ -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",
]
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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.
*/
@@ -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",
}
@@ -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"] }
)
})
@@ -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"] }
)
})
@@ -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 })
@@ -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 })
@@ -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 })
@@ -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) {
@@ -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)
@@ -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
}
}
@@ -21,7 +21,7 @@ export default async (req, res) => {
)
customer = await customerService.retrieve(id, {
relations: ["orders", "shipping_addresses"],
relations: ["shipping_addresses"],
})
res.json({ customer })
@@ -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 })
@@ -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",
]
+6
View File
@@ -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")
+15
View File
@@ -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
@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class notifications1613146953072 implements MigrationInterface {
name = "notifications1613146953072"
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`)
}
}
@@ -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<void> {
await queryRunner.query(
@@ -0,0 +1,10 @@
import { Entity, Column, PrimaryColumn } from "typeorm"
@Entity()
export class NotificationProvider {
@PrimaryColumn()
id: string
@Column({ default: true })
is_installed: boolean
}
@@ -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}`
}
}
@@ -0,0 +1,7 @@
import { EntityRepository, Repository } from "typeorm"
import { NotificationProvider } from "../models/notification-provider"
@EntityRepository(NotificationProvider)
export class NotificationProviderRepository extends Repository<
NotificationProvider
> {}
@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { Notification } from "../models/notification"
@EntityRepository(Notification)
export class NotificationRepository extends Repository<Notification> {}
@@ -1,6 +1,9 @@
export const EventBusServiceMock = {
emit: jest.fn(),
subscribe: jest.fn(),
withTransaction: function() {
return this
},
}
const mock = jest.fn().mockImplementation(() => {
@@ -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)
})
})
})
@@ -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() {
+19 -1
View File
@@ -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,
+1 -1
View File
@@ -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)
}
+6
View File
@@ -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)
+8 -4
View File
@@ -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}`
)
@@ -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
@@ -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<string>} 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<Notification>} 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
+9
View File
@@ -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",
+7
View File
@@ -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
})
}
+4 -1
View File
@@ -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")
@@ -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
+4 -2
View File
@@ -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."