Files
medusa-store/www/apps/resources/app/commerce-modules/promotion/extend/page.mdx
2025-03-10 08:33:21 +02:00

705 lines
23 KiB
Plaintext

---
sidebar_label: "Extend Promotion"
tags:
- promotion
- tutorial
- extend module
- server
---
import { Prerequisites } from "docs-ui"
export const metadata = {
title: `Extend Promotion Data Model`,
}
# {metadata.title}
In this documentation, you'll learn how to extend a data model of the Promotion Module to add a custom property.
You'll create a `Custom` data model in a module. This data model will have a `custom_name` property, which is the property you want to add to the [Promotion data model](/references/promotion/models/Promotion) defined in the Promotion Module.
You'll then learn how to:
- Link the `Custom` data model to the `Promotion` data model.
- Set the `custom_name` property when a promotion is created or updated using Medusa's API routes.
- Retrieve the `custom_name` property with the promotion's details, in custom or existing API routes.
<Note title="Tip">
Similar steps can be applied to the `Campaign` data model.
</Note>
## Step 1: Define Custom Data Model
Consider you have a Hello Module defined in the `/src/modules/hello` directory.
<Note title="Tip">
If you don't have a module, follow [this guide](!docs!/learn/fundamentals/modules) to create one.
</Note>
To add the `custom_name` property to the `Promotion` data model, you'll create in the Hello Module a data model that has the `custom_name` property.
Create the file `src/modules/hello/models/custom.ts` with the following content:
```ts title="src/modules/hello/models/custom.ts"
import { model } from "@medusajs/framework/utils"
export const Custom = model.define("custom", {
id: model.id().primaryKey(),
custom_name: model.text(),
})
```
This creates a `Custom` data model that has the `id` and `custom_name` properties.
<Note title="Tip">
Learn more about data models in [this guide](!docs!/learn/fundamentals/modules#1-create-data-model).
</Note>
---
## Step 2: Define Link to Promotion Data Model
Next, you'll define a module link between the `Custom` and `Promotion` data model. A module link allows you to form a relation between two data models of separate modules while maintaining module isolation.
<Note title="Tip">
Learn more about module links in [this guide](!docs!/learn/fundamentals/module-links).
</Note>
Create the file `src/links/promotion-custom.ts` with the following content:
```ts title="src/links/promotion-custom.ts"
import { defineLink } from "@medusajs/framework/utils"
import HelloModule from "../modules/hello"
import PromotionModule from "@medusajs/medusa/promotion"
export default defineLink(
PromotionModule.linkable.promotion,
HelloModule.linkable.custom
)
```
This defines a link between the `Promotion` and `Custom` data models. Using this link, you'll later query data across the modules, and link records of each data model.
---
## Step 3: Generate and Run Migrations
<Prerequisites
items={[
{
text: "Module must be registered in medusa-config.js",
link: "!docs!/learn/fundamentals/modules#4-add-module-to-configurations"
}
]}
/>
To reflect the `Custom` data model in the database, generate a migration that defines the table to be created for it.
Run the following command in your Medusa project's root:
```bash
npx medusa db:generate helloModuleService
```
Where `helloModuleService` is your module's name.
Then, run the `db:migrate` command to run the migrations and create a table in the database for the link between the `Promotion` and `Custom` data models:
```bash
npx medusa db:migrate
```
A table for the link is now created in the database. You can now retrieve and manage the link between records of the data models.
---
## Step 4: Consume promotionsCreated Workflow Hook
When a promotion is created, you also want to create a `Custom` record and set the `custom_name` property, then create a link between the `Promotion` and `Custom` records.
To do that, you'll consume the [promotionsCreated](/references/medusa-workflows/createPromotionsWorkflow#promotionsCreated) hook of the [createPromotionsWorkflow](/references/medusa-workflows/createPromotionsWorkflow). This workflow is executed in the [Create Promotion Admin API route](!api!/admin#promotions_postpromotions)
<Note title="Tip">
Learn more about workflow hooks in [this guide](!docs!/learn/fundamentals/workflows/workflow-hooks).
</Note>
The API route accepts in its request body an `additional_data` parameter. You can pass in it custom data, which is passed to the workflow hook handler.
### Add custom_name to Additional Data Validation
To pass the `custom_name` in the `additional_data` parameter, you must add a validation rule that tells the Medusa application about this custom property.
Create the file `src/api/middlewares.ts` with the following content:
```ts title="src/api/middlewares.ts"
import { defineMiddlewares } from "@medusajs/framework/http"
import { z } from "zod"
export default defineMiddlewares({
routes: [
{
method: "POST",
matcher: "/admin/promotions",
additionalDataValidator: {
custom_name: z.string().optional(),
},
},
],
})
```
The `additional_data` parameter validation is customized using `defineMiddlewares`. In the routes middleware configuration object, the `additionalDataValidator` property accepts [Zod](https://zod.dev/) validaiton rules.
In the snippet above, you add a validation rule indicating that `custom_name` is a string that can be passed in the `additional_data` object.
<Note title="Tip">
Learn more about additional data validation in [this guide](!docs!/learn/fundamentals/api-routes/additional-data).
</Note>
### Create Workflow to Create Custom Record
You'll now create a workflow that will be used in the hook handler.
This workflow will create a `Custom` record, then link it to the promotion.
Start by creating the step that creates the `Custom` record. Create the file `src/workflows/create-custom-from-promotion/steps/create-custom.ts` with the following content:
```ts title="src/workflows/create-custom-from-promotion/steps/create-custom.ts"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import HelloModuleService from "../../../modules/hello/service"
import { HELLO_MODULE } from "../../../modules/hello"
type CreateCustomStepInput = {
custom_name?: string
}
export const createCustomStep = createStep(
"create-custom",
async (data: CreateCustomStepInput, { container }) => {
if (!data.custom_name) {
return
}
const helloModuleService: HelloModuleService = container.resolve(
HELLO_MODULE
)
const custom = await helloModuleService.createCustoms(data)
return new StepResponse(custom, custom)
},
async (custom, { container }) => {
const helloModuleService: HelloModuleService = container.resolve(
HELLO_MODULE
)
await helloModuleService.deleteCustoms(custom.id)
}
)
```
In the step, you resolve the Hello Module's main service and create a `Custom` record.
In the compensation function that undoes the step's actions in case of an error, you delete the created record.
<Note title="Tip">
Learn more about compensation functions in [this guide](!docs!/learn/fundamentals/workflows/compensation-function).
</Note>
Then, create the workflow at `src/workflows/create-custom-from-promotion/index.ts` with the following content:
```ts title="src/workflows/create-custom-from-promotion/index.ts" collapsibleLines="1-7" expandButtonLabel="Show Imports"
import { createWorkflow, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { PromotionDTO } from "@medusajs/framework/types"
import { createRemoteLinkStep } from "@medusajs/medusa/core-flows"
import { Modules } from "@medusajs/framework/utils"
import { HELLO_MODULE } from "../../modules/hello"
import { createCustomStep } from "./steps/create-custom"
export type CreateCustomFromPromotionWorkflowInput = {
promotion: PromotionDTO
additional_data?: {
custom_name?: string
}
}
export const createCustomFromPromotionWorkflow = createWorkflow(
"create-custom-from-promotion",
(input: CreateCustomFromPromotionWorkflowInput) => {
const customName = transform(
{
input,
},
(data) => data.input.additional_data.custom_name || ""
)
const custom = createCustomStep({
custom_name: customName,
})
when(({ custom }), ({ custom }) => custom !== undefined)
.then(() => {
createRemoteLinkStep([{
[Modules.PROMOTION]: {
promotion_id: input.promotion.id,
},
[HELLO_MODULE]: {
custom_id: custom.id,
},
}])
})
return new WorkflowResponse({
custom,
})
}
)
```
The workflow accepts as an input the created promotion and the `additional_data` parameter passed in the request. This is the same input that the `promotionsCreated` hook accepts.
In the workflow, you:
1. Use `transform` to get the value of `custom_name` based on whether it's set in `additional_data`. Learn more about why you can't use conditional operators in a workflow without using `transform` in [this guide](!docs!/learn/fundamentals/workflows/conditions#why-if-conditions-arent-allowed-in-workflows).
2. Create the `Custom` record using the `createCustomStep`.
3. Use `when-then` to link the promotion to the `Custom` record if it was created. Learn more about why you can't use if-then conditions in a workflow without using `when-then` in [this guide](!docs!/learn/fundamentals/workflows/conditions#why-if-conditions-arent-allowed-in-workflows).
You'll next execute the workflow in the hook handler.
### Consume Workflow Hook
You can now consume the `promotionsCreated` hook, which is executed in the `createPromotionsWorkflow` after the promotion is created.
To consume the hook, create the file `src/workflow/hooks/promotion-created.ts` with the following content:
```ts title="src/workflow/hooks/promotion-created.ts" collapsibleLines="1-6" expandButtonLabel="Show Imports"
import { createPromotionsWorkflow } from "@medusajs/medusa/core-flows"
import {
createCustomFromPromotionWorkflow,
CreateCustomFromPromotionWorkflowInput,
} from "../create-custom-from-promotion"
createPromotionsWorkflow.hooks.promotionsCreated(
async ({ promotions, additional_data }, { container }) => {
const workflow = createCustomFromPromotionWorkflow(container)
for (const promotion of promotions) {
await workflow.run({
input: {
promotion,
additional_data,
} as CreateCustomFromPromotionWorkflowInput,
})
}
}
)
```
The hook handler executes the `createPromotionsWorkflow`, passing it its input.
### Test it Out
To test it out, send a `POST` request to `/admin/promotions` to create a promotion, passing `custom_name` in `additional_data`:
```bash
curl --location 'localhost:9000/admin/promotions' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer {token}' \
--data '{
"additional_data": {
"custom_name": "test"
},
"code": "50OFF",
"type": "standard",
"application_method": {
"description": "My promotion",
"value": 50,
"currency_code": "usd",
"max_quantity": 1,
"type": "percentage",
"target_type": "items",
"apply_to_quantity": 0,
"buy_rules_min_quantity": 1,
"allocation": "each"
}
}'
```
Make sure to replace `{token}` with an admin user's JWT token. Learn how to retrieve it in the [API reference](!api!/admin#1-bearer-authorization-with-jwt-tokens).
The request will return the promotion's details. You'll learn how to retrieve the `custom_name` property with the promotion's details in the next section.
---
## Step 5: Retrieve custom_name with Promotion Details
When you extend an existing data model through links, you also want to retrieve the custom properties with the data model.
### Retrieve in API Routes
To retrieve the `custom_name` property when you're retrieving the promotion through API routes, such as the [Get Promotion API Route](!api!/admin#promotions_getpromotionsid), pass in the `fields` query parameter `+custom.*`, which retrieves the linked `Custom` record's details.
<Note title="Tip">
The `+` prefix in `+custom.*` indicates that the relation should be retrieved with the default promotion fields. Learn more about selecting fields and relations in the [API reference](!api!/admin#select-fields-and-relations).
</Note>
For example:
```bash
curl 'localhost:9000/admin/promotions/{promotion_id}?fields=+custom.*' \
-H 'Authorization: Bearer {token}'
```
Make sure to replace `{promotion_id}` with the promotion's ID, and `{token}` with an admin user's JWT token.
Among the returned `promotion` object, you'll find a `custom` property which holds the details of the linked `Custom` record:
```json
{
"promotion": {
// ...
"custom": {
"id": "01J9NP7ANXDZ0EAYF0956ZE1ZA",
"custom_name": "test",
"created_at": "2024-10-08T09:09:06.877Z",
"updated_at": "2024-10-08T09:09:06.877Z",
"deleted_at": null
}
}
}
```
### Retrieve using Query
You can also retrieve the `Custom` record linked to a promotion in your code using [Query](!docs!/learn/fundamentals/module-links/query).
For example:
```ts
const { data: [promotion] } = await query.graph({
entity: "promotion",
fields: ["*", "custom.*"],
filters: {
id: promotion_id,
},
})
```
Learn more about how to use Query in [this guide](!docs!/learn/fundamentals/module-links/query).
---
## Step 6: Consume promotionsUpdated Workflow Hook
Similar to the `promotionsCreated` hook, you'll consume the [promotionsUpdated](/references/medusa-workflows/updatePromotionsWorkflow#promotionsUpdated) hook of the [updatePromotionsWorkflow](/references/medusa-workflows/updatePromotionsWorkflow) to update `custom_name` when the promotion is updated.
The `updatePromotionsWorkflow` is executed by the [Update Promotion API route](!api!/admin#promotions_postpromotionsid), which accepts the `additional_data` parameter to pass custom data to the hook.
### Add custom_name to Additional Data Validation
To allow passing `custom_name` in the `additional_data` parameter of the update promotion route, add in `src/api/middlewares.ts` a new route middleware configuration object:
```ts title="src/api/middlewares.ts"
import { defineMiddlewares } from "@medusajs/framework/http"
import { z } from "zod"
export default defineMiddlewares({
routes: [
// ...
{
method: "POST",
matcher: "/admin/promotions/:id",
additionalDataValidator: {
custom_name: z.string().nullish(),
},
},
],
})
```
The validation schema is the similar to that of the Create Promotion API route, except you can pass a `null` value for `custom_name` to remove or unset the `custom_name`'s value.
### Create Workflow to Update Custom Record
Next, you'll create a workflow that creates, updates, or deletes `Custom` records based on the provided `additional_data` parameter:
1. If `additional_data.custom_name` is set and it's `null`, the `Custom` record linked to the promotion is deleted.
2. If `additional_data.custom_name` is set and the promotion doesn't have a linked `Custom` record, a new record is created and linked to the promotion.
3. If `additional_data.custom_name` is set and the promotion has a linked `Custom` record, the `custom_name` property of the `Custom` record is updated.
Start by creating the step that updates a `Custom` record. Create the file `src/workflows/update-custom-from-promotion/steps/update-custom.ts` with the following content:
```ts title="src/workflows/update-custom-from-promotion/steps/update-custom.ts"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { HELLO_MODULE } from "../../../modules/hello"
import HelloModuleService from "../../../modules/hello/service"
type UpdateCustomStepInput = {
id: string
custom_name: string
}
export const updateCustomStep = createStep(
"update-custom",
async ({ id, custom_name }: UpdateCustomStepInput, { container }) => {
const helloModuleService: HelloModuleService = container.resolve(
HELLO_MODULE
)
const prevData = await helloModuleService.retrieveCustom(id)
const custom = await helloModuleService.updateCustoms({
id,
custom_name,
})
return new StepResponse(custom, prevData)
},
async (prevData, { container }) => {
const helloModuleService: HelloModuleService = container.resolve(
HELLO_MODULE
)
await helloModuleService.updateCustoms(prevData)
}
)
```
In this step, you update a `Custom` record. In the compensation function, you revert the update.
Next, you'll create the step that deletes a `Custom` record. Create the file `src/workflows/update-custom-from-promotion/steps/delete-custom.ts` with the following content:
```ts title="src/workflows/update-custom-from-promotion/steps/delete-custom.ts" collapsibleLines="1-6" expandButtonLabel="Show Imports"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { Custom } from "../../../modules/hello/models/custom"
import { InferTypeOf } from "@medusajs/framework/types"
import HelloModuleService from "../../../modules/hello/service"
import { HELLO_MODULE } from "../../../modules/hello"
type DeleteCustomStepInput = {
custom: InferTypeOf<typeof Custom>
}
export const deleteCustomStep = createStep(
"delete-custom",
async ({ custom }: DeleteCustomStepInput, { container }) => {
const helloModuleService: HelloModuleService = container.resolve(
HELLO_MODULE
)
await helloModuleService.deleteCustoms(custom.id)
return new StepResponse(custom, custom)
},
async (custom, { container }) => {
const helloModuleService: HelloModuleService = container.resolve(
HELLO_MODULE
)
await helloModuleService.createCustoms(custom)
}
)
```
In this step, you delete a `Custom` record. In the compensation function, you create it again.
Finally, you'll create the workflow. Create the file `src/workflows/update-custom-from-promotion/index.ts` with the following content:
```ts title="src/workflows/update-custom-from-promotion/index.ts" collapsibleLines="1-9" expandButtonLabel="Show Imports"
import { PromotionDTO } from "@medusajs/framework/types"
import { createWorkflow, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { createRemoteLinkStep, dismissRemoteLinkStep, useQueryGraphStep } from "@medusajs/medusa/core-flows"
import { createCustomStep } from "../create-custom-from-cart/steps/create-custom"
import { Modules } from "@medusajs/framework/utils"
import { HELLO_MODULE } from "../../modules/hello"
import { deleteCustomStep } from "./steps/delete-custom"
import { updateCustomStep } from "./steps/update-custom"
export type UpdateCustomFromPromotionStepInput = {
promotion: PromotionDTO
additional_data?: {
custom_name?: string | null
}
}
export const updateCustomFromPromotionWorkflow = createWorkflow(
"update-custom-from-promotion",
(input: UpdateCustomFromPromotionStepInput) => {
const { data: promotions } = useQueryGraphStep({
entity: "promotion",
fields: ["custom.*"],
filters: {
id: input.promotion.id,
},
})
// TODO create, update, or delete Custom record
}
)
```
The workflow accepts the same input as the `promotionsUpdated` workflow hook handler would.
In the workflow, you retrieve the promotion's linked `Custom` record using Query.
Next, replace the `TODO` with the following:
```ts title="src/workflows/update-custom-from-promotion/index.ts"
const created = when(
"create-promotion-custom-link",
{
input,
promotions,
}, (data) =>
!data.promotions[0].custom &&
data.input.additional_data?.custom_name?.length > 0
)
.then(() => {
const custom = createCustomStep({
custom_name: input.additional_data.custom_name,
})
createRemoteLinkStep([{
[Modules.PROMOTION]: {
promotion_id: input.promotion.id,
},
[HELLO_MODULE]: {
custom_id: custom.id,
},
}])
return custom
})
// TODO update, or delete Custom record
```
Using `when-then`, you check if the promotion doesn't have a linked `Custom` record and the `custom_name` property is set. If so, you create a `Custom` record and link it to the promotion.
To create the `Custom` record, you use the `createCustomStep` you created in an earlier section.
Next, replace the new `TODO` with the following:
```ts title="src/workflows/update-custom-from-promotion/index.ts"
const deleted = when(
"delete-promotion-custom-link",
{
input,
promotions,
}, (data) =>
data.promotions[0].custom && (
data.input.additional_data?.custom_name === null ||
data.input.additional_data?.custom_name.length === 0
)
)
.then(() => {
deleteCustomStep({
custom: promotions[0].custom,
})
dismissRemoteLinkStep({
[HELLO_MODULE]: {
custom_id: promotions[0].custom.id,
},
})
return promotions[0].custom.id
})
// TODO delete Custom record
```
Using `when-then`, you check if the promotion has a linked `Custom` record and `custom_name` is `null` or an empty string. If so, you delete the linked `Custom` record and dismiss its links.
Finally, replace the new `TODO` with the following:
```ts title="src/workflows/update-custom-from-promotion/index.ts"
const updated = when({
input,
promotions,
}, (data) => data.promotions[0].custom && data.input.additional_data?.custom_name?.length > 0)
.then(() => {
return updateCustomStep({
id: promotions[0].custom.id,
custom_name: input.additional_data.custom_name,
})
})
return new WorkflowResponse({
created,
updated,
deleted,
})
```
Using `when-then`, you check if the promotion has a linked `Custom` record and `custom_name` is passed in the `additional_data`. If so, you update the linked `Custom` record.
You return in the workflow response the created, updated, and deleted `Custom` record.
### Consume promotionsUpdated Workflow Hook
You can now consume the `promotionsUpdated` and execute the workflow you created.
Create the file `src/workflows/hooks/promotion-updated.ts` with the following content:
```ts title="src/workflows/hooks/promotion-updated.ts"
import { updatePromotionsWorkflow } from "@medusajs/medusa/core-flows"
import {
UpdateCustomFromPromotionStepInput,
updateCustomFromPromotionWorkflow,
} from "../update-custom-from-promotion"
updatePromotionsWorkflow.hooks.promotionsUpdated(
async ({ promotions, additional_data }, { container }) => {
const workflow = updateCustomFromPromotionWorkflow(container)
for (const promotion of promotions) {
await workflow.run({
input: {
promotion,
additional_data,
} as UpdateCustomFromPromotionStepInput,
})
}
}
)
```
In the workflow hook handler, you execute the workflow, passing it the hook's input.
### Test it Out
To test it out, send a `POST` request to `/admin/promotions/:id` to update a promotion, passing `custom_name` in `additional_data`:
```bash
curl -X POST 'localhost:9000/admin/promotions/{promotion_id}?fields=+custom.*' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer {token}' \
--data '{
"additional_data": {
"custom_name": "test 2"
}
}'
```
Make sure to replace `{promotion_id}` with the promotion's ID, and `{token}` with the JWT token of an admin user.
The request will return the promotion's details with the updated `custom` linked record.