Merge pull request #228 from medusajs/plugin/restock-notification
Plugin/restock notification
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"@babel/cli": "^7.7.5",
|
||||
"@babel/core": "^7.7.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
||||
"@babel/plugin-transform-instanceof": "^7.12.13",
|
||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||
"@babel/preset-env": "^7.7.5",
|
||||
"@babel/runtime": "^7.9.6",
|
||||
|
||||
12
packages/medusa-plugin-restock-notification/.babelrc.js
Normal file
12
packages/medusa-plugin-restock-notification/.babelrc.js
Normal file
@@ -0,0 +1,12 @@
|
||||
let ignore = [`**/dist`]
|
||||
|
||||
// Jest needs to compile this code, but generally we don't want this copied
|
||||
// to output folders
|
||||
if (process.env.NODE_ENV !== `test`) {
|
||||
ignore.push(`**/__tests__`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
presets: [["babel-preset-medusa-package"], ["@babel/preset-typescript"]],
|
||||
ignore,
|
||||
}
|
||||
9
packages/medusa-plugin-restock-notification/.eslintrc
Normal file
9
packages/medusa-plugin-restock-notification/.eslintrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"plugins": ["prettier"],
|
||||
"extends": ["prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"semi": "error",
|
||||
"no-unused-expressions": "true"
|
||||
}
|
||||
}
|
||||
18
packages/medusa-plugin-restock-notification/.gitignore
vendored
Normal file
18
packages/medusa-plugin-restock-notification/.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
/lib
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
/*.js
|
||||
!.babelrc*
|
||||
!index.js
|
||||
!jest.config.js
|
||||
|
||||
/dist
|
||||
|
||||
/api
|
||||
/services
|
||||
/models
|
||||
/subscribers
|
||||
/migrations
|
||||
/repositories
|
||||
|
||||
9
packages/medusa-plugin-restock-notification/.npmignore
Normal file
9
packages/medusa-plugin-restock-notification/.npmignore
Normal file
@@ -0,0 +1,9 @@
|
||||
/lib
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
/*.js
|
||||
!index.js
|
||||
yarn.lock
|
||||
|
||||
|
||||
7
packages/medusa-plugin-restock-notification/.prettierrc
Normal file
7
packages/medusa-plugin-restock-notification/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
89
packages/medusa-plugin-restock-notification/README.md
Normal file
89
packages/medusa-plugin-restock-notification/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# medusa-plugin-restock-notification
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
Install the plugin:
|
||||
|
||||
`$ yarn add medusa-plugin-restock-notification`
|
||||
|
||||
```js
|
||||
// medusa-config.js
|
||||
|
||||
module.exports = {
|
||||
...,
|
||||
plugins: [
|
||||
...,
|
||||
`medusa-plugin-restock-notification`
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The plugin will migrate your database to include the RestockNotification entity, which consists of a variant id of a sold out item and a jsonb list of arrays that wish to be notified about restocks for the item.
|
||||
|
||||
|
||||
## API endpoint
|
||||
|
||||
The plugin exposes an endpoint to sign emails up for restock notifications:
|
||||
|
||||
```
|
||||
POST /restock-notifications/variants/:variant_id
|
||||
|
||||
Body
|
||||
{
|
||||
"email": "seb@test.com"
|
||||
}
|
||||
```
|
||||
|
||||
The endpoint responds with `200 OK` on succesful signups. If a signup for an already in stock item is attempted the endpoint will have a 400 response code.
|
||||
|
||||
|
||||
## Restock events
|
||||
|
||||
The plugin listens for the `product-variant.updated` call and emits a `restock-notification.restocked` event when a variant with restock signups become available.
|
||||
|
||||
The data sent with the `restock-notification.restocked` event is:
|
||||
```
|
||||
variant_id: The id of the variant to listen for restock events for.
|
||||
emails: An array of emails that are to be notified of restocks.
|
||||
|
||||
e.g.
|
||||
|
||||
{
|
||||
"variant_id": "variant_1234567890",
|
||||
"emails": ["seb@test.com", "oli@test.com"]
|
||||
}
|
||||
```
|
||||
|
||||
*Note: This plugin does not send any communication to the customer, communication logic must be implemented or provided through a communication plugin.*
|
||||
|
||||
You may use `medusa-plugin-sendgrid` to orchestrate transactional emails.
|
||||
|
||||
|
||||
## Usage with medusa-plugin-sendgrid
|
||||
|
||||
Install the plugins:
|
||||
`$ yarn add medusa-plugin-restock-notification medusa-plugin-sendgrid`
|
||||
|
||||
```js
|
||||
// medusa-config.js
|
||||
|
||||
module.exports = {
|
||||
...,
|
||||
plugins: [
|
||||
...,
|
||||
`medusa-plugin-restock-notification`,
|
||||
{
|
||||
resolve: `medusa-plugin-sendgrid`,
|
||||
options: {
|
||||
from: SENDGRID_FROM,
|
||||
api_key: SENDGRID_API_KEY,
|
||||
medusa_restock_template: `d-13141234123412342314`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
You should set up a dynamic template in SendGrid which will be send for each of the emails in the restock notification.
|
||||
|
||||
1
packages/medusa-plugin-restock-notification/index.js
Normal file
1
packages/medusa-plugin-restock-notification/index.js
Normal file
@@ -0,0 +1 @@
|
||||
// noop
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
}
|
||||
41
packages/medusa-plugin-restock-notification/package.json
Normal file
41
packages/medusa-plugin-restock-notification/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "medusa-plugin-restock-notification",
|
||||
"version": "0.0.1",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/medusa-plugin-restock-notification"
|
||||
},
|
||||
"author": "Sebastian Rindom <seb@medusa-commerce.com>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.7.5",
|
||||
"@babel/core": "^7.7.5",
|
||||
"@babel/plugin-transform-typescript": "^7.13.0",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"babel-preset-medusa-package": "^1.1.0",
|
||||
"cross-env": "^5.2.1",
|
||||
"eslint": "^6.8.0",
|
||||
"jest": "^25.5.2",
|
||||
"medusa-test-utils": "^1.1.6",
|
||||
"pg": "^8.5.1",
|
||||
"ulid": "^2.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "babel src -d . --ignore **/__tests__ --extensions \".ts,.js\"",
|
||||
"prepare": "cross-env NODE_ENV=production npm run build",
|
||||
"watch": "babel -w src --out-dir . --ignore **/__tests__",
|
||||
"test": "jest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"medusa-interfaces": "1.x"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medusajs/medusa": "^1.1.17",
|
||||
"body-parser": "^1.19.0",
|
||||
"express": "^4.17.1",
|
||||
"medusa-core-utils": "^1.1.3"
|
||||
},
|
||||
"gitHead": "0646bd395a6056657cb0aa93c13699c4a9dbbcdd"
|
||||
}
|
||||
10
packages/medusa-plugin-restock-notification/src/api/index.js
Normal file
10
packages/medusa-plugin-restock-notification/src/api/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Router } from "express"
|
||||
import routes from "./routes"
|
||||
|
||||
export default (container) => {
|
||||
const app = Router()
|
||||
|
||||
routes(app)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default (fn) => (...args) => fn(...args).catch(args[2])
|
||||
@@ -0,0 +1,5 @@
|
||||
import { default as wrap } from "./await-middleware"
|
||||
|
||||
export default {
|
||||
wrap,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Validator, MedusaError } from "medusa-core-utils"
|
||||
|
||||
export default async (req, res) => {
|
||||
const { variant_id } = req.params
|
||||
|
||||
const schema = Validator.object().keys({
|
||||
email: Validator.string().required(),
|
||||
})
|
||||
|
||||
const { value, error } = schema.validate(req.body)
|
||||
if (error) {
|
||||
res.status(400).json({ message: error.message })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const restockNotificationService = req.scope.resolve(
|
||||
"restockNotificationService"
|
||||
)
|
||||
await restockNotificationService.addEmail(variant_id, value.email)
|
||||
res.sendStatus(201)
|
||||
} catch (err) {
|
||||
res.status(400).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Router } from "express"
|
||||
import bodyParser from "body-parser"
|
||||
import middlewares from "../middleware"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default (app) => {
|
||||
app.use("/restock-notifications", route)
|
||||
|
||||
route.post(
|
||||
"/variants/:variant_id",
|
||||
bodyParser.json(),
|
||||
middlewares.wrap(require("./add-email").default)
|
||||
)
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||
|
||||
export class restockNotification1617703530229 implements MigrationInterface {
|
||||
name = "restockNotification1617703530229"
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "restock_notification" ("variant_id" character varying NOT NULL, "emails" jsonb NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_49181ca04caac807fcec321705a" PRIMARY KEY ("variant_id"))`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "restock_notification" ADD CONSTRAINT "FK_49181ca04caac807fcec321705a" FOREIGN KEY ("variant_id") REFERENCES "product_variant"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
|
||||
)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "restock_notification" DROP CONSTRAINT "FK_49181ca04caac807fcec321705a"`
|
||||
)
|
||||
await queryRunner.query(`DROP TABLE "restock_notification"`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Entity,
|
||||
Index,
|
||||
BeforeInsert,
|
||||
Column,
|
||||
DeleteDateColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
PrimaryColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from "typeorm"
|
||||
import { ProductVariant } from "@medusajs/medusa"
|
||||
|
||||
@Entity()
|
||||
export class RestockNotification {
|
||||
@PrimaryColumn()
|
||||
variant_id: string
|
||||
|
||||
@ManyToOne(() => ProductVariant)
|
||||
@JoinColumn({ name: "variant_id" })
|
||||
variant: ProductVariant
|
||||
|
||||
@Column({ type: "jsonb" })
|
||||
emails: string[]
|
||||
|
||||
@CreateDateColumn({ type: "timestamptz" })
|
||||
created_at: Date
|
||||
|
||||
@UpdateDateColumn({ type: "timestamptz" })
|
||||
updated_at: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* @schema restock_notification
|
||||
* title: "Restock Notification"
|
||||
* description: "Holds a list of emails that wish to be notifed when an item is restocked."
|
||||
* x-resourceId: restock_notification
|
||||
* properties:
|
||||
* variant_id:
|
||||
* type: string
|
||||
* description: "The id of the variant that customers have signed up to be notified about,"
|
||||
* emails:
|
||||
* type: string[]
|
||||
* description: "The emails of customers who wish to be notified about restocks."
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: "The date time at which the first restock signup was made."
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: "The date time at which the first last signup was made."
|
||||
*/
|
||||
@@ -0,0 +1,166 @@
|
||||
import { MockManager, MockRepository } from "medusa-test-utils"
|
||||
import RestockNotificationService from "../restock-notification"
|
||||
|
||||
describe("RestockNotificationService", () => {
|
||||
const RestockNotificationModel = MockRepository({
|
||||
findOne: (q) => {
|
||||
if (q.where.variant_id === "variant_1234") {
|
||||
return Promise.resolve({
|
||||
variant_id: "variant_1234",
|
||||
emails: ["test@tesmail.com"],
|
||||
})
|
||||
}
|
||||
if (q.where.variant_id === "variant_outofstock") {
|
||||
return Promise.resolve({
|
||||
variant_id: "variant_outofstock",
|
||||
emails: ["test@tesmail.com"],
|
||||
})
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
const ProductVariantService = {
|
||||
retrieve: (id) => {
|
||||
if (id === "variant_instock") {
|
||||
return {
|
||||
id,
|
||||
inventory_quantity: 10,
|
||||
}
|
||||
}
|
||||
if (id === "variant_1234") {
|
||||
return {
|
||||
id,
|
||||
inventory_quantity: 10,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
inventory_quantity: 0,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const EventBusService = {
|
||||
emit: jest.fn(),
|
||||
withTransaction: function () {
|
||||
return this
|
||||
},
|
||||
}
|
||||
|
||||
describe("retrieve", () => {
|
||||
const restockNotiService = new RestockNotificationService({
|
||||
manager: MockManager,
|
||||
productVariantService: ProductVariantService,
|
||||
restockNotificationModel: RestockNotificationModel,
|
||||
eventBusService: EventBusService,
|
||||
})
|
||||
|
||||
it("successfully retrieves", async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
const result = await restockNotiService.retrieve("variant_1234")
|
||||
|
||||
expect(result).toEqual({
|
||||
variant_id: "variant_1234",
|
||||
emails: ["test@tesmail.com"],
|
||||
})
|
||||
})
|
||||
|
||||
it("successfully retrieves with empty response", async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
const result = await restockNotiService.retrieve("variant_non")
|
||||
|
||||
expect(result).toEqual(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("addEmail", () => {
|
||||
const restockNotiService = new RestockNotificationService({
|
||||
manager: MockManager,
|
||||
productVariantService: ProductVariantService,
|
||||
restockNotificationModel: RestockNotificationModel,
|
||||
eventBusService: EventBusService,
|
||||
})
|
||||
|
||||
it("successfully adds email to non-existing noti", async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
await restockNotiService.addEmail("variant_test", "seb@med-test.com")
|
||||
|
||||
expect(RestockNotificationModel.create).toHaveBeenCalledTimes(1)
|
||||
expect(RestockNotificationModel.create).toHaveBeenCalledWith({
|
||||
variant_id: "variant_test",
|
||||
emails: ["seb@med-test.com"],
|
||||
})
|
||||
|
||||
expect(RestockNotificationModel.save).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("successfully adds email to existing noti", async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
await restockNotiService.addEmail("variant_1234", "seb@med-test.com")
|
||||
|
||||
expect(RestockNotificationModel.save).toHaveBeenCalledTimes(1)
|
||||
expect(RestockNotificationModel.save).toHaveBeenCalledWith({
|
||||
variant_id: "variant_1234",
|
||||
emails: ["test@tesmail.com", "seb@med-test.com"],
|
||||
})
|
||||
})
|
||||
|
||||
it("fails to add if in stock", async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
await expect(
|
||||
restockNotiService.addEmail("variant_instock", "seb@med-test.com")
|
||||
).rejects.toThrow(
|
||||
"You cannot sign up for restock notifications on a product that is not sold out"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("triggerRestock", () => {
|
||||
const restockNotiService = new RestockNotificationService({
|
||||
manager: MockManager,
|
||||
productVariantService: ProductVariantService,
|
||||
restockNotificationModel: RestockNotificationModel,
|
||||
eventBusService: EventBusService,
|
||||
})
|
||||
|
||||
it("non-existing noti does nothing", async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
await expect(restockNotiService.triggerRestock("variant_test")).resolves
|
||||
})
|
||||
|
||||
it("existing noti but out of stock does nothing", async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
await expect(restockNotiService.triggerRestock("variant_outofstock"))
|
||||
.resolves
|
||||
})
|
||||
|
||||
it("existing noti emits and deletes", async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
await restockNotiService.triggerRestock("variant_1234")
|
||||
|
||||
expect(EventBusService.emit).toHaveBeenCalledTimes(1)
|
||||
expect(EventBusService.emit).toHaveBeenCalledWith(
|
||||
"restock-notification.restocked",
|
||||
{
|
||||
variant_id: "variant_1234",
|
||||
emails: ["test@tesmail.com"],
|
||||
}
|
||||
)
|
||||
|
||||
expect(RestockNotificationModel.delete).toHaveBeenCalledTimes(1)
|
||||
expect(RestockNotificationModel.delete).toHaveBeenCalledWith(
|
||||
"variant_1234"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { MedusaError } from "medusa-core-utils"
|
||||
import { BaseService } from "medusa-interfaces"
|
||||
|
||||
/**
|
||||
* Restock notifications can be used to keep track of customers who wish to be
|
||||
* notified when a certain item is restocked. Restock notifications can only
|
||||
* apply to sold out items and will be deleted once items are restocked.
|
||||
*/
|
||||
class RestockNotificationService extends BaseService {
|
||||
constructor(
|
||||
{
|
||||
manager,
|
||||
eventBusService,
|
||||
productVariantService,
|
||||
restockNotificationModel,
|
||||
},
|
||||
options
|
||||
) {
|
||||
super()
|
||||
|
||||
this.manager_ = manager
|
||||
|
||||
this.options_ = options
|
||||
|
||||
this.productVariantService_ = productVariantService
|
||||
|
||||
this.restockNotificationModel_ = restockNotificationModel
|
||||
|
||||
this.eventBus_ = eventBusService
|
||||
}
|
||||
|
||||
withTransaction(transactionManager) {
|
||||
if (!transactionManager) {
|
||||
return this
|
||||
}
|
||||
|
||||
const cloned = new RestockNotificationService({
|
||||
manager: transactionManager,
|
||||
options: this.options_,
|
||||
eventBusService: this.eventBus_,
|
||||
productVariantService: this.productVariantService_,
|
||||
})
|
||||
|
||||
cloned.transactionManager_ = transactionManager
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a restock notification by a given variant id.
|
||||
* @param {string} variantId - the variant id to retrieve restock notification
|
||||
* for
|
||||
* @return {Promise<RestockNotification>} The restock notification
|
||||
*/
|
||||
async retrieve(variantId) {
|
||||
const restockRepo = this.manager_.getRepository(
|
||||
this.restockNotificationModel_
|
||||
)
|
||||
return await restockRepo.findOne({ where: { variant_id: variantId } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an email to be notified when a certain variant is restocked. Throws if
|
||||
* the variant is not sold out.
|
||||
* @param {string} variantId - the variant id to sign up for notifications for
|
||||
* @param {string} email - the email to signup
|
||||
* @return {Promise<RestockNotification>} The resulting restock notification
|
||||
*/
|
||||
async addEmail(variantId, email) {
|
||||
return this.atomicPhase_(async (manager) => {
|
||||
const restockRepo = manager.getRepository(this.restockNotificationModel_)
|
||||
const existing = await this.retrieve(variantId)
|
||||
|
||||
if (existing) {
|
||||
// Converting to a set handles duplicates for us
|
||||
const emailSet = new Set(existing.emails)
|
||||
emailSet.add(email)
|
||||
|
||||
existing.emails = Array.from(emailSet)
|
||||
|
||||
return await restockRepo.save(existing)
|
||||
} else {
|
||||
const variant = await this.productVariantService_.retrieve(variantId)
|
||||
|
||||
if (variant.inventory_quantity > 0) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"You cannot sign up for restock notifications on a product that is not sold out"
|
||||
)
|
||||
}
|
||||
|
||||
const created = restockRepo.create({
|
||||
variant_id: variant.id,
|
||||
emails: [email],
|
||||
})
|
||||
|
||||
return await restockRepo.save(created)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if anyone has signed up for restock notifications on a given variant
|
||||
* and emits a restocked event to the event bus. After successful emission the
|
||||
* restock notification is deleted.
|
||||
* @param {string} variantId - the variant id to trigger restock for
|
||||
* @return {Promise<RestockNotification>} The resulting restock notification
|
||||
*/
|
||||
async triggerRestock(variantId) {
|
||||
return this.atomicPhase_(async (manager) => {
|
||||
const restockRepo = manager.getRepository(this.restockNotificationModel_)
|
||||
|
||||
const existing = await this.retrieve(variantId)
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
|
||||
const variant = await this.productVariantService_.retrieve(variantId)
|
||||
if (variant.inventory_quantity > 0) {
|
||||
await this.eventBus_
|
||||
.withTransaction(manager)
|
||||
.emit("restock-notification.restocked", {
|
||||
variant_id: variantId,
|
||||
emails: existing.emails,
|
||||
})
|
||||
await restockRepo.delete(variantId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default RestockNotificationService
|
||||
@@ -0,0 +1,25 @@
|
||||
class VariantSubscriber {
|
||||
constructor({ manager, eventBusService, restockNotificationService }) {
|
||||
this.manager_ = manager
|
||||
this.restockNotificationService_ = restockNotificationService
|
||||
|
||||
eventBusService.subscribe(
|
||||
"product-variant.updated",
|
||||
this.handleVariantUpdate
|
||||
)
|
||||
}
|
||||
|
||||
handleVariantUpdate = async (data) => {
|
||||
const { id, fields } = data
|
||||
if (fields.includes("inventory_quantity")) {
|
||||
return await this.manager_.transaction(
|
||||
async (m) =>
|
||||
await this.restockNotificationService_
|
||||
.withTransaction(m)
|
||||
.triggerRestock(id)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default VariantSubscriber
|
||||
15
packages/medusa-plugin-restock-notification/tsconfig.json
Normal file
15
packages/medusa-plugin-restock-notification/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"es5",
|
||||
"es6"
|
||||
],
|
||||
"target": "es5",
|
||||
"esModuleInterop": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
4678
packages/medusa-plugin-restock-notification/yarn.lock
Normal file
4678
packages/medusa-plugin-restock-notification/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -121,6 +121,8 @@ class SendGridService extends NotificationService {
|
||||
return this.userPasswordResetData(eventData, attachmentGenerator)
|
||||
case "customer.password_reset":
|
||||
return this.customerPasswordResetData(eventData, attachmentGenerator)
|
||||
case "restock-notification.restocked":
|
||||
return await this.restockNotificationData(eventData, attachmentGenerator)
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
@@ -154,6 +156,8 @@ class SendGridService extends NotificationService {
|
||||
return this.options_.user_password_reset_template
|
||||
case "customer.password_reset":
|
||||
return this.options_.customer_password_reset_template
|
||||
case "restock-notification.restocked":
|
||||
return this.options_.medusa_restock_template
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -671,6 +675,19 @@ class SendGridService extends NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
async restockNotificationData({ variant_id, emails }) {
|
||||
const variant = await this.productVariantService_.retrieve(variant_id, {
|
||||
relations: ["product"]
|
||||
})
|
||||
|
||||
return {
|
||||
product: variant.product,
|
||||
variant,
|
||||
variant_id,
|
||||
emails
|
||||
}
|
||||
}
|
||||
|
||||
userPasswordResetData(data) {
|
||||
return data
|
||||
}
|
||||
@@ -698,7 +715,9 @@ class SendGridService extends NotificationService {
|
||||
}
|
||||
|
||||
const normalized = humanizeAmount(amount, currency)
|
||||
return normalized.toFixed(zeroDecimalCurrencies.includes(currency.toLowerCase()) ? 0 : 2)
|
||||
return normalized.toFixed(
|
||||
zeroDecimalCurrencies.includes(currency.toLowerCase()) ? 0 : 2
|
||||
)
|
||||
}
|
||||
|
||||
normalizeThumbUrl_(url) {
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
class OrderSubscriber {
|
||||
constructor({
|
||||
totalsService,
|
||||
orderService,
|
||||
sendgridService,
|
||||
notificationService,
|
||||
fulfillmentService,
|
||||
}) {
|
||||
this.orderService_ = orderService
|
||||
this.totalsService_ = totalsService
|
||||
this.sendgridService_ = sendgridService
|
||||
constructor({ notificationService }) {
|
||||
this.notificationService_ = notificationService
|
||||
this.fulfillmentService_ = fulfillmentService
|
||||
|
||||
this.notificationService_.subscribe("order.shipment_created", "sendgrid")
|
||||
this.notificationService_.subscribe("order.gift_card_created", "sendgrid")
|
||||
|
||||
41
packages/medusa-plugin-sendgrid/src/subscribers/restock.js
Normal file
41
packages/medusa-plugin-sendgrid/src/subscribers/restock.js
Normal file
@@ -0,0 +1,41 @@
|
||||
class RestockNotification {
|
||||
constructor({ eventBusService, sendgridService }) {
|
||||
eventBusService.subscribe(
|
||||
"restock-notification.restocked",
|
||||
async (eventData) => {
|
||||
const templateId = await sendgridService.getTemplateId(
|
||||
"restock-notification.restocked"
|
||||
)
|
||||
|
||||
if (!templateId) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await sendgridService.fetchData(
|
||||
"restock-notification.restocked",
|
||||
eventData,
|
||||
null
|
||||
)
|
||||
|
||||
if (!data.emails) {
|
||||
return
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
data.emails.map(async (e) => {
|
||||
const sendOptions = {
|
||||
template_id: templateId,
|
||||
from: sendgridService.options_.from,
|
||||
to: e,
|
||||
dynamic_template_data: data,
|
||||
}
|
||||
|
||||
return await sendgridService.sendEmail(sendOptions)
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default RestockNotification
|
||||
@@ -1,4 +1,8 @@
|
||||
export default {
|
||||
getRepository: function (repo) {
|
||||
return repo;
|
||||
},
|
||||
|
||||
getCustomRepository: function (repo) {
|
||||
return repo;
|
||||
},
|
||||
|
||||
@@ -10,10 +10,12 @@ class MockRepo {
|
||||
findOneOrFail,
|
||||
save,
|
||||
findAndCount,
|
||||
del,
|
||||
}) {
|
||||
this.create_ = create;
|
||||
this.update_ = update;
|
||||
this.remove_ = remove;
|
||||
this.delete_ = del;
|
||||
this.softRemove_ = softRemove;
|
||||
this.find_ = find;
|
||||
this.findOne_ = findOne;
|
||||
@@ -93,6 +95,12 @@ class MockRepo {
|
||||
}
|
||||
return {};
|
||||
});
|
||||
delete = jest.fn().mockImplementation((...args) => {
|
||||
if (this.delete_) {
|
||||
return this.delete_(...args);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
export default (methods = {}) => {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@babel/node": "^7.7.4",
|
||||
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.12.1",
|
||||
"@babel/plugin-transform-classes": "^7.9.5",
|
||||
"@babel/plugin-transform-instanceof": "^7.8.3",
|
||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||
"@babel/preset-env": "^7.7.5",
|
||||
@@ -47,7 +48,6 @@
|
||||
"typeorm": "0.2.x"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-classes": "^7.9.5",
|
||||
"@hapi/joi": "^16.1.8",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"awilix": "^4.2.3",
|
||||
|
||||
@@ -11,7 +11,7 @@ import modelsLoader from "./models"
|
||||
import servicesLoader from "./services"
|
||||
import subscribersLoader from "./subscribers"
|
||||
import passportLoader from "./passport"
|
||||
import pluginsLoader from "./plugins"
|
||||
import pluginsLoader, { registerPluginModels } from "./plugins"
|
||||
import defaultsLoader from "./defaults"
|
||||
import Logger from "./logger"
|
||||
import { getManager } from "typeorm"
|
||||
@@ -64,6 +64,9 @@ export default async ({ directory: rootDirectory, expressApp }) => {
|
||||
await modelsLoader({ container })
|
||||
Logger.info("Models initialized")
|
||||
|
||||
await registerPluginModels({ rootDirectory, container })
|
||||
Logger.info("Models initialized")
|
||||
|
||||
await repositoriesLoader({ container })
|
||||
Logger.info("Repositories initialized")
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import glob from "glob"
|
||||
import { EntitySchema } from "typeorm"
|
||||
import {
|
||||
BaseModel,
|
||||
BaseService,
|
||||
@@ -12,13 +13,32 @@ import { getConfigFile, createRequireFromPath } from "medusa-core-utils"
|
||||
import _ from "lodash"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { asFunction, aliasTo } from "awilix"
|
||||
import { asValue, asClass, asFunction, aliasTo } from "awilix"
|
||||
import { sync as existsSync } from "fs-exists-cached"
|
||||
|
||||
/**
|
||||
* Registers all services in the services directory
|
||||
*/
|
||||
export default async ({ rootDirectory, container, app }) => {
|
||||
const resolved = getResolvedPlugins(rootDirectory)
|
||||
|
||||
await Promise.all(
|
||||
resolved.map(async pluginDetails => {
|
||||
registerRepositories(pluginDetails, container)
|
||||
await registerServices(pluginDetails, container)
|
||||
registerMedusaApi(pluginDetails, container)
|
||||
registerApi(pluginDetails, app, rootDirectory, container)
|
||||
registerCoreRouters(pluginDetails, container)
|
||||
registerSubscribers(pluginDetails, container)
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
resolved.map(async pluginDetails => runLoaders(pluginDetails, container))
|
||||
)
|
||||
}
|
||||
|
||||
function getResolvedPlugins(rootDirectory) {
|
||||
const { configModule } = getConfigFile(rootDirectory, `medusa-config`)
|
||||
|
||||
if (!configModule) {
|
||||
@@ -46,20 +66,16 @@ export default async ({ rootDirectory, container, app }) => {
|
||||
version: createFileContentHash(process.cwd(), `**`),
|
||||
})
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
export async function registerPluginModels({ rootDirectory, container }) {
|
||||
const resolved = getResolvedPlugins(rootDirectory)
|
||||
await Promise.all(
|
||||
resolved.map(async pluginDetails => {
|
||||
registerModels(pluginDetails, container)
|
||||
await registerServices(pluginDetails, container)
|
||||
registerMedusaApi(pluginDetails, container)
|
||||
registerApi(pluginDetails, app, rootDirectory, container)
|
||||
registerCoreRouters(pluginDetails, container)
|
||||
registerSubscribers(pluginDetails, container)
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
resolved.map(async pluginDetails => runLoaders(pluginDetails, container))
|
||||
)
|
||||
}
|
||||
|
||||
async function runLoaders(pluginDetails, container) {
|
||||
@@ -288,6 +304,33 @@ function registerSubscribers(pluginDetails, container) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a plugin's models at the right location in our container. Models
|
||||
* must inherit from BaseModel. Models are registered directly in the container.
|
||||
* Names are camelCase formatted and namespaced by the folder i.e:
|
||||
* models/example-person -> examplePersonModel
|
||||
* @param {object} pluginDetails - the plugin details including plugin options,
|
||||
* version, id, resolved path, etc. See resolvePlugin
|
||||
* @param {object} container - the container where the services will be
|
||||
* registered
|
||||
* @return {void}
|
||||
*/
|
||||
function registerRepositories(pluginDetails, container) {
|
||||
const files = glob.sync(`${pluginDetails.resolve}/repositories/*.js`, {})
|
||||
files.forEach(fn => {
|
||||
const loaded = require(fn)
|
||||
|
||||
Object.entries(loaded).map(([key, val]) => {
|
||||
if (typeof val === "function") {
|
||||
const name = formatRegistrationName(fn)
|
||||
container.register({
|
||||
[name]: asClass(val),
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a plugin's models at the right location in our container. Models
|
||||
* must inherit from BaseModel. Models are registered directly in the container.
|
||||
@@ -302,20 +345,17 @@ function registerSubscribers(pluginDetails, container) {
|
||||
function registerModels(pluginDetails, container) {
|
||||
const files = glob.sync(`${pluginDetails.resolve}/models/*.js`, {})
|
||||
files.forEach(fn => {
|
||||
const loaded = require(fn).default
|
||||
const loaded = require(fn)
|
||||
|
||||
if (!(loaded.prototype instanceof BaseModel)) {
|
||||
const logger = container.resolve("logger")
|
||||
const message = `Models must inherit from BaseModel, please check ${fn}`
|
||||
logger.error(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
Object.entries(loaded).map(([key, val]) => {
|
||||
if (typeof val === "function" || val instanceof EntitySchema) {
|
||||
const name = formatRegistrationName(fn)
|
||||
container.register({
|
||||
[name]: asValue(val),
|
||||
})
|
||||
|
||||
const name = formatRegistrationName(fn)
|
||||
container.register({
|
||||
[name]: asFunction(
|
||||
cradle => new loaded(cradle, pluginDetails.options)
|
||||
).singleton(),
|
||||
container.registerAdd("db_entities", asValue(val))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@ class ClaimItemService extends BaseService {
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
return claimTagRepo.create({ value: normalized })
|
||||
})
|
||||
)
|
||||
|
||||
@@ -5662,21 +5662,21 @@ media-typer@0.3.0:
|
||||
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
|
||||
integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
|
||||
|
||||
medusa-core-utils@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-1.1.0.tgz#0641b365b769dbf99856025d935eef5cf5d81f2c"
|
||||
integrity sha512-zocRthKhLK3eSjrXbAhZZkIMBRxyvU7GcAMFh5UCEgfe7f935vjE7r5lGTr5jTEwgwaoTUk9ep0VBekz0SEdyw==
|
||||
medusa-core-utils@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-1.1.4.tgz#ec2bb98c83426d7033632cd225b3b5dc62c26f1a"
|
||||
integrity sha512-SzFfMmNbE9ukSfhapJOuYEksOKDo3yYSCeuBLFWnCZZRDnUV4ttH4Yp/ydT+cZKtqZwF2vKceXNbrT4uJYjHgw==
|
||||
dependencies:
|
||||
joi "^17.3.0"
|
||||
joi-objectid "^3.0.1"
|
||||
|
||||
medusa-test-utils@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/medusa-test-utils/-/medusa-test-utils-1.1.3.tgz#c2b45d44b9567fa2255e936d7bed73a31dfb42dd"
|
||||
integrity sha512-0saYG5BhEjc4BZP76/2IJL7CyqIdbCasAci+EYXzwnwgS+nCUhKDjzzNAnC+PZMK/teD3M7x4n7isFtjgNSIDQ==
|
||||
medusa-test-utils@^1.1.7:
|
||||
version "1.1.7"
|
||||
resolved "https://registry.yarnpkg.com/medusa-test-utils/-/medusa-test-utils-1.1.7.tgz#19d0bdf3f6f7fef0bc7f2f8e258f8c66167c692f"
|
||||
integrity sha512-kcN4oJjUEAkFeko7DEaok9Qy3aty41gEzGSdR4F3KPORXZJ+YADHuj4F219/X1k+3iGqCUgmNQ9eKl6Aobq44g==
|
||||
dependencies:
|
||||
"@babel/plugin-transform-classes" "^7.9.5"
|
||||
medusa-core-utils "^1.1.0"
|
||||
medusa-core-utils "^1.1.4"
|
||||
randomatic "^3.1.1"
|
||||
|
||||
merge-descriptors@1.0.1:
|
||||
|
||||
Reference in New Issue
Block a user