--- 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. Similar steps can be applied to the `Campaign` data model. ## Step 1: Define Custom Data Model Consider you have a Hello Module defined in the `/src/modules/hello` directory. If you don't have a module, follow [this guide](!docs!/learn/fundamentals/modules) to create one. 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. Learn more about data models in [this guide](!docs!/learn/fundamentals/modules#1-create-data-model). --- ## 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. Learn more about module links in [this guide](!docs!/learn/fundamentals/module-links). 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 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) Learn more about workflow hooks in [this guide](!docs!/learn/fundamentals/workflows/workflow-hooks). 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. Learn more about additional data validation in [this guide](!docs!/learn/fundamentals/api-routes/additional-data). ### 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. Learn more about compensation functions in [this guide](!docs!/learn/fundamentals/workflows/compensation-function). 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. 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). 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 } 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.