docs: added restock notification guide (#10413)

* docs: added restock notification guide

* changes

* lint fixes

* fixes

* more changes

* remove get email step

* add og image

* update sendRestockNotificationsWorkflow

* updates

* fix links
This commit is contained in:
Shahed Nasser
2024-12-10 16:50:40 +02:00
committed by GitHub
parent 4b384eaf05
commit e41ab50f59
12 changed files with 1554 additions and 406 deletions
@@ -182,14 +182,14 @@ export const serviceHighlights1 = [
```ts title="src/modules/resend/service.ts" highlights={serviceHighlights1}
import {
AbstractNotificationProviderService
AbstractNotificationProviderService,
} from "@medusajs/framework/utils"
import {
Logger
} from "@medusajs/framework/types";
Logger,
} from "@medusajs/framework/types"
import {
Resend
} from "resend";
Resend,
} from "resend"
type ResendOptions = {
api_key: string
@@ -263,7 +263,7 @@ So, add to the `ResendNotificationProviderService` the `validateOptions` method:
// other imports...
import {
// other imports...
MedusaError
MedusaError,
} from "@medusajs/framework/utils"
// ...
@@ -397,7 +397,7 @@ class ResendNotificationProviderService extends AbstractNotificationProviderServ
from: this.options.from,
to: [notification.to],
subject: this.getTemplateSubject(notification.template as Templates),
html: ""
html: "",
}
if (typeof template === "string") {
@@ -440,7 +440,7 @@ Create the file `src/modules/resend/index.ts` with the following content:
```ts title="src/modules/resend/index.ts"
import {
ModuleProvider,
Modules
Modules,
} from "@medusajs/framework/utils"
import ResendNotificationProviderService from "./service"
@@ -562,7 +562,7 @@ function OrderPlacedEmailComponent({ order }: OrderPlacedEmailProps) {
<Heading>Thank you for your order</Heading>
{order.email}'s Items
<Container>
{order.items.map(item => {
{order.items.map((item) => {
return (
<Section
key={item.id}
@@ -606,10 +606,10 @@ Next, update the `templates` variable in `src/modules/resend/service.ts` to assi
```ts title="src/modules/resend/service.ts"
// other imports...
import { orderPlacedEmail } from "./emails/order-placed";
import { orderPlacedEmail } from "./emails/order-placed"
const templates: {[key in Templates]?: (props: unknown) => React.ReactNode} = {
[Templates.ORDER_PLACED]: orderPlacedEmail
[Templates.ORDER_PLACED]: orderPlacedEmail,
}
```
@@ -696,10 +696,10 @@ export const workflowHighlights = [
```ts title="src/workflows/send-order-confirmation.ts" highlights={workflowHighlights}
import {
createWorkflow,
WorkflowResponse
} from "@medusajs/framework/workflows-sdk";
import { useQueryGraphStep } from "@medusajs/medusa/core-flows";
import { sendNotificationStep } from "./steps/send-notification";
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "@medusajs/medusa/core-flows"
import { sendNotificationStep } from "./steps/send-notification"
type WorkflowInput = {
id: string
@@ -719,8 +719,8 @@ export const sendOrderConfirmationWorkflow = createWorkflow(
"items.*",
],
filters: {
id
}
id,
},
})
const notification = sendNotificationStep([{
@@ -728,8 +728,8 @@ export const sendOrderConfirmationWorkflow = createWorkflow(
channel: "email",
template: "order-placed",
data: {
order: orders[0]
}
order: orders[0],
},
}])
return new WorkflowResponse(notification)
@@ -787,8 +787,8 @@ export default async function orderPlacedHandler({
await sendOrderConfirmationWorkflow(container)
.run({
input: {
id: data.id
}
id: data.id,
},
})
}
@@ -19,371 +19,14 @@ Medusa provides the necessary architecture and tools to implement commerce autom
## Re-Stock Notifications
Customers may be interested in a product that is currently out of stock.
To implement sending restock notifications, you can:
- Create a module that manages the customers subscribed to a variant's restock notification.
- Create relationships to the Product and Sales Channel modules. A variant's inventory is managed by the sales channel's associated stock locations.
- Create an API route that allows customers to subscribe to a variant's restock notification.
- Create a subscriber that listens to the `inventory-item.updated` event and sends a notification to the subscribed customers if the variant's quantity is more than `0`.
<Note type="soon">
The `inventory-item.updated` event is currently not emitted.
</Note>
<CardList items={[
{
href: "!docs!/learn/fundamentals/modules",
title: "Create a Module",
text: "Learn how to create a module in Medusa.",
icon: AcademicCapSolid,
},
{
href: "!docs!/learn/fundamentals/modules#1-create-data-model",
title: "Create a Data Model",
text: "Learn how to create a data model.",
icon: AcademicCapSolid,
},
]} />
<CardList items={[
{
href: "!docs!/learn/fundamentals/api-routes",
title: "Create an API Route",
text: "Learn how to create an API route in Medusa.",
icon: AcademicCapSolid,
},
{
href: "!docs!/learn/fundamentals/events-and-subscribers",
title: "Create a Subscriber",
text: "Learn how to create a subscriber in Medusa.",
icon: AcademicCapSolid,
},
]} className="mt-1" />
<Details summaryContent="Example">
In this example, you'll create a Restock Notification Module with the features explained above.
### Create Restock Notification Module
Start by creating the `src/modules/restock-notification` directory.
Then, create the file `src/modules/restock-notification/models/restock-notification.ts` with the following content:
export const restockModelHighlights = [
["5", "email", "The email of the customer to send the notification to when the item is restocked."],
["6", "variant_id", "The ID of the variant the customer is subscribed to."],
["7", "sales_channel_id", "The ID of the sales channel the customer is viewing the product variant from."]
]
```ts title="src/modules/restock-notification/models/restock-notification.ts" highlights={restockModelHighlights}
import { model } from "@medusajs/framework/utils"
const RestockNotification = model.define("restock_notification", {
id: model.id().primaryKey(),
email: model.text(),
variant_id: model.text(),
sales_channel_id: model.text(),
})
export default RestockNotification
```
This creates a `RestockNotification` data model with the following properties:
- `id`: An automatically generated ID.
- `email`: The email of the customer to send the notification to when the item is restocked.
- `variant_id`: The ID of the variant the customer is subscribed to. This will later be used to form a relationship with the `ProductVariant` data model of the Product Module.
- `sales_channel_id`: The ID of the sales channel the customer is viewing the product variant from. This will later be used to form a relationship with the `SalesChannel` data model of the Sales Channel Module.
Since a variant's inventory is managed based on the locations of each sales channel, you have to specify which sales channel to check stock quantity in.
Next, create the file `src/modules/restock-notification/migrations/Migration20240516140616.ts` with the following content:
```ts title="src/modules/restock-notification/migrations/Migration20240516140616.ts"
import { Migration } from "@mikro-orm/migrations"
export class Migration20240516140616 extends Migration {
async up(): Promise<void> {
this.addSql("create table if not exists \"restock_notification\" (\"id\" text not null, \"email\" text not null, \"variant_id\" text not null, \"sales_channel_id\" text not null, constraint \"restock_notification_pkey\" primary key (\"id\"));")
}
async down(): Promise<void> {
this.addSql("drop table if exists \"restock_notification\" cascade;")
}
}
```
You'll run the migration to reflect the changes on the database after finishing the module's definition.
Then, create the module's main service at `src/modules/restock-notification/service.ts` with the following content:
```ts title="src/modules/restock-notification/service.ts"
import { MedusaService } from "@medusajs/framework/utils"
import RestockNotification from "./models/restock-notification"
class RestockNotificationModuleService extends MedusaService({
RestockNotification,
}){
// TODO add custom methods
}
export default RestockNotificationModuleService
```
The module's main service extends the service factory which generates basic management features for the `RestockNotification` data model.
Next, create the module's definition file `src/modules/restock-notification/index.ts` with the following content:
```ts title="src/modules/restock-notification/index.ts"
import RestockNotificationModuleService from "./service"
import { Module } from "@medusajs/framework/utils"
export default Module("restock-notification", {
service: RestockNotificationModuleService,
})
```
Finally, add the module to the `modules` object in `medusa-config.ts`:
```ts title="medusa-config.ts"
module.exports = defineConfig({
// ...
modules: [
{
resolve: "./src/modules/restock-notification",
},
],
})
```
You can now run the migrations with the following command:
```bash npm2yarn
npx medusa db:migrate
```
### Create Restock Notification API Route
Create the file `src/api/store/restock-notification/route.ts` with the following content:
```ts title="src/api/store/restock-notification/route.ts" collapsibleLines="1-13" expandButtonLabel="Show Imports"
import type {
MedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import RestockNotificationModuleService
from "../../../modules/restock-notification/service"
type RestockNotificationReq = {
email: string
variant_id: string
sales_channel_id: string
}
export async function POST(
req: MedusaRequest<RestockNotificationReq>,
res: MedusaResponse
) {
const restockNotificationModuleService:
RestockNotificationModuleService = req.scope.resolve(
"restockNotificationModuleService"
)
await restockNotificationModuleService.createRestockNotifications(
req.body
)
res.json({
success: true,
})
}
```
This creates a `POST` API route at `/store/restock-notification`. It accepts the `email`, `variant_id`, and `sales_channel_id` request body parameters and creates a restock notification.
### Create Inventory Item Updated Subscriber
To handle the sending of the restock notifications, create a subscriber that listens to the `inventory-item.updated` event, then sends a notification using the Notification Module to subscribed emails.
<Note type="soon">
The `inventory-item.updated` event is currently not emitted.
</Note>
{/* To handle the sending of the restock notifications, create the file `src/subscribers/inventory-item-update.ts` with the following content: */}
export const subscriberHighlights = [
["48", "inventoryVariantLinkService", "Retrieve an instance of the link service for the product-variant-inventory-item link module."],
["55", "inventoryVariantItems", "Retrieve the variants linked to the updated inventory item."],
["68", "restockQuery", "Assemble the query to retrieve the restock notifications with their associated variants."],
["81", "restockNotifications", "Retrieve the restock notifications using the query."],
["84", "salesChannelLocationService", "Retrieve an instance of the link service for the sales-channel-stock-location link module."],
["93", "salesChannelLocations", "Retrieve the stock locations linked to the restock notification's sales channel."],
["107", "availableQuantity", "Retrieve the available quantity of the variant in the retrieved stock locations."],
["116", "continue", "Only send the notification if the available quantity is greater than `0`"],
["119", "createNotifications", "Send the notification to the customer using the Notification Module."],
["122", '"test_template"', "Replace with the actual template used for sending the email."],
["123", "data", "The data to send along to the third-party service sending the notification."],
["131", "deleteRestockNotifications", "Delete the restock notification to not send the notification again."]
]
{/* ```ts title="src/subscribers/inventory-item-update.ts" highlights={subscriberHighlights} collapsibleLines="1-20" expandButtonLabel="Show Imports"
import type {
SubscriberArgs,
SubscriberConfig,
} from "@medusajs/framework"
import {
IInventoryService,
INotificationModuleService,
RemoteQueryFunction,
} from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
Modules,
remoteQueryObjectFromString,
} from "@medusajs/framework/utils"
import {
RemoteLink,
} from "@medusajs/framework/modules-sdk"
import RestockNotificationModuleService
from "../modules/restock-notification/service"
// subscriber function
export default async function inventoryItemUpdateHandler({
data,
container,
}: SubscriberArgs<{ id: string }>) {
const remoteQuery: RemoteQueryFunction = container.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const remoteLink: RemoteLink = container.resolve(
ContainerRegistrationKeys.REMOTE_LINK
)
const restockNotificationModuleService:
RestockNotificationModuleService = container.resolve(
"restockNotificationModuleService"
)
const inventoryModuleService: IInventoryService =
container.resolve(Modules.INVENTORY)
const notificationModuleService: INotificationModuleService =
container.resolve(
Modules.NOTIFICATION
)
const inventoryItemId = data.data.id
const inventoryVariantLinkService = remoteLink.getLinkModule(
Modules.PRODUCT,
"variant_id",
Modules.INVENTORY,
"inventory_item_id"
)
const inventoryVariantItems =
await inventoryVariantLinkService.list({
inventory_item_id: [inventoryItemId],
}) as {
variant_id: string,
inventory_item_id: string
}[]
if (!inventoryVariantItems.length) {
console.log("no inventory variant items")
return
}
const restockQuery = remoteQueryObjectFromString({
entryPoint: "restock_notification",
fields: [
"email",
"variant.name",
],
variables: {
filters: {
variant_id: inventoryVariantItems[0].variant_id,
},
},
})
const restockNotifications =
await remoteQuery(restockQuery)
const salesChannelLocationService = remoteLink.getLinkModule(
Modules.SALES_CHANNEL,
"sales_channel_id",
Modules.STOCK_LOCATION,
"stock_location_id"
)
for (const restockNotification of restockNotifications) {
const salesChannelLocations =
await salesChannelLocationService.list({
sales_channel_id: [
restockNotification.sales_channel_id,
],
}) as {
stock_location_id: string
sales_channel_id: string
}[]
if (!salesChannelLocations.length) {
continue
}
const availableQuantity = await inventoryModuleService
.retrieveAvailableQuantity(
inventoryItemId,
salesChannelLocations.map(
(salesChannelLocation) =>
salesChannelLocation.stock_location_id
)
)
if (availableQuantity === 0) {
continue
}
notificationModuleService.createNotifications({
to: restockNotification.email,
channel: "email",
template: "test_template",
data: {
variant_id: restockNotification.variant_id,
variant_name: restockNotification.variant.title,
// other data...
},
})
// delete the restock notification
await restockNotificationModuleService
.deleteRestockNotifications(restockNotification.id)
}
}
// subscriber config
export const config: SubscriberConfig = {
event: "inventory-item.updated",
}
```
This adds a subscriber to the `inventory-item.updated` event. In the subscriber handler function, you:
- Retrieve an instance of the link service for the product-variant-inventory-item link module.
- Retrieve the variants linked to the updated inventory item.
- Retrieve the restock notifications of those variants.
- For each restock notification, you:
- Retrieve its quantity based on the stock location associated with the restock notification's sales channel.
- If the quantity is greater than `0`, you send a notification using the Notification Module and delete the restock notification. */}
</Details>
Customers may be interested in a product that is currently out of stock. The following guide explains how to add restock notifications in your Medusa application:
<Card
href="/recipes/commerce-automation/restock-notification"
title="Restock Notification Guide"
text="Learn how to implement restock notifications in the Medusa application."
icon={AcademicCapSolid}
/>
---
@@ -458,13 +101,13 @@ export const syncProductsWorkflowHighlight = [
```ts title="src/workflows/sync-products.ts" highlights={syncProductsWorkflowHighlight} collapsibleLines="1-16" expandButtonLabel="Show Imports"
import {
Modules
Modules,
} from "@medusajs/framework/utils"
import {
IProductModuleService,
IStoreModuleService,
ProductDTO,
StoreDTO
StoreDTO,
} from "@medusajs/framework/types"
import {
StepResponse,
File diff suppressed because it is too large Load Diff
@@ -894,7 +894,7 @@ import {
import { Modules, promiseAll } from "@medusajs/framework/utils"
import {
cancelOrderWorkflow,
createOrderWorkflow
createOrderWorkflow,
} from "@medusajs/medusa/core-flows"
import MarketplaceModuleService from "../../../../modules/marketplace/service"
import { MARKETPLACE_MODULE } from "../../../../modules/marketplace"
@@ -1010,9 +1010,9 @@ try {
await promiseAll(
vendorIds.map(async (vendorId) => {
const items = vendorsItems[vendorId]
const vendor = vendors.find(v => v.id === vendorId)!
const vendor = vendors.find((v) => v.id === vendorId)!
const {result: childOrder} = await createOrderWorkflow(
const { result: childOrder } = await createOrderWorkflow(
container
)
.run({
@@ -1025,11 +1025,11 @@ try {
linkDefs.push({
[MARKETPLACE_MODULE]: {
vendor_id: vendor.id
vendor_id: vendor.id,
},
[Modules.ORDER]: {
order_id: childOrder.id
}
order_id: childOrder.id,
},
})
})
)
@@ -1037,7 +1037,7 @@ try {
return StepResponse.permanentFailure(
`An error occured while creating vendor orders: ${e}`,
{
created_orders: createdOrders
created_orders: createdOrders,
}
)
}
@@ -1174,7 +1174,7 @@ const createVendorOrdersWorkflow = createWorkflow(
})
const { vendorsItems } = groupVendorItemsStep({
cart: carts[0]
cart: carts[0],
})
const order = getOrderDetailWorkflow.runAsStep({
@@ -29,7 +29,7 @@ export const fetchHighlights = [
const searchParams = new URLSearchParams({
fields: "category_children.id,category_children.name",
include_descendants_tree: true,
parent_category_id: null
parent_category_id: null,
})
fetch(`http://localhost:9000/store/product-categories/${id}?${
@@ -84,7 +84,7 @@ export const highlights = [
const searchParams = new URLSearchParams({
fields: "category_children.id,category_children.name",
parent_category_id: null
parent_category_id: null,
})
fetch(`http://localhost:9000/store/product-categories/${id}?${
+2 -1
View File
@@ -5659,5 +5659,6 @@ export const generatedEditDates = {
"references/workflows/classes/workflows.WorkflowResponse/page.mdx": "2024-12-09T13:22:04.820Z",
"references/workflows/interfaces/workflows.ApplyStepOptions/page.mdx": "2024-12-09T13:22:04.808Z",
"references/workflows/types/workflows.WorkflowData/page.mdx": "2024-12-09T13:22:04.836Z",
"app/integrations/guides/resend/page.mdx": "2024-12-09T16:19:17.798Z"
"app/integrations/guides/resend/page.mdx": "2024-12-09T16:19:17.798Z",
"app/recipes/commerce-automation/restock-notification/page.mdx": "2024-12-10T14:15:39.178Z"
}
@@ -667,6 +667,10 @@ export const filesMap = [
"filePath": "/www/apps/resources/app/recipes/commerce-automation/page.mdx",
"pathname": "/recipes/commerce-automation"
},
{
"filePath": "/www/apps/resources/app/recipes/commerce-automation/restock-notification/page.mdx",
"pathname": "/recipes/commerce-automation/restock-notification"
},
{
"filePath": "/www/apps/resources/app/recipes/digital-products/examples/standard/page.mdx",
"pathname": "/recipes/digital-products/examples/standard"
+10 -1
View File
@@ -96,7 +96,16 @@ export const generatedSidebar = [
"type": "link",
"path": "/recipes/commerce-automation",
"title": "Commerce Automation",
"children": []
"children": [
{
"loaded": true,
"isPathHref": true,
"type": "link",
"path": "/recipes/commerce-automation/restock-notification",
"title": "Example: Restock Notifications",
"children": []
}
]
},
{
"loaded": true,
+7
View File
@@ -68,6 +68,13 @@ export const sidebar = sidebarAttachHrefCommonOptions([
type: "link",
path: "/recipes/commerce-automation",
title: "Commerce Automation",
children: [
{
type: "link",
path: "/recipes/commerce-automation/restock-notification",
title: "Example: Restock Notifications",
},
],
},
{
type: "link",
@@ -17,8 +17,8 @@ export const WorkflowDiagramCanvasDepth = ({
return (
<div className="flex items-start">
<div className="flex flex-col justify-center gap-y-docs_0.5">
{cluster.map((step) => (
<WorkflowDiagramStepNode key={step.name} step={step} />
{cluster.map((step, index) => (
<WorkflowDiagramStepNode key={`${step.name}-${index}`} step={step} />
))}
</div>
<WorkflowDiagramLine step={next} />
@@ -17,8 +17,8 @@ export const WorkflowDiagramDepth = ({
return (
<div className="flex items-start">
<div className="flex flex-col justify-center gap-y-docs_0.5">
{cluster.map((step) => (
<WorkflowDiagramStepNode key={step.name} step={step} />
{cluster.map((step, index) => (
<WorkflowDiagramStepNode key={`${step.name}-${index}`} step={step} />
))}
</div>
<WorkflowDiagramLine step={next} />
@@ -17,8 +17,8 @@ export const WorkflowDiagramListDepth = ({
<div className="flex items-start">
<WorkflowDiagramLine step={cluster} />
<div className="flex flex-col justify-center gap-y-docs_0.5">
{cluster.map((step) => (
<WorkflowDiagramStepNode key={step.name} step={step} />
{cluster.map((step, index) => (
<WorkflowDiagramStepNode key={`${step.name}-${index}`} step={step} />
))}
</div>
</div>