- Get Started
- Product
- Resources
- Tools & SDKs
- Framework
- Reference
- Get Started
- Product
- Resources
- Tools & SDKs
- Framework
- Reference
Implement Restock Notifications in Medusa
In this guide, you'll learn how to notify customers when a variant is restocked in Medusa.
When you install a Medusa application, you get a fully-fledged commerce platform with a framework for customization. The Medusa application's commerce features are built around commerce modules which are available out-of-the-box. These features include managing the inventory of product variants in different stock locations and sales channels.
Customers browsing your store may be interested in a product that is currently out of stock. To keep the customer interested in your store and encourage them to purchase the product in the future, you can build customizations around Medusa's commerce features to subscribe customers to receive a notification when the product is restocked.
This guide will teach you how to:
- Install and set up Medusa.
- Implement the data model to subscribe for variant restocking.
- Add a custom endpoint to subscribe a customer to a variant's restock notification.
- Build a flow to send a notification to customers subscribed to a variant when it's restocked.
You can follow this guide whether you're new to Medusa or an advanced Medusa developer.
Step 1: Install a Medusa Application#
Start by installing the Medusa application on your machine with the following command:
You'll first be asked for the project's name. You can also optionally choose to install the Next.js starter storefront.
Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the {project-name}-storefront
name.
Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credential and submit the form. Afterwards, you can login with the new user and explore the dashboard.
Step 2: Create Restock Module#
To add custom tables to the database, which are called data models, you create a module. A module is a re-usable package with functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup.
In this step, you'll create a Restock Module that adds a custom data model for restock notification subscriptions. In later steps, you'll store customer subscriptions in this data model.
Create Module Directory#
A module is created under the src/modules
directory of your Medusa application. So, create the directory src/modules/restock
.
Create Data Models#
A data model represents a table in the database. You create data models using Medusa's Data Model Language (DML). It simplifies defining a table's columns, relations, and indexes with straightforward methods and configurations.
In Medusa, you have sales channels that indicate the channels you sell your products through, such as online storefront or offline store. A product's variants have different inventory quantities across stock locations, which are associated with sales channels.
So, a customer sees the inventory quantity of a product variant based on their sales channel. To subscribe a customer to a product variant's restock notification, you'll store the subscription in a RestockSubscription
data model.
You create a data model in a TypeScript or JavaScript file under the models
directory of a module. So, create the file src/modules/restock/models/restock-subscription.ts
with the following content:
1import { model } from "@medusajs/framework/utils"2 3const RestockSubscription = model.define("restock_subscription", {4 id: model.id().primaryKey(),5 variant_id: model.text(),6 sales_channel_id: model.text(),7 email: model.text(),8 customer_id: model.text().nullable(),9})10.indexes([11 {12 on: ["variant_id", "sales_channel_id", "email"],13 unique: true,14 },15])16 17export default RestockSubscription
You define the data model using DML's define
method. It accepts two parameters:
- The first one is the name of the data model's table in the database.
- The second is an object, which is the data model's schema. The schema's properties are defined using DML methods.
In the data model, you define the following properties:
id
: A primary key ID for each record.variant_id
: The ID of a variant that customers have subscribed to.sales_channel_id
: The ID of the sales channel that this variant is out-of-stock in.email
: The email of the customer subscribed to the restock notification.customer_id
: The customer's ID in Medusa. This is nullable in case the customer is a guest.
You also define a unique index on the variant_id
, sales_channel_id
, and email
properties using the indexes
method.
Create Service#
You define data-management methods of your data models in a service. A service is a TypeScript or JavaScript class that the module exports. In the service's methods, you can perform database operations.
In this section, you'll create the Restock Module's service. Create the file src/modules/restock/service.ts
with the following content:
The RestockModuleService
extends MedusaService
from the Modules SDK which generates a class with data-management methods for your module's data models. This saves you time on implementing Create, Read, Update, and Delete (CRUD) methods.
So, the RestockModuleService
class now has methods like createRestockSubscriptions
and retrieveRestockSubscription
.
You'll use this service in a later method to store and manage restock subscriptions.
Export Module Definition#
The final piece to a module is its definition, which you export in an index.ts
file at its root directory. This definition tells Medusa the name of the module and its service.
So, create the file src/modules/restock/index.ts
with the following content:
You use the Module
function from the Modules SDK to create the module's definition. It accepts two parameters:
- The module's name, which is
restock
. - An object with a required property
service
indicating the module's service.
Add Module to Medusa's Configurations#
Once you finish building the module, add it to Medusa's configurations to start using it.
In medusa-config.ts
, add a modules
property and pass an array with your custom module:
Each object in the modules
array has a resolve
property, whose value is either a path to the module's directory, or an npm
package’s name.
Generate Migrations#
Since data models represent tables in the database, you define how they're created in the database with migrations. A migration is a TypeScript or JavaScript file that defines database changes made by a module.
Medusa's CLI tool generates the migrations for you. To generate a migration for the Restock Module, run the following command in your Medusa application's directory:
The db:generate
command of the Medusa CLI accepts the name of the module to generate the migration for. You'll now have a migrations
directory under src/modules/restock
that holds the generated migration.
Then, to reflect these migrations on the database, run the following command:
The table of the Restock Module's data model are now created in the database.
Step 3: Link Restock Subscription to Product Variant#
Since the RestockSubscription
data model stores the product variant's ID, you may want to retrieve the product variant's details while retrieving a restock subscription record.
However, modules are isolated to ensure they're re-usable and don't have side effects when integrated into the Medusa application. So, to build associations between modules, you define module links. A Module link associates two modules' data models while maintaining module isolation.
In this section, you'll link the RestockSubscription
data model to the Product Module's ProductVariant
data model.
To create a link, create the file src/links/restock-variant.ts
with the following content:
1import { defineLink } from "@medusajs/framework/utils"2import RestockModule from "../modules/restock"3import ProductModule from "@medusajs/medusa/product"4 5export default defineLink(6 {7 ...RestockModule.linkable.restockSubscription.id,8 field: "variant_id",9 },10 ProductModule.linkable.productVariant,11 {12 readOnly: true,13 }14)
You define a link using defineLink
from the Modules SDK. It accepts three parameters:
- The first data model part of the link, which is the Restock Module's
restockSubscription
data model. A module has a speciallinkable
property that contain link configurations for its data models. You also specify the field that points to the product variant. - The second data model part of the link, which is the Product Module's
productVariant
data model. - An object of configurations for the module link. By default, Medusa creates a table in the database to represent the link you define. However, in this guide, you only want this link to retrieve the variants associated with a subscription and vice-versa. So, you enable
readOnly
telling Medusa not to create a table for this link.
In the next steps, you'll see how this link allows you to retrieve product variants' details when retrieving restock subscriptions.
Step 4: Create Restock Subscription Workflow#
To subscribe customers to a variant's restock notification, you need a workflow.
A workflow is a series of queries and actions, called steps, that complete a task. You construct a workflow like you construct a function, but it's a special function that allows you to track its executions' progress, define roll-back logic, and configure other advanced features. Then, you execute the workflow from other customizations, such as in an endpoint.
In this section, you'll create a workflow that validates that a variant is out-of-stock in the customer's sales channel, then subscribes the customer to the variant's restock notification. Later, you'll execute this workflow in an endpoint that you use in a storefront.
The workflow has the following steps:
The useQueryGraphStep
is from Medusa's workflows package. So, you'll only implement the other steps.
validateVariantOutOfStockStep#
The second step in the workflow will validate that the variant is actually out of stock in the customer's sales channel.
Create the file src/workflows/create-restock-subscription/steps/validate-variant-out-of-stock.ts
with the following content:
1import { getVariantAvailability, MedusaError } from "@medusajs/framework/utils"2import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"3 4type ValidateVariantOutOfStockStepInput = {5 variant_id: string6 sales_channel_id: string7}8 9export const validateVariantOutOfStockStep = createStep(10 "validate-variant-out-of-stock",11 async ({ variant_id, sales_channel_id }: ValidateVariantOutOfStockStepInput, { container }) => {12 const query = container.resolve("query")13 const availability = await getVariantAvailability(query, {14 variant_ids: [variant_id],15 sales_channel_id,16 })17 18 if (availability[variant_id].availability > 0) {19 throw new MedusaError(20 MedusaError.Types.INVALID_DATA,21 "Variant isn't out of stock."22 )23 }24 }25)
This step accepts the ID of the variant and the ID of the customer's sales channel. In the step, you use the getVariantAvailability
from the Medusa Framework to get the variant's quantity in the specified sales channels. If the variant's quantity is greater than 0
, you throw an error, stopping the workflow's execution.
createRestockSubscriptionStep#
In the workflow, you'll try to retrieve the restock subscription if it already exists for the same email, variant ID, and sales channel ID. If it doesn't exist, you'll use this step to create the restock subscription.
Create the file src/workflows/create-restock-subscription/steps/create-restock-subscription.ts
with the following content:
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import RestockModuleService from "../../../modules/restock/service"3import { RESTOCK_MODULE } from "../../../modules/restock"4 5type CreateRestockSubscriptionStepInput = {6 variant_id: string7 sales_channel_id: string8 email: string9 customer_id?: string10}11 12export const createRestockSubscriptionStep = createStep(13 "create-restock-subscription",14 async (input: CreateRestockSubscriptionStepInput, { container }) => {15 const restockModuleService: RestockModuleService = container.resolve(16 RESTOCK_MODULE17 )18 19 const restockSubscription = await restockModuleService.createRestockSubscriptions(20 input21 )22 23 return new StepResponse(restockSubscription, restockSubscription)24 }25)
In the step, you resolve the Restock Module's service from the Medusa container. Medusa registers the service of custom and core modules in the container under the module's name.
Then, you use the service's createRestockSubscriptions
method, which was generated by MedusaService
, to create the restock subscription.
Finally, you return the created restock subscription by passing it as a first parameter to StepResponse
. The second parameter is data passed to the compensation function, which you'll learn about next.
Add Compensation Function
A compensation function defines the rollback logic of a step, and it's only executed if an error occurs in the workflow. This eliminates data inconsistency if an error occurs and the workflow can't finish execution successfully.
Since the createRestockSubscriptionStep
creates a restock subscription, you'll undo that in the compensation function. To add a compensation function, pass it as a third parameter to createStep
:
1export const createOrGetRestockSubscriptionsStep = createStep(2 // ...3 async (restockSubscription, { container }) => {4 const restockModuleService: RestockModuleService = container.resolve(5 RESTOCK_MODULE6 )7 8 await restockModuleService.deleteRestockSubscriptions(restockSubscription.id)9 }10)
The compensation function receives two parameters:
- The second parameter of
StepResponse
, which is the created restock subscription. - An object similar to the second parameter of a step function. It has a
container
property to resolve resources from the Medusa container.
In the compensation function, you resolve the Restock Module's service from the container, then delete the created subscription using the generated deleteRestockSubscriptions
method.
updateRestockSubscriptionStep#
As mentioned in the previous step, the workflow will try to retrieve the restock subscription in case it already exists. If it does, you'll run this step to update its customer ID if it wasn't previously set in the subscription.
Create the file src/workflows/create-restock-subscription/steps/update-restock-subscription.ts
with the following content:
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import RestockModuleService from "../../../modules/restock/service"3import { RESTOCK_MODULE } from "../../../modules/restock"4 5type UpdateRestockSubscriptionStepInput = {6 id: string7 customer_id?: string8}9 10export const updateRestockSubscriptionStep = createStep(11 "update-restock-subscription",12 async ({ id, customer_id }: UpdateRestockSubscriptionStepInput, { container }) => {13 const restockModuleService: RestockModuleService = container.resolve(14 RESTOCK_MODULE15 )16 17 const oldData = await restockModuleService.retrieveRestockSubscription(18 id19 )20 const restockSubscription = await restockModuleService.updateRestockSubscriptions({21 id,22 customer_id: oldData.customer_id || customer_id,23 })24 25 return new StepResponse(restockSubscription, oldData)26 },27 async (restockSubscription, { container }) => {28 const restockModuleService: RestockModuleService = container.resolve(29 RESTOCK_MODULE30 )31 32 await restockModuleService.updateRestockSubscriptions(restockSubscription)33 }34)
In the step, you resolve the Restock Module's service and use its generated retrieveRestockSubscription
method to retrieve the restock subscription. You then update the subscription with the updateRestockSubscriptions
, updating the customer ID if it wasn't set in the subscription.
The step returns the updated restock subscription. It also passes to the compensation function the subscription's data before the update to undo the change in case an error occurs.
Add createRestockSubscriptionWorkflow#
You can now finally add the workflow that uses all these steps. Create the file src/workflows/create-restock-subscription/index.ts
with the following content:
1import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { validateVariantOutOfStockStep } from "./steps/validate-variant-out-of-stock"3import { useQueryGraphStep } from "@medusajs/medusa/core-flows"4import { createRestockSubscriptionStep } from "./steps/create-restock-subscription"5import { updateRestockSubscriptionStep } from "./steps/update-restock-subscription"6 7type CreateRestockSubscriptionWorkflowInput = {8 variant_id: string9 sales_channel_id: string10 customer: {11 email?: string12 customer_id?: string13 }14}15 16export const createRestockSubscriptionWorkflow = createWorkflow(17 "create-restock-subscription",18 ({19 variant_id,20 sales_channel_id,21 customer,22 }: CreateRestockSubscriptionWorkflowInput) => {23 const customerId = transform({24 customer,25 }, (data) => {26 return data.customer.customer_id || ""27 })28 const retrievedCustomer = when(29 "retrieve-customer-by-id",30 { customer }, 31 ({ customer }) => {32 return !customer.email33 }34 ).then(() => {35 // @ts-ignore36 const { data } = useQueryGraphStep({37 entity: "customer",38 fields: ["email"],39 filters: { id: customerId },40 options: {41 throwIfKeyNotFound: true,42 },43 }).config({ name: "retrieve-customer" })44 45 return data46 })47 48 const email = transform({ 49 retrievedCustomer, 50 customer,51 }, (data) => {52 return data.customer?.email ?? data.retrievedCustomer?.[0].email53 })54 55 // TODO add more steps56 }57)
You create a workflow using createWorkflow
from the Workflows SDK. It accepts the workflow's unique name as a first parameter.
It accepts as a second parameter a constructor function, which is the workflow's implementation. In the workflow, you:
- Use transform from the Workflows SDK to create a
customerId
variable. Its value is either the ID of the customer passed in the workflow's input if it's notundefined
, or an empty string. - Use when-then from the Workflows SDK that performs steps if a condition is met. If the customer's email isn't set in the workflow's input, you retrieve the customer using
useQueryGraphStep
by its ID. - Use
transform
again to create anemail
variable whose value is either the email passed in the workflow's input or the retrieved customer's email.
transform
for variable manipulation and when-then
to perform steps based on a condition. Learn more about these constraints in this documentation.Next, replace the TODO
with the following:
1validateVariantOutOfStockStep({2 variant_id,3 sales_channel_id,4})5 6// @ts-ignore7const { data: restockSubscriptions } = useQueryGraphStep({8 entity: "restock_subscription",9 fields: ["*"],10 filters: {11 email,12 variant_id,13 sales_channel_id,14 },15}).config({ name: "retrieve-subscriptions" })16 17when({ restockSubscriptions }, ({ restockSubscriptions }) => {18 return restockSubscriptions.length === 019})20.then(() => {21 createRestockSubscriptionStep({22 variant_id,23 sales_channel_id,24 email,25 customer_id: customer.customer_id,26 })27})28 29when({ restockSubscriptions }, ({ restockSubscriptions }) => {30 return restockSubscriptions.length > 031})32.then(() => {33 updateRestockSubscriptionStep({34 id: restockSubscriptions[0].id,35 customer_id: customer.customer_id,36 })37})38 39// @ts-ignore40const { data: restockSubscription } = useQueryGraphStep({41 entity: "restock_subscription",42 fields: ["*"],43 filters: {44 email,45 variant_id,46 sales_channel_id,47 },48}).config({ name: "retrieve-restock-subscription" })49 50return new WorkflowResponse(51 restockSubscription52)
You add the following steps to the workflow:
validateVariantOutOfStockStep
to validate that the variant is out of stock in the specified sales channel. If not, an error is thrown, halting the workflow's execution.useQueryGraphStep
to retrieve the restock subscription in case it already exists.- Use when-then to perform an action if a condition is met.
- The first when-then block checks if the restock subscription doesn't exist, then creates it using the
createRestockSubscriptionStep
. - The second when-then block checks if the restock subscription already exists, then updates it using the
updateRestockSubscriptionStep
.
- The first when-then block checks if the restock subscription doesn't exist, then creates it using the
useQueryGraphStep
again to retrieve the restock subscription before returning it.
Workflows must return an instance of WorkflowResponse
, passing as a parameter the data to return to the workflow's executor. The workflow returns the restock subscription.
You'll execute the workflow when you create the API route next.
Step 5: Subscribe to Restock Notifications API Route#
Now that you implemented the flow to subscribe customers to a variant's restock notifications, you'll expose this feature through an API route.
An API Route is an endpoint that exposes commerce features to external applications and clients, such as storefronts. You'll create an API route at the path /store/restock-subscriptions
that executes the workflow from the previous step.
Implement API Route#
An API route is created in a route.ts
file under a sub-directory of the src/api
directory.
The path of the API route is the file's path relative to src/api
. So, to create the /store/restock-subscriptions
API route, create the file src/api/store/restock-subscriptions/route.ts
with the following content:
1import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"2import { MedusaError } from "@medusajs/framework/utils"3import { createRestockSubscriptionWorkflow } from "../../../workflows/create-restock-subscription"4 5type PostStoreCreateRestockSubscription = {6 variant_id: string7 email?: string8 sales_channel_id?: string9}10 11export async function POST(12 req: AuthenticatedMedusaRequest<PostStoreCreateRestockSubscription>,13 res: MedusaResponse14) {15 const salesChannelId = req.validatedBody.sales_channel_id || (16 req.publishable_key_context?.sales_channel_ids?.length ? 17 req.publishable_key_context?.sales_channel_ids[0] : undefined18 )19 if (!salesChannelId) {20 throw new MedusaError(21 MedusaError.Types.INVALID_DATA,22 "At least one sales channel ID is required, either associated with the publishable API key or in the request body."23 )24 }25 const { result } = await createRestockSubscriptionWorkflow(req.scope)26 .run({27 input: {28 variant_id: req.validatedBody.variant_id,29 sales_channel_id: salesChannelId,30 customer: {31 email: req.validatedBody.email,32 customer_id: req.auth_context?.actor_id,33 },34 },35 })36 37 return res.sendStatus(201)38}
Since you export a POST
function in this file, you're exposing a POST
API route at /store/restock-subscriptions
. The route handler function accepts two parameters:
- A request object with details and context on the request, such as body parameters or authenticated customer details.
- A response object to manipulate and send the response.
AuthenticatedMedusaRequest
accepts the request body's type as a type argument.In the function, you first declare the sales channel ID either based on the parameter specified in the request body or the publishable API key's first sales channel. If the sales channel's ID is not set, an error is thrown.
Then, you execute the createRestockSubscriptionWorkflow
by invoking it, passing it the Medusa container which is stored in the scope
property of the request object, and invoking its run
method.
The run
method accepts an object having an input
property, which is the input to pass to the workflow. You pass the following input:
variant_id
: The ID of the variant the customer is subscribing to. You access the request body parameters from thevalidatedBody
property of the request object.sales_channel_id
: The ID of the sales channel.customer
: The subscriber customer's details:email
: The email passed in the request body, if available.customer_id
: The ID of the customer if they're authenticated.
Finally, you return a 201
response code, indicating that the customer has subscribed to restock notifications of the specified variant.
Add Validation Schema#
The API route accepts the variant ID, and optionally the customer email and sales channel ID as request body parameters. So, you'll create a schema to validate the request body.
In Medusa, you create validation schemas using Zod in a TypeScript file under the src/api
directory. So, create the file src/api/store/restock-subscriptions/validators.ts
with the following content:
You create an object schema with the following properties:
variant_id
: A required string parameter.email
: An optional string parameter. The email is optional if the customer is authenticated.sales_channel_id
: An optional string parameter. By default, every route starting with/store
must pass the publishable API key, which is linked to one or more sales channels. This parameter takes a precedence over the publishable API key's channel.
You can now replace the PostStoreCreateRestockSubscription
type in src/api/store/restock-subscriptions/route.ts
with the following:
Next, you'll use this schema for validation.
Add Validation and Auth Middlewares#
To use the Zod schema for validation, you apply the validateAndTransformBody
middleware on the /store/restock-subscriptions
route. A middleware is a function executed before the API route when a request is sent to it.
To apply middlewares, create the file src/api/middlewares.ts
with the following content:
1import { 2 authenticate, 3 defineMiddlewares, 4 validateAndTransformBody,5} from "@medusajs/framework/http"6import { 7 PostStoreCreateRestockSubscription,8} from "./store/restock-subscriptions/validators"9 10export default defineMiddlewares({11 routes: [12 {13 matcher: "/store/restock-subscriptions",14 method: "POST",15 middlewares: [16 authenticate("customer", ["bearer", "session"], {17 allowUnauthenticated: true,18 }),19 validateAndTransformBody(PostStoreCreateRestockSubscription),20 ],21 },22 ],23})
In this file, you export the middlewares definition using defineMiddlewares
from the Medusa Framework. This function accepts an object having a routes
property, which is an array of middleware configurations to apply on routes.
You pass in the routes
array an object having the following properties:
matcher
: The route to apply the middleware on.method
: The HTTP method to apply the middleware on for the specified API route.middlewares
: An array of the middlewares to apply. You apply two middlewares:authenticate
: A middleware that guards and attaches the logged-in customer details to the request object received by the API route handler. The middleware accepts three parameters:- The type of user to authenticate, which is
customer
. - The types of authentication methods allowed.
- An optional object of options. You enable the
allowUnauthenticated
, which allows both authenticated and guest customers to access the route, and attaches the authenticated customer's ID to the request object.
- The type of user to authenticate, which is
validateAndTransformBody
: A middleware to ensure the received request body is valid against the Zod schema you defined earlier.
Any request sent to /store/restock-subscriptions
will now automatically fail if its body parameters don't match the PostStoreCreateRestockSubscription
validation schema.
Test API Route#
To test out this API route, start the Medusa application by running the following command in the root directory of the Medusa application:
Before sending the request, you need to obtain a publishable API key. So, open the Medusa Admin at http://localhost:9000/app
and log in with the user you created earlier.
To access your application's API keys in the admin, go to Settings -> Publishable API Keys. You'll have an API key created by default, which is associated with the default sales channel. You can use this publishable API key in the request header.
Then, to obtain an ID of a variant that's out of stock, access a product from the Products page and:
- Under Variants, click on the variant you want to edit its inventory quantity.
- Under Inventory Items, click on an inventory item.
- Under Locations, click on the third-dots icon at the right of a location, then choose Edit from the dropdown.
- In the drawer form, enter
0
for the item's in-stock quantity. - Click the Save button.
- Go back to the variant's page and click on the icon at the right of the JSON section.
- In the JSON object, hover over the
id
field and click the copy icon.
Finally, send a POST
request to the /store/restock-subscriptions
API route:
Make sure to replace {api_key}
with the publishable API key you copied from the settings, and {variant_id}
for the ID of the out-of-stock variant.
You'll receive a 201
response, indicating that the guest customer with email customer@gmail.com
is now subscribed to restock notifications for the specified variant in the first sales channel associated with the specified publishable API key.
In the next step, you'll implement the functionality to send a notification to the variant's subscribers when it's restocked.
Step 6: Send Restock Notification Workflow#
After allowing customers to subscribe to a variant's restock notification, you want to implement the flow that checks the variant is restocked and sends a notification to its subscribers.
In this step, you'll create a workflow that retrieves all restock subscriptions, checks which variants are now restocked, and sends a notification to their subscribers.
The workflow has the following steps:
The useQueryGraphStep
is from Medusa's workflows. So, you'll only implement the other steps.
Optional Prerequisite: Notification Module Provider#
Within this workflow, you'll use Medusa's Notification Module to send an email to the customer.
The module delegates the email sending to a module provider, such as SendGrid or Resend. You can refer to their linked guides to set up either module providers.
Alternatively, for development and debugging purposes, you can use the default Notification Module Provider that only logs a message in the terminal instead of sending an email. To do that, add the following to the modules
array in medusa-config.ts
:
getDistinctSubscriptionsStep#
The first step is to retrieve all restock subscriptions to later check which variants have been restocked in their sales channel. However, considering there could be a lot of subscribers to the same variant and sales channel pairing, you'll retrieve subscriptions with distinct variant and sales channel ID pairings.
Before adding the step that does this, you'll add a method in the RestockModuleService
to retrieve the distinct records from the database. So, add the following to src/modules/restock/service.ts
:
1// other imports...2import { InjectManager, MedusaContext } from "@medusajs/framework/utils"3import { Context } from "@medusajs/framework/types"4import { EntityManager } from "@mikro-orm/knex"5 6class RestockModuleService extends MedusaService({7 RestockSubscription,8}) {9 // ...10 @InjectManager()11 async getUniqueSubscriptions(12 @MedusaContext() context: Context<EntityManager> = {}13 ) {14 return await context.manager?.createQueryBuilder("restock_subscription")15 .select(["variant_id", "sales_channel_id"]).distinct().execute()16 }17}18 19export default RestockModuleService
To perform queries on the database in a method, add the @InjectManager
decorator to the method. This will inject a forked MikroORM entity manager that you can use in your method.
Methods with the @InjectManager
decorator accept as a last parameter a context object that has the @MedusaContext
decorator. The entity manager is injected into the manager
property of this paramter.
In the method, you use the createQueryBuilder
to construct a query, passing it the name of the RestockSubscription
's table. You then select distinct variant_id
and sales_channel
pairings, and execute and return the query's result.
You'll use this method in the step. To create the step, create the file src/workflows/send-restock-notifications/steps/get-distinct-subscriptions.ts
with the following content:
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import RestockModuleService from "../../../modules/restock/service"3import { RESTOCK_MODULE } from "../../../modules/restock"4 5export const getDistinctSubscriptionsStep = createStep(6 "get-distinct-subscriptions",7 async (_, { container }) => {8 const restockModuleService: RestockModuleService = container.resolve(9 RESTOCK_MODULE10 )11 12 const distinctSubscriptions = await restockModuleService.getUniqueSubscriptions()13 14 return new StepResponse(distinctSubscriptions)15 }16)
In the step, you resolve the Restock Module's service and use the getUniqueSubscriptions
method to retrieve the distinct subscriptions. You return those subscriptions in the StepResponse
.
getRestockedStep#
The second step of the workflow receives all restock subscriptions and returns only those whose variants are restocked in the specified sales channel.
Create the file src/workflows/send-restock-notifications/steps/get-restocked.ts
with the following content:
1import { getVariantAvailability, promiseAll } from "@medusajs/framework/utils"2import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"3 4type GetRestockedStepInput = {5 variant_id: string6 sales_channel_id: string7}[]8 9export const getRestockedStep = createStep(10 "get-restocked",11 async (input: GetRestockedStepInput, { container }) => {12 const restocked: GetRestockedStepInput = []13 const query = container.resolve("query")14 15 await promiseAll(16 input.map(async (restockSubscription) => {17 const variantAvailability = await getVariantAvailability(query, {18 variant_ids: [restockSubscription.variant_id],19 sales_channel_id: restockSubscription.sales_channel_id,20 })21 22 if (variantAvailability[restockSubscription.variant_id].availability > 0) {23 restocked.push(restockSubscription)24 }25 })26 )27 28 return new StepResponse(restocked)29 }30)
In this step, you loop over the restock subscriptions and use getVariantAvailability
from the Medusa Framework to retrieve a variant's quantity in the sales channel.
If the variant isn't out of stock, then the restock subscription is pushed into the restocked
array, which is returned in the step's response.
sendRestockNotificationStep#
The third step of the workflow receives the subscriptions whose variants have been restocked to send a notification to their subscribers.
Create the file src/workflows/send-restock-notifications/steps/send-restock-notification.ts
with the following content:
1import { promiseAll } from "@medusajs/framework/utils"2import { createStep } from "@medusajs/framework/workflows-sdk"3import { InferTypeOf, ProductVariantDTO } from "@medusajs/framework/types"4import RestockSubscription from "../../../modules/restock/models/restock-subscription"5 6type SendRestockNotificationStepInput = (InferTypeOf<typeof RestockSubscription> & {7 product_variant?: ProductVariantDTO8})[]9 10export const sendRestockNotificationStep = createStep(11 "send-restock-notification",12 async (input: SendRestockNotificationStepInput, { container }) => {13 const notificationModuleService = container.resolve("notification")14 15 const notificationData = input.map((subscription) => ({16 to: subscription.email,17 channel: "email",18 template: "variant-restock",19 data: {20 variant: subscription.product_variant,21 },22 }))23 24 await notificationModuleService.createNotifications(notificationData)25 }26)
This step resolves the Notification Module's service from the Medusa container and, for each subscription, sends a notification to its subscribers.
To send a notification, you use the createNotifications
method of the Notification Module's service. It accepts an array of notification objects, each having the following properties:
to
: The email to send the notification to.channel
: The channel to send the notification through, which isemail
for sending an email.template
: The email template to use for this notification.data
: Data to pass to the template relevant for the notification. Since the email will probably include details about the variant, you pass the variant's details.
deleteRestockSubscriptionStep#
The final step deletes the restock subscriptions whose subscribers have been notified.
Create the file src/workflows/send-restock-notifications/steps/delete-restock-subscriptions.ts
with the following content:
1import { InferTypeOf } from "@medusajs/framework/types"2import RestockSubscription from "../../../modules/restock/models/restock-subscription"3import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"4import RestockModuleService from "../../../modules/restock/service"5import { RESTOCK_MODULE } from "../../../modules/restock"6 7type DeleteRestockSubscriptionsStepInput = InferTypeOf<typeof RestockSubscription>[]8 9export const deleteRestockSubscriptionStep = createStep(10 "delete-restock-subscription",11 async (12 restockSubscriptions: DeleteRestockSubscriptionsStepInput, 13 { container }14 ) => {15 const restockModuleService: RestockModuleService = container.resolve(16 RESTOCK_MODULE17 )18 19 await restockModuleService.deleteRestockSubscriptions(20 restockSubscriptions.map((subscription) => subscription.id)21 )22 23 return new StepResponse(undefined, restockSubscriptions)24 },25 async (restockSubscriptions, { container }) => {26 if (!restockSubscriptions) {27 return28 }29 30 const restockModuleService: RestockModuleService = container.resolve(31 RESTOCK_MODULE32 )33 34 await restockModuleService.createRestockSubscriptions(restockSubscriptions)35 }36)
In the step, you resolve the Restock Module's service and use its deleteRestockSubscriptions
to delete the restock subscriptions.
In the step's compensation, which receives the deleted restock subscriptions as a parameter, you resolve the Restock Module's service and use its createRestockSubscriptions
to create these subscriptions again if an error occurs.
Implement sendRestockNotificationsWorkflow#
You can now implement the workflow that sends restock notifications using the above steps.
Create the file src/workflows/send-restock-notifications/index.ts
with the following content:
1import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { useQueryGraphStep } from "@medusajs/medusa/core-flows"3import { getRestockedStep } from "./steps/get-restocked"4import { sendRestockNotificationStep } from "./steps/send-restock-notification"5import { deleteRestockSubscriptionStep } from "./steps/delete-restock-subscriptions"6import { getDistinctSubscriptionsStep } from "./steps/get-distinct-subscriptions"7 8export const sendRestockNotificationsWorkflow = createWorkflow(9 "send-restock-notifications",10 () => {11 const subscriptions = getDistinctSubscriptionsStep()12 13 // @ts-ignore14 const restockedSubscriptions = getRestockedStep(subscriptions)15 16 const { variant_ids, sales_channel_ids } = transform({17 restockedSubscriptions,18 }, (data) => {19 const filters: Record<string, string[]> = {20 variant_ids: [],21 sales_channel_ids: [],22 }23 data.restockedSubscriptions.map((subscription) => {24 filters.variant_ids.push(subscription.variant_id)25 filters.sales_channel_ids.push(subscription.sales_channel_id)26 })27 28 return filters29 })30 31 // @ts-ignore32 const { data: restockedSubscriptionsWithEmails } = useQueryGraphStep({33 entity: "restock_subscription",34 fields: ["*", "product_variant.*"],35 filters: {36 variant_id: variant_ids,37 sales_channel_id: sales_channel_ids,38 },39 })40 41 // @ts-ignore42 sendRestockNotificationStep(restockedSubscriptionsWithEmails)43 44 // @ts-ignore45 deleteRestockSubscriptionStep(restockedSubscriptionsWithEmails)46 47 return new WorkflowResponse({48 subscriptions: restockedSubscriptionsWithEmails,49 })50 }51)
This workflow has the following steps:
getDistinctSubscriptionsStep
to retrieve the restock subscriptions by distinct variant and sales channel ID pairings.getRestockedStep
to filter the subscriptions retrieved by the previous step and return only those whose variants have been restocked.useQueryGraphStep
to retrieve all subscriptions that have a restocked variant and sales channel ID pairing using Query. Notice that in the specifiedfields
you passproduct_variant.*
, which retrieves the details of the subscription's variant from the Product Module. This is possible due to the module link you created between theRestockSubscription
andProductVariant
models in an earlier step.sendRestockNotificationStep
to send the notification to the subscribers of the restocked variants.deleteRestockSubscriptionStep
to delete the restock subscriptions since their subscribers have been notified.
The workflow returns the restocked subscriptions, which are now deleted.
You'll execute this workflow in the next section.
Step 7: Send Restock Notifications Daily#
Now that you've built the flow to send restock notifications, you want to check for restocked variants and send notifications to their subscribers once a day. To do so, you'll use a scheduled job.
A scheduled job is an asynchronous function that the Medusa application runs at the schedule you specify during the Medusa application's runtime. Scheduled jobs are useful for automating tasks at a fixed schedule.
In this step, you'll create a scheduled job that runs once a day to execute the sendRestockNotificationsWorkflow
from the previous step.
A scheduled job is created in a TypeScript or JavaScript file under the src/jobs
directory. So, create the file src/jobs/check-restock.ts
with the following content:
1import {2 MedusaContainer,3} from "@medusajs/framework/types"4import { 5 sendRestockNotificationsWorkflow,6} from "../workflows/send-restock-notifications"7 8export default async function myCustomJob(container: MedusaContainer) {9 await sendRestockNotificationsWorkflow(container)10 .run()11}12 13export const config = {14 name: "check-restock",15 schedule: "0 0 * * *", // For debugging, change to `* * * * *`16}
In this file, you export:
- An asynchronous function, which is the task to execute at the specified schedule.
- A configuration object having the following properties:
name
: A unique name for the scheduled job.schedule
: A cron expression string indicating the schedule to run the job at. The specified schedule indicates that this job should run every day at midnight.
The scheduled job function accepts the Medusa container as a parameter. In the function, you execute the sendRestockNotificationsWorkflow
by invoking it, passing it the container, then executing its run
method.
Test Scheduled Job#
To test out the scheduled job, start the Medusa application:
Then, open the Medusa Admin again at http://localhost:9000/app
and log in. After that:
- Go to the same product -> variant that you edited earlier to make out of stock.
- On the variant's details page, click on an inventory item under the Inventory Items section.
- On the inventory item's page, click on the three dots icon next to a location, then choose edit from the dropdown.
- In the drawer form, enter any value greater than
0
. - Click the Save button.
With this change, the variant you previously subscribed to is now restocked. To trigger the scheduled job to run, change its config
object to run every minute:
After the application restarts, wait for the scheduled job to execute. If you're using the default Notification Module Provider that logs notifications in the terminal, you'll see a message similar to the following:
Next Steps#
You've now implemented restock notifications in Medusa. You can also customize the storefront to allow customers to subscribe to the restock notification using the new API route you added.
If you're new to Medusa, check out the main documentation, where you'll get a more in-depth learning of all the concepts you've used in this guide and more.
To learn more about the commerce features that Medusa provides, check out Medusa's Commerce Modules.
For other general guides related to deployment, storefront development, integrations, and more, check out the Development Resources.