Skip to content

intuio-io/medusa-v2-restock-notification

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

8 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Medusa Restock Notification Module (v2)

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.

Features

  • 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

Installation

npm install @intuio/medusa-restock-notification

Module Configuration

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"
    }
  ]
}

Environment Variables

Add to your .env:

SENDGRID_API_KEY=your_api_key
[email protected]
SENDGRID_RESTOCK_TEMPLATE_ID=your_template_id
STORE_NAME="Your Store Name"

Implementation Guide

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:

1. Store API Routes

Subscribe to Restock Notifications

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 })
    }
}

Check Subscription Status

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 })
    }
}

Remove Subscription

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 })
    }
}

2. Admin API Routes

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
        })
    }
}

3. Inventory Check Job

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
}

API Reference

Store Endpoints

Subscribe to Restock Notifications

  • POST /store/restock-notifications
    {
      "email": "[email protected]",
      "variant_id": "variant_123"
    }

Get User's Subscriptions

Check Subscription Status

  • GET /store/restock-notifications/check-subscription?email=xxx&variant_id=xxx

Remove Subscription

Admin Endpoints

Get Subscribers for Variant

  • GET /admin/restock-notifications?variant_id=xxx

Force Send Notifications

  • POST /admin/restock-notifications
    {
      "variant_id": "variant_123"
    }

Customization

SendGrid Template Variables

The following variables are available in your SendGrid email templates:

  • product_title - The name of the product
  • variant_title - The specific variant title
  • current_stock - Current available stock quantity
  • store_name - Your store name as configured in environment variables

Job Schedule

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 * * * *

Development

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

πŸ’¬ Let's Connect

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

πŸ‘₯ Collaborate With Us

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

🀝 Contributing

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.

πŸ“œ License

MIT Β© Intuio Software Labs

πŸ“ˆ Loved By the Community?

If you’ve used this plugin and found it helpful, leave us a ⭐ on GitHub and share it with others using Medusa.

About

Restock notification module for Medusa v2

Resources

Stars

Watchers

Forks

Packages

No packages published