A Medusa module that enables restock notifications for your e-commerce store. This module allows customers to subscribe to out-of-stock products and receive email notifications when items are back in stock.
- Allow customers to subscribe to out-of-stock products
- Send automated restock notifications via SendGrid
- Track subscription status
- Admin API for managing subscriptions
- Store API for customer subscriptions
- Scheduled job for checking inventory levels
- Support for batch email notifications
npm install @intuio/medusa-restock-notification
Add to your medusa-config.js
:
module.exports = {
projectConfig: {
// ... other config
database_type: "postgres",
redis_url: REDIS_URL,
database_url: DATABASE_URL,
},
modules: [
{
resolve: "@intuio/medusa-restock-notification"
}
]
}
Add to your .env
:
SENDGRID_API_KEY=your_api_key
[email protected]
SENDGRID_RESTOCK_TEMPLATE_ID=your_template_id
STORE_NAME="Your Store Name"
Since Medusa v2 requires API routes and jobs to be implemented at the application level, you'll need to create the following files in your Medusa project:
Create src/api/store/restock-notifications/route.ts
:
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { RestockService } from "@intuio/medusa-restock-notification"
import { Modules } from "@medusajs/framework/utils"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const restockService = req.scope.resolve<RestockService>("restock")
const productService = req.scope.resolve(Modules.PRODUCT)
const inventoryService = req.scope.resolve(Modules.INVENTORY)
const logger = req.scope.resolve("logger")
try {
const { variant_id, email }: any = req.body
if (!variant_id || !email) {
res.status(400).json({ message: "variant_id and email are required" })
return
}
// Get variant details including SKU
const variantDetails = await productService.listProductVariants({
id: [variant_id],
}, {
relations: ["options", "product"],
})
if (!variantDetails?.length) {
res.status(404).json({ message: "Variant not found" })
return
}
const variant: any = variantDetails[0]
const variantSku = variant.sku
if (!variantSku) {
logger.warn(`Variant ${variant_id} has no SKU`)
res.status(400).json({ message: "Variant has no SKU" })
return
}
// Get product details
const product = await productService.retrieveProduct(variant.product_id)
if (!product) {
res.status(404).json({ message: "Product not found" })
return
}
// Get inventory items by SKU
const inventoryItems = await inventoryService.listInventoryItems({})
const inventoryItem = inventoryItems.find(item => item.sku === variantSku)
if (!inventoryItem) {
logger.warn(`No inventory item found for SKU ${variantSku}`)
res.status(404).json({ message: "No inventory item found" })
return
}
// Get inventory levels for this item
const levels = await inventoryService.listInventoryLevels({
inventory_item_id: inventoryItem.id
})
// Calculate total stock quantity
let stockQuantity = 0
for (const level of levels) {
stockQuantity += level.stocked_quantity || 0
}
if (stockQuantity > 0) {
res.status(400).json({
message: "Product is currently in stock",
stock_quantity: stockQuantity
})
return
}
// Create subscription if out of stock
const subscription = await restockService.subscribe(
variant_id,
email,
product.title,
variant.title
)
res.json({
subscription,
product_id: variant.product_id,
variant_title: variant.title
})
} catch (error) {
logger.error("Error in restock notification:", error)
res.status(400).json({ message: error.message })
}
}
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const restockService = req.scope.resolve<RestockService>("restock")
const { email } = req.query
try {
if (!email) {
res.status(400).json({ message: "Email is required" })
return
}
const subscriptions = await restockService.listSubscriptions(email as string)
res.json({ subscriptions })
} catch (error) {
res.status(400).json({ message: error.message })
}
}
Create src/api/store/restock-notifications/check-subscription/route.ts
:
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { RestockService } from "@intuio/medusa-restock-notification"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const restockService = req.scope.resolve<RestockService>("restock")
const { email, variant_id } = req.query
try {
if (!email || !variant_id) {
res.status(400).json({
message: "email and variant_id are required"
})
return
}
const result = await restockService.isSubscribed(
email as string,
variant_id as string
)
res.json({
variant_id,
email,
...result
})
} catch (error) {
res.status(400).json({ message: error.message })
}
}
Create src/api/store/restock-notifications/[id]/route.ts
:
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { RestockService } from "@intuio/medusa-restock-notification"
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
const restockService = req.scope.resolve<RestockService>("restock")
const { id } = req.params
const { email }: any = req.body
try {
await restockService.removeSubscription(id, email)
res.json({ success: true })
} catch (error) {
res.status(400).json({ message: error.message })
}
}
Create src/api/admin/restock-notifications/route.ts
:
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { RestockService } from "@intuio/medusa-restock-notification"
import { IProductModuleService, IInventoryService } from "@medusajs/types"
import { Modules } from "@medusajs/framework/utils"
import sgMail from "@sendgrid/mail"
// Initialize SendGrid
sgMail.setApiKey(process.env.SENDGRID_API_KEY || '')
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const restockService = req.scope.resolve<RestockService>("restock")
const { variant_id } = req.query
try {
if (!variant_id) {
res.status(400).json({ message: "variant_id is required" })
return
}
const subscribers = await restockService.getSubscribers(variant_id as string)
res.json({ subscribers })
} catch (error) {
res.status(400).json({ message: error.message })
}
}
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const restockService = req.scope.resolve<RestockService>("restock")
const productService = req.scope.resolve<IProductModuleService>(Modules.PRODUCT)
const inventoryService = req.scope.resolve<IInventoryService>(Modules.INVENTORY)
const logger = req.scope.resolve("logger")
try {
const { variant_id }: any = req.body
if (!variant_id) {
res.status(400).json({
message: "variant_id is required required"
})
return
}
const variantDetails = await productService.listProductVariants({
id: [variant_id],
}, {
relations: ["options", "product"],
})
if (!variantDetails?.length) {
res.status(404).json({ message: "Variant not found" })
return
}
const variant: any = variantDetails[0]
const variantSku = variant.sku
if (!variantSku) {
logger.warn(`Variant ${variant_id} has no SKU`)
res.status(400).json({ message: "Variant has no SKU" })
return
}
// Get product details
const product = await productService.retrieveProduct(variant.product_id)
if (!product) {
res.status(404).json({ message: "Product not found" })
return
}
// Get inventory items
const inventoryItems = await inventoryService.listInventoryItems({})
const inventoryItem = inventoryItems.find(item => item.sku === variantSku)
if (!inventoryItem) {
logger.warn(`No inventory item found for SKU ${variantSku}`)
res.status(404).json({ message: "No inventory item found" })
return
}
// Get inventory levels for this item
const levels = await inventoryService.listInventoryLevels({
inventory_item_id: inventoryItem.id
})
// Calculate total stock quantity
let stockQuantity = 0
for (const level of levels) {
stockQuantity += level.stocked_quantity || 0
}
if (stockQuantity > 0) {
res.status(400).json({
message: "Product is currently in stock",
stock_quantity: stockQuantity
})
return
}
// Get subscribers
const subscribers = await restockService.getSubscribers(variant_id)
if (subscribers.length === 0) {
res.json({ message: "No subscribers found" })
return
}
// Prepare email data
const emails = subscribers.map(subscriber => ({
to: subscriber.email,
from: {
email: process.env.SENDGRID_FROM || '',
name: process.env.STORE_NAME || 'Your Store'
},
templateId: process.env.SENDGRID_RESTOCK_TEMPLATE_ID,
dynamicTemplateData: {
product_title: product.title,
variant_title: variant.title,
current_stock: stockQuantity
}
}))
// Send emails in batches
const BATCH_SIZE = 1000
for (let i = 0; i < emails.length; i += BATCH_SIZE) {
const batch: any = emails.slice(i, i + BATCH_SIZE)
try {
await sgMail.send(batch)
logger.info(`Sent restock notifications batch ${i / BATCH_SIZE + 1}`)
} catch (error) {
logger.error(`Failed to send email batch: ${error.message}`)
throw error
}
}
// Mark notifications as sent
await restockService.markNotified(subscribers.map(sub => sub.id))
res.json({
message: "Notifications sent successfully",
count: subscribers.length,
stock_quantity: stockQuantity
})
} catch (error) {
logger.error(`Error in restock notification: ${error.message}`)
res.status(400).json({
message: "Failed to send notifications",
error: error.message
})
}
}
Create src/jobs/check-inventory.ts
:
import { IProductModuleService, IInventoryService, MedusaContainer } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { RestockService } from "@intuio/medusa-restock-notification"
import sgMail from "@sendgrid/mail"
export default async function checkInventoryJob(container: MedusaContainer) {
const logger = container.resolve("logger")
const restockService = container.resolve("restock") as RestockService
const productService = container.resolve<IProductModuleService>(Modules.PRODUCT)
const inventoryService = container.resolve<IInventoryService>(Modules.INVENTORY)
try {
logger.info("Starting inventory check job")
// Get all variants that have subscribers
const allSubscriptions = await restockService.getAllActiveSubscriptions()
// Group subscriptions by variant ID
const variantSubscriptions = allSubscriptions.reduce((acc, sub) => {
if (!acc[sub.variant_id]) {
acc[sub.variant_id] = []
}
acc[sub.variant_id].push(sub)
return acc
}, {})
// Process each variant
for (const subscription of Object.entries(variantSubscriptions)) {
const [variantId, subscribers]: any = subscription;
try {
// Get variant details from product service
const variantDetails = await productService.listProductVariants({
id: [variantId],
}, {
relations: ["product"]
})
if (!variantDetails?.length) {
logger.warn(`Variant ${variantId} not found`)
continue
}
const variant: any = variantDetails[0]
const variantSku = variant.sku
if (!variantSku) {
logger.warn(`Variant ${variantId} has no SKU`)
continue
}
// Get inventory items by SKU
const inventoryItems = await inventoryService.listInventoryItems({})
const inventoryItem = inventoryItems.find(item => item.sku === variantSku)
if (!inventoryItem) {
logger.warn(`No inventory item found for SKU ${variantSku}`)
continue
}
// Get inventory levels for this item
const levels = await inventoryService.listInventoryLevels({
inventory_item_id: inventoryItem.id
})
// Calculate total stock quantity
let stockQuantity = 0
for (const level of levels) {
stockQuantity += level.stocked_quantity || 0
}
// If item is in stock, notify subscribers
if (stockQuantity > 0) {
logger.info(`Processing notifications for variant ${variantId} with stock ${stockQuantity}`)
// Prepare email data
const emails = subscribers.map(subscriber => ({
to: subscriber.email,
from: {
email: process.env.SENDGRID_FROM || '',
name: process.env.STORE_NAME || 'Your Store'
},
templateId: process.env.SENDGRID_RESTOCK_TEMPLATE_ID,
dynamicTemplateData: {
product_title: variant.product.title,
variant_title: variant.title,
current_stock: stockQuantity
}
}))
// Send emails in batches
const BATCH_SIZE = 1000
for (let i = 0; i < emails.length; i += BATCH_SIZE) {
const batch: any = emails.slice(i, i + BATCH_SIZE)
try {
await sgMail.send(batch)
logger.info(`Sent restock notifications batch ${i / BATCH_SIZE + 1}`)
} catch (error) {
logger.error(`Failed to send email batch: ${error.message}`)
continue
}
}
// Mark subscribers as notified
await restockService.markNotified(subscribers.map(sub => sub.id))
logger.info(`Marked ${subscribers.length} subscribers as notified for variant ${variantId}`)
}
} catch (error) {
logger.error(`Error processing variant ${variantId}: ${error.message}`)
continue // Continue with next variant even if one fails
}
}
logger.info("Completed inventory check job")
} catch (error) {
logger.error("Failed to process inventory check job:", error)
throw error
}
}
export const config = {
name: "check-inventory-restock",
schedule: "*/5 * * * *", // Run every 5 minutes
}
- POST
/store/restock-notifications
{ "email": "[email protected]", "variant_id": "variant_123" }
- GET
/store/[email protected]
- GET
/store/restock-notifications/check-subscription?email=xxx&variant_id=xxx
- DELETE
/store/restock-notifications/:id
{ "email": "[email protected]" }
- GET
/admin/restock-notifications?variant_id=xxx
- POST
/admin/restock-notifications
{ "variant_id": "variant_123" }
The following variables are available in your SendGrid email templates:
product_title
- The name of the productvariant_title
- The specific variant titlecurrent_stock
- Current available stock quantitystore_name
- Your store name as configured in environment variables
The inventory check job runs every 5 minutes by default. You can modify the schedule in the job configuration using standard cron syntax. To change the schedule, modify the config
object in the check-inventory.ts
file:
export const config = {
name: "check-inventory-restock",
schedule: "*/5 * * * *", // Modify this cron expression as needed
}
Common cron schedule examples:
- Every hour:
0 * * * *
- Every day at midnight:
0 0 * * *
- Every 15 minutes:
*/15 * * * *
To work on the module locally:
# Install dependencies
npm install
# Build the module
npm run build
# Run migrations
npx medusa db:migrate
# Run in watch mode during development
npm run dev
Weβre building this in public at Intuio Software Labs β a premium product studio focused on ecommerce and open-source innovation. β Like the plugin? Buy us a coffee or support our efforts : Donate here
Weβre looking for contributors, collaborators, and ecommerce founders to partner with. If youβre doing something cool with Medusa or want to build the next big thing, reach out!
π© [email protected] / [email protected]
π https://intuio.io
Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change.
Check out the contributing guide to get started.
MIT Β© Intuio Software Labs
If youβve used this plugin and found it helpful, leave us a β on GitHub and share it with others using Medusa.