From 78f49286d0dba7a65b088a49ba5ec02705c76f19 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Thu, 11 Jul 2024 18:56:41 +0300 Subject: [PATCH] docs: updated marketplace recipe + added example (#8061) - Moved recipes into its own section of the learning resources - Updated the marketplace recipe overview - Added an example implementation of the marketplace recipe Closes DOCS-791 --- .../marketplace/examples/vendors/page.mdx | 1374 +++++++++++++++++ .../app/recipes/marketplace/page.mdx | 764 +-------- www/apps/resources/app/recipes/page.mdx | 11 + www/apps/resources/generated/files-map.mjs | 8 + www/apps/resources/generated/sidebar.mjs | 24 +- www/apps/resources/sidebar.mjs | 16 +- 6 files changed, 1467 insertions(+), 730 deletions(-) create mode 100644 www/apps/resources/app/recipes/marketplace/examples/vendors/page.mdx create mode 100644 www/apps/resources/app/recipes/page.mdx diff --git a/www/apps/resources/app/recipes/marketplace/examples/vendors/page.mdx b/www/apps/resources/app/recipes/marketplace/examples/vendors/page.mdx new file mode 100644 index 0000000000..15efaeaa17 --- /dev/null +++ b/www/apps/resources/app/recipes/marketplace/examples/vendors/page.mdx @@ -0,0 +1,1374 @@ +import { Github, PlaySolid } from "@medusajs/icons" + +export const metadata = { + title: `Marketplace Recipe: Vendors Example`, +} + +# {metadata.title} + +This document provides an example of implementing the marketplace recipe. + + + +You can implement the marketplace as you see fit for your use case. This is only an example of one way to implement it. + + + +## Features + +By following this example, you’ll have a marketplace with the following features: + +1. Multiple vendors, each having vendor admins. +2. Vendor admins manage the vendor’s products and orders. +3. On order creation, the order is split into multiple orders for each vendor. +4. All other commerce features that Medusa provides. + +, + showLinkIcon: false + }, + { + href: "https://res.cloudinary.com/dza7lstvk/raw/upload/v1720603521/OpenApi/Marketplace_OpenApi_n458oh.yml", + title: "OpenApi Specs for Postman", + text: "Imported this OpenApi Specs file into tools like Postman.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + + + +- [A new Medusa application installed.](!docs!#get-started) + + + +## Step 1: Create Marketplace Module + +The first step is to create a marketplace module that holds the data models for a vendor and an admin. + +Create the directory `src/modules/marketplace`. + +### Create Data Models + +Create the file `src/modules/marketplace/models/vendor.ts` with the following content: + +```ts title="src/modules/marketplace/models/vendor.ts" +import { model } from "@medusajs/utils" +import VendorAdmin from "./vendor-admin" + +const Vendor = model.define("vendor", { + id: model.id().primaryKey(), + handle: model.text(), + name: model.text(), + logo: model.text().nullable(), + admins: model.hasMany(() => VendorAdmin) +}) + +export default Vendor +``` + +This creates a `Vendor` data model, which represents a business that sells its products in the marketplace. + +Notice that the `Vendor` has many admins whose data model you’ll create next. + +Create the file `src/modules/marketplace/models/vendor-admin.ts` with the following content: + +```ts title="src/modules/marketplace/models/vendor-admin.ts" +import { model } from "@medusajs/utils" +import Vendor from "./vendor" + +const VendorAdmin = model.define("vendor_admin", { + id: model.id().primaryKey(), + first_name: model.text().nullable(), + last_name: model.text().nullable(), + email: model.text().unique(), + vendor: model.belongsTo(() => Vendor, { + mappedBy: "admins" + }) +}) + +export default VendorAdmin +``` + +This creates a `VendorAdmin` data model, which represents an admin of a vendor. + +### Generate Migrations + +To create tables for the data models in the database, you need to generate migrations for them. + +Create the file `src/modules/marketplace/migrations-config.ts` with the following content: + +```ts +import { defineMikroOrmCliConfig } from "@medusajs/utils" +import path from "path" +import Vendor from "./models/vendor" +import VendorAdmin from "./models/vendor-admin" + +export default defineMikroOrmCliConfig("marketplace", { + entities: [Vendor, VendorAdmin] as any[], + migrations: { + path: path.join(__dirname, "migrations"), + }, +}) +``` + +Then, run the following command to generate the migration: + +```bash +npx cross-env MIKRO_ORM_CLI=./src/modules/marketplace/migrations-config.ts mikro-orm migration:create +``` + +This generates a migration in the `src/modules/marketeplace/migrations` directory. + +### Create Main Module Service + +Next, create the main service of the module at `src/modules/marketplace/service.ts` with the following content: + +```ts title="src/modules/marketplace/service.ts" +import { MedusaService } from "@medusajs/utils" +import Vendor from "./models/vendor" +import VendorAdmin from "./models/vendor-admin" +import { CreateVendorData, VendorData } from "./types" + +class MarketplaceModuleService extends MedusaService({ + Vendor, + VendorAdmin +}) { +} + +export default MarketplaceModuleService +``` + +The service extends the [service factory](!docs!/advanced-development/modules/service-factory), which provides basic data-management features. + +### Create Module Definition + +After that, create the module definition at `src/modules/marketplace/index.ts` with the following content: + +```ts title="src/modules/marketplace/index.ts" +import { Module } from "@medusajs/utils" +import MarketplaceModuleService from "./service" + +export const MARKETPLACE_MODULE = "marketplaceModuleService" + +export default Module(MARKETPLACE_MODULE, { + service: MarketplaceModuleService +}) +``` + +### Add Module to Medusa Configuration + +Finally, add the module to the list of modules in `medusa-config.js`: + +```ts title="medusa-config.js" +import { MARKETPLACE_MODULE } from './src/modules/marketplace' + +// ... + +module.exports = defineConfig({ + // ... + modules: { + [MARKETPLACE_MODULE]: { + resolve: "./modules/marketplace", + definition: { + isQueryable: true + } + } + } +}) +``` + +### Further Reads + +- [How to Create a Module](!docs!/basics/modules-and-services) +- [How to Create Data Models](!docs!/basics/data-models) + +--- + +## Step 2: Define Links to Product and Order Data Models + +Each vendor has products and orders. So, in this step, you’ll define links between the `Vendor` data model and the `Product` and `Order` data models from the Product and Order modules, respectively. + + + +If your use case requires linking the vendor to other data models, such as `SalesChannel`, define those links in a similar manner. + + + +Create the file `src/links/vendor-product.ts` with the following content: + +```ts title="src/links/vendor-product.ts" +import { defineLink } from "@medusajs/utils" +import MarketplaceModule from "../modules/marketplace" +import ProductModule from "@medusajs/product" + +export default defineLink( + MarketplaceModule.linkable.vendor, + { + linkable: ProductModule.linkable.product, + isList: true + } +) +``` + +This adds a list link between the `Vendor` and `Product` data models, indicating that a vendor record can be linked to many product records. + +Then, create the file `src/links/vendor-order.ts` with the following content: + +```ts title="src/links/vendor-order.ts" +import { defineLink } from "@medusajs/utils" +import MarketplaceModule from "../modules/marketplace" +import OrderModule from "@medusajs/order" + +export default defineLink( + MarketplaceModule.linkable.vendor, + { + linkable: OrderModule.linkable.order, + isList: true + } +) +``` + +This adds a list link between the `Vendor` and `Order` data models, indicating that a vendor record can be linked to many order records. + +### Further Read + +- [How to Define Module Links](!docs!/advanced-development/modules/module-links) + +--- + +## Step 3: Run Migrations + +To reflect the module’s data models and the module links in the database, run the following command: + +```bash +npx medusa migrations run +``` + +--- + +## Step 4: Create Vendor Admin Workflow + +In this step, you’ll create the workflow used to create a vendor admin. + +The workflow’s steps are: + +1. Create the vendor admin using the Marketplace Module’s main service. +2. Create a `vendor` [actor type](../../../../commerce-modules/auth/auth-identity-and-actor-types/page.mdx) to authenticate the vendor admin using the Auth Module. + +First, create the file `src/workflows/marketplace/create-vendor-admin/steps/create-vendor-admin.ts` with the following content: + +```ts title="src/workflows/marketplace/create-vendor-admin/steps/create-vendor-admin.ts" +import { + createStep, + StepResponse, +} from "@medusajs/workflows-sdk" +import { CreateVendorAdminWorkflowInput } from ".." +import MarketplaceModuleService from "../../../../modules/marketplace/service" +import { MARKETPLACE_MODULE } from "../../../../modules/marketplace" + +const createVendorAdminStep = createStep( + "create-vendor-admin-step", + async ({ + admin: adminData, + }: Pick, + { container }) => { + const marketplaceModuleService: MarketplaceModuleService = + container.resolve(MARKETPLACE_MODULE) + + const vendorAdmin = await marketplaceModuleService.createVendorAdmins( + adminData + ) + + return new StepResponse(vendorAdmin) + } +) + +export default createVendorAdminStep +``` + +This is the first step that creates the vendor admin and returns it. + +Then, create the workflow at `src/workflows/marketplace/create-vendor-admin/index.ts` with the following content: + +export const vendorAdminWorkflowHighlights = [ + ["33", "setAuthAppMetadataStep", "Step is imported from `@medusajs/core-flows`."] +] + +```ts title="src/workflows/marketplace/create-vendor-admin/index.ts" highlights={vendorAdminWorkflowHighlights} +import { createWorkflow } from "@medusajs/workflows-sdk" +import { + setAuthAppMetadataStep, +} from "@medusajs/core-flows" +import createVendorAdminStep from "./steps/create-vendor-admin" + +export type CreateVendorAdminWorkflowInput = { + admin: { + email: string + first_name?: string + last_name?: string + vendor_id: string + } + authIdentityId: string +} + +type CreateVendorAdminWorkflowOutput = { + id: string + first_name: string + last_name: string + email: string +} + +const createVendorAdminWorkflow = createWorkflow< + CreateVendorAdminWorkflowInput, CreateVendorAdminWorkflowOutput +>( + "create-vendor-admin", + function (input) { + const vendorAdmin = createVendorAdminStep({ + admin: input.admin, + }) + + setAuthAppMetadataStep({ + authIdentityId: input.authIdentityId, + actorType: "vendor", + value: vendorAdmin.id, + }) + + return vendorAdmin + } +) + +export default createVendorAdminWorkflow +``` + +This runs the `createVendorAdminStep`, then the `setAuthAppMetadataStep` imported from `@medusajs/core-flows`, which creates the `vendor` actor type. + +The workflow returns the created vendor admin. + +### Further Read + +- [How to Create a Workflow](!docs!/basics/workflows) +- [What is an Actor Type](../../../../commerce-modules/auth/auth-identity-and-actor-types/page.mdx) +- [How to Create an Actor Type](../../../../commerce-modules/auth/create-actor-type/page.mdx) + +--- + +## Step 5: Create Vendor API Route + +In this step, you’ll create the API route that runs the workflow from the previous step. + +Start by creating the file `src/api/vendors/route.ts` with the following content: + +export const vendorRouteSchemaHighlights = [ + ["10", "schema", "Define the fields expected in the request body."], +] + +```ts title="src/api/vendors/route.ts" highlights={vendorRouteSchemaHighlights} +import { + AuthenticatedMedusaRequest, + MedusaResponse +} from "@medusajs/medusa" +import { MedusaError } from "@medusajs/utils" +import { z } from "zod" +import MarketplaceModuleService from "../../modules/marketplace/service"; +import createVendorAdminWorkflow from "../../workflows/marketplace/create-vendor-admin"; + +const schema = z.object({ + name: z.string(), + handle: z.string().optional(), + logo: z.string().optional(), + admin: z.object({ + email: z.string(), + first_name: z.string().optional(), + last_name: z.string().optional() + }).strict() +}).strict() + +type RequestBody = { + name: string, + handle?: string, + logo?: string, + admin: { + email: string, + first_name?: string, + last_name?: string + } +} + +``` + +This defines the schema to be accepted in the request body. + +Then, add the route handler to the same file: + +export const vendorRouteHighlights = [ + ["14", "parse", "Validate the request body and, if valid, retrieve it as an object."], + ["20", "createVendors", "Create the vendor using the Marketplace Module's main service."], + ["23", "createVendorAdminWorkflow", "Execute the workflow created in the first step."], +] + +```ts title="src/api/vendors/route.ts" highlights={vendorRouteHighlights} +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + // If `actor_id` is present, the request carries + // authentication for an existing vendor admin + if (req.auth_context?.actor_id) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Request already authenticated as a vendor." + ) + } + + const { admin, ...vendorData } = schema.parse(req.body) as RequestBody + + const marketplaceModuleService: MarketplaceModuleService = req.scope + .resolve("marketplaceModuleService") + + // create vendor + let vendor = await marketplaceModuleService.createVendors([vendorData]) + + // create vendor admin + await createVendorAdminWorkflow(req.scope) + .run({ + input: { + admin: { + ...admin, + vendor_id: vendor[0].id + }, + authIdentityId: req.auth_context.auth_identity_id, + } + }) + + // retrieve vendor again with admins + vendor = await marketplaceModuleService.retrieveVendor(vendor[0].id, { + relations: ["admins"] + }) + + res.json({ + vendor, + }) +} +``` + +This API route expects the request header to contain a new vendor admin’s authentication JWT token. + +The route handler creates a vendor using the Marketplace Module’s main service and then uses the `createVendorAdminWorkflow` to create an admin for the vendor. + +Next, create the file `src/api/middlewares.ts` with the following content: + +```ts title="src/api/middlewares.ts" +import { MiddlewaresConfig, authenticate } from "@medusajs/medusa" + +export const config: MiddlewaresConfig = { + routes: [ + { + matcher: "/vendors", + method: "POST", + middlewares: [ + authenticate("vendor", ["session", "bearer"], { + allowUnregistered: true, + }), + ], + }, + { + matcher: "/vendors/*", + middlewares: [ + authenticate("vendor", ["session", "bearer"]), + ] + }, + ], +} +``` + +This applies two middlewares: + +1. On the `/vendors` POST API route; it requires authentication but allows unregistered users. +2. On the `/vendors/*` API routes, which you’ll implement in upcoming sections; it requires an authenticated vendor admin. + +### Test it Out + +To test out the above API route: + +1. Start the Medusa application: + +```bash npm2yarn +npm run dev +``` + +2. Retrieve a JWT token from the `/auth/vendor/emailpass` API route: + +```bash apiTesting testApiUrl="http://localhost:9000/auth/vendor/emailpass" testApiMethod="POST" testBodyParams={{ "email": "admin@medusa-test.com", "password": "supersecret" }} +curl -X POST 'http://localhost:9000/auth/vendor/emailpass' \ +-H 'Content-Type: application/json' \ +--data-raw '{ + "email": "admin@medusa-test.com", + "password": "supersecret" +}' +``` + + + +This route is available because you created the `vendor` actor type previously. + + + +3. Send a request to the `/vendors` API route, passing the token retrieved from the previous response in the request header: + +```bash +curl -X POST 'http://localhost:9000/vendors' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer {token}' \ +--data-raw '{ + "name": "Acme", + "admin": { + "email": "admin@medusa-test.com", + "first_name": "Admin", + "last_name": "Acme" + } +}' +``` + +This returns the created vendor and admin. + +4. Retrieve an authenticated token of the vendor admin by sending another request to the `/auth/vendor/emailpass` API route: + +```bash apiTesting testApiUrl="http://localhost:9000/auth/vendor/emailpass" testApiMethod="POST" testBodyParams={{ "email": "admin@medusa-test.com", "password": "supersecret" }} +curl -X POST 'http://localhost:9000/auth/vendor/emailpass' \ +-H 'Content-Type: application/json' \ +--data-raw '{ + "email": "admin@medusa-test.com", + "password": "supersecret" +}' +``` + +Use this token in the header of later requests that require authentication. + +### Further Reads + +- [How to Create an API route](!docs!/basics/api-routes) +- [How to Create a Middleware](!docs!/advanced-development/api-routes/middlewares) +- [Learn more about the /auth route](../../../../commerce-modules/auth/authentication-route/page.mdx) + +--- + +## Step 6: Add Product API Routes + +In this section, you’ll add two API routes: one to retrieve the vendor’s products and one to create a product. + +To create the API route that retrieves the vendor’s products, create the file `src/api/vendors/products/route.ts` with the following content: + +export const retrieveProductHighlights = [ + ["16", "retrieveVendorAdmin", "Retrive the vendor admin to retrieve its vendor's ID."], + ["33", "remoteQuery", "Retrieve the vendor's products using remote query."] +] + +```ts title="src/api/vendors/products/route.ts" highlights={retrieveProductHighlights} +import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/medusa"; +import { + remoteQueryObjectFromString, +} from "@medusajs/utils" +import MarketplaceModuleService from "../../../modules/marketplace/service"; +import { MARKETPLACE_MODULE } from "../../../modules/marketplace"; + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve("remoteQuery") + const marketplaceModuleService: MarketplaceModuleService = + req.scope.resolve(MARKETPLACE_MODULE) + + const vendorAdmin = await marketplaceModuleService.retrieveVendorAdmin( + req.auth_context.actor_id, + { + relations: ["vendor"] + } + ) + + const query = remoteQueryObjectFromString({ + entryPoint: "vendor", + fields: ["products.*"], + variables: { + filters: { + id: [vendorAdmin.vendor.id] + } + } + }) + + const result = await remoteQuery(query) + + res.json({ + products: result[0].products + }) +} +``` + +This adds a `GET` API route at `/vendors/products` that, using the remote query, retrieves the list of products of the vendor and returns them in the response. + +To add the create product API route, add to the same file the following: + +export const createProducts1Highlights = [ + ["15", "CreateProductWorkflowInputDTO", "Accept the same request body as Medusa's Create Product API route"], + ["32", "retrieveVendorAdmin", "Retrive the vendor admin to retrieve its vendor's ID."], +] + +```ts title="src/api/vendors/products/route.ts" highlights={createProducts1Highlights} +// other imports... +import { createProductsWorkflow } from "@medusajs/core-flows" +import { + CreateProductWorkflowInputDTO, + IProductModuleService, + ISalesChannelModuleService +} from "@medusajs/types" +import { + Modules, + ModuleRegistrationName +} from "@medusajs/utils" + +// GET method... + +type RequestType = CreateProductWorkflowInputDTO + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteLink = req.scope.resolve("remoteLink") + const marketplaceModuleService: MarketplaceModuleService = + req.scope.resolve(MARKETPLACE_MODULE) + const productModuleService: IProductModuleService = req.scope + .resolve(ModuleRegistrationName.PRODUCT) + const salesChannelModuleService: ISalesChannelModuleService = req.scope + .resolve(ModuleRegistrationName.SALES_CHANNEL) + // Retrieve default sales channel to make the product available in. + // Alternatively, you can link sales channels to vendors and allow vendors + // to manage sales channels + const salesChannels = await salesChannelModuleService.listSalesChannels() + const vendorAdmin = await marketplaceModuleService.retrieveVendorAdmin( + req.auth_context.actor_id, + { + relations: ["vendor"] + } + ) + + // TODO create and link product +} +``` + +This adds a `POST` API route at `/vendors/products`. It resolves the necessary modules' main services, and retrieves the sales channels and vendor admin. + +In the place of the `TODO`, add the following: + +export const createProducts2Highlights = [ + ["1", "createProductsWorkflow", "Use Medusa's workflow to create a product."], + ["12", "create", "Create a link between the created product and the vendor."] +] + +```ts title="src/api/vendors/products/route.ts" highlights={createProducts2Highlights} +const { result } = await createProductsWorkflow(req.scope) + .run({ + input: { + products: [{ + ...req.body, + sales_channels: salesChannels + }] + } + }) + +// link product to vendor +await remoteLink.create({ + [MARKETPLACE_MODULE]: { + vendor_id: vendorAdmin.vendor.id + }, + [Modules.PRODUCT]: { + product_id: result[0].id + } +}) + +// retrieve product again +const product = await productModuleService.retrieveProduct( + result[0].id +) + +res.json({ + product +}) +``` + +This creates a product, links it to the vendor, and returns the product in the response. + + + +In the route handler, you add the product to the default sales channel. You can, instead, link sales channels with vendors similar to the steps explained in step 2. + + + +Finally, apply a middleware on the create products route to validate the request body before executing the route handler: + +```ts +import { MiddlewaresConfig, authenticate } from "@medusajs/medusa" +import { validateAndTransformBody } from "@medusajs/medusa/dist/api/utils/validate-body" +import { AdminCreateProduct } from "@medusajs/medusa/dist/api/admin/products/validators" + +export const config: MiddlewaresConfig = { + routes: [ + // ... + { + matcher: "/vendors/products", + method: "POST", + middlewares: [ + authenticate("vendor", ["session", "bearer"]), + validateAndTransformBody(AdminCreateProduct), + ] + } + ], +} +``` + +### Test it Out + +To test out the new API routes: + +1. Send a `POST` request to `/vendors/products` to create a product: + +```bash +curl -X POST 'http://localhost:9000/vendors/products' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer {token}' \ +--data '{ + "title": "T-Shirt", + "status": "published", + "options": [ + { + "title": "Color", + "values": ["Blue"] + } + ], + "variants": [ + { + "title": "T-Shirt", + "prices": [ + { + "currency_code": "eur", + "amount": 10 + } + ], + "manage_inventory": false, + "options": { + "Color": "Blue" + } + } + ] +}' +``` + +2. Send a `GET` request to `/vendors/products` to retrieve the vendor’s products: + +```bash +curl 'http://localhost:9000/vendors/products' \ +-H 'Authorization: Bearer {token}' +``` + +### Further Reads + +- [How to use the Remote Query](!docs!/advanced-development/modules/remote-query) +- [How to use the Remote Link](!docs!/advanced-development/modules/remote-link) + +--- + +## Step 7: Create Vendor Order Workflow + +In this step, you’ll create a workflow that’s executed when the customer places an order. It has the following steps: + +```mermaid +graph TD + retrieveCartStep --> createParentOrderStep + createParentOrderStep --> groupVendorItemsStep + groupVendorItemsStep --> createVendorOrdersStep +``` + +1. Retrieve the customer’s cart using its ID. +2. Create a parent order for the cart and its items. +3. Group the cart items by their product’s associated vendor. +4. For each vendor, create a child order with the cart items of their products. + +### retrieveCartStep + +Start by creating the first step in the file `src/workflows/marketplace/create-vendor-orders/steps/retrieve-cart.ts`: + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/retrieve-cart.ts" +import { + createStep, + StepResponse, +} from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/utils" +import { ICartModuleService } from "@medusajs/types" + +type StepInput = { + cart_id: string +} + +const retrieveCartStep = createStep( + "retrieve-cart", + async ({ cart_id }: StepInput, { container }) => { + const cartModuleService: ICartModuleService = container + .resolve(ModuleRegistrationName.CART) + + const cart = await cartModuleService.retrieveCart(cart_id, { + relations: ["items"] + }) + + return new StepResponse({ + cart + }) + } +) + +export default retrieveCartStep +``` + +This step retrieves the cart by its ID using the Cart Module’s main service and returns it. + +### createParentOrderStep + +Then, create the second step in the file `src/workflows/marketplace/create-vendor-orders/steps/create-parent-order.ts`: + +export const parentOrderHighlights = [ + ["14", "completeCartWorkflow", "Use Medusa's workflow to complete the cart and create an order."] +] + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/create-parent-order.ts" highlights={parentOrderHighlights} +import { + createStep, + StepResponse, +} from "@medusajs/workflows-sdk" +import { completeCartWorkflow } from "@medusajs/core-flows" + +type StepInput = { + cart_id: string +} + +const createParentOrderStep = createStep( + "create-parent-order", + async ({ cart_id }: StepInput, { container }) => { + const { result } = await completeCartWorkflow(container) + .run({ + input: { + id: cart_id + } + }) + + return new StepResponse({ + order: result + }) + } +) + +export default createParentOrderStep +``` + +This step uses the `completeCartWorkflow` implemented by Medusa to create a parent order and return it. + +### groupVendorItemsStep + +Next, create the third step in the file `src/workflows/marketplace/create-vendor-orders/steps/group-vendor-items.ts`: + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/group-vendor-items.ts" +import { + createStep, + StepResponse, +} from "@medusajs/workflows-sdk" +import { CartDTO, CartLineItemDTO } from "@medusajs/types" +import { remoteQueryObjectFromString } from "@medusajs/utils" + +type StepInput = { + cart: CartDTO +} + +const groupVendorItemsStep = createStep( + "group-vendor-items", + async ({ cart }: StepInput, { container }) => { + const remoteQuery = container.resolve("remoteQuery") + + const vendorsItems: Record = {} + + await Promise.all(cart.items?.map(async (item) => { + const query = remoteQueryObjectFromString({ + entryPoint: "product", + fields: ["vendor.*"], + variables: { + filters: { + id: [item.product_id] + } + } + }) + + const result = await remoteQuery(query) + + const vendorId = result[0].vendor?.id + + if (!vendorId) { + return + } + vendorsItems[vendorId] = [ + ...(vendorsItems[vendorId] || []), + item + ] + })) + + return new StepResponse({ + vendorsItems + }) + } +) + +export default groupVendorItemsStep +``` + +This step groups the items by the vendor associated with the product into an object and returns the object. + +### createVendorOrdersStep + +Lastly, create the fourth step in the file `src/workflows/marketplace/create-vendor-orders/steps/create-vendor-orders.ts`: + +export const vendorOrder1Highlights = [ + ["28", "", "If the `vendorItems` object is empty, return."], +] + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/create-vendor-orders.ts" collapsibleLines="1-15" expandMoreLabel="Show Imports" highlights={vendorOrder1Highlights} +import { + createStep, + StepResponse, +} from "@medusajs/workflows-sdk" +import { + CartLineItemDTO, + OrderDTO, +} from "@medusajs/types" +import { Modules } from "@medusajs/utils" +import { createOrdersWorkflow } from "@medusajs/core-flows" +import MarketplaceModuleService from "../../../../modules/marketplace/service" +import { MARKETPLACE_MODULE } from "../../../../modules/marketplace" +import { VendorData } from "../../../../modules/marketplace/types" + +export type VendorOrder = (OrderDTO & { + vendor: VendorData +}) + +type StepInput = { + parentOrder: OrderDTO + vendorsItems: Record +} + +const createVendorOrdersStep = createStep( + "create-vendor-orders", + async ({ vendorsItems, parentOrder }: StepInput, { container }) => { + const vendorIds = Object.keys(vendorsItems) + if (vendorIds.length === 0) { + return new StepResponse({ + orders: [] + }) + } + const remoteLink = container.resolve("remoteLink") + const marketplaceModuleService: MarketplaceModuleService = + container.resolve(MARKETPLACE_MODULE) + const isOnlyOneVendorOrder = vendorIds.length === 1 + + // TODO handle creating child orders + } +) + +export default createVendorOrdersStep +``` + +This creates a step that receives the grouped vendor items and the parent order. For now, it only checks if there are any items in `vendorItems` before returning. + +Replace the `TODO` with the following: + +export const vendorOrder2Highlights = [ + ["1", "isOnlyOneVendorOrder", "If the order has items for one vendor only, the parent order is linked to the vendor."], +] + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/create-vendor-orders.ts" highlights={vendorOrder2Highlights} +if (isOnlyOneVendorOrder) { + const vendorId = vendorIds[0] + const vendor = await marketplaceModuleService.retrieveVendor( + vendorId + ) + // link the parent order to the vendor instead of creating child orders + await remoteLink.create({ + [MARKETPLACE_MODULE]: { + vendor_id: vendorId + }, + [Modules.ORDER]: { + order_id: parentOrder.id + } + }) + + return new StepResponse({ + orders: [ + { + ...parentOrder, + vendor + } + ] + }) +} + +// TODO create multiple child orders +``` + +In the above snippet, if there's only one vendor in the group, the parent order is returned instead of creating child orders. + +Replace the new `TODO` with the following snippet: + +export const vendorOrder3Highlights = [ + ["4", "map", "Loop over the vendor IDs and create a child order with only their items."], + ["15", "parent_order_id", "Set the ID of the parent order in the `metadata` property of the child order."], + ["52", "create", "Create a link between the vendor and the child order."] +] + +```ts title="src/workflows/marketplace/create-vendor-orders/steps/create-vendor-orders.ts" highlights={vendorOrder3Highlights} +const createdOrders: VendorOrder[] = [] + +await Promise.all( + vendorIds.map(async (vendorId) => { + const items = vendorsItems[vendorId] + const vendor = await marketplaceModuleService.retrieveVendor( + vendorId + ) + // create an child order + const { result: childOrder } = await createOrdersWorkflow(container) + .run({ + input: { + items, + metadata: { + parent_order_id: parentOrder.id + }, + // use info from parent + region_id: parentOrder.region_id, + customer_id: parentOrder.customer_id, + sales_channel_id: parentOrder.sales_channel_id, + email: parentOrder.email, + currency_code: parentOrder.currency_code, + shipping_address_id: parentOrder.shipping_address?.id, + billing_address_id: parentOrder.billing_address?.id, + // A better solution would be to have shipping methods for each + // item/vendor. This requires changes in the storefront to commodate that + // and passing the item/vendor ID in the `data` property, for example. + // For simplicity here we just use the same shipping method. + shipping_methods: parentOrder.shipping_methods.map((shippingMethod) => ({ + name: shippingMethod.name, + amount: shippingMethod.amount, + shipping_option_id: shippingMethod.shipping_option_id, + data: shippingMethod.data, + tax_lines: shippingMethod.tax_lines.map((taxLine) => ({ + code: taxLine.code, + rate: taxLine.rate, + provider_id: taxLine.provider_id, + tax_rate_id: taxLine.tax_rate_id, + description: taxLine.description + })), + adjustments: shippingMethod.adjustments.map((adjustment) => ({ + code: adjustment.code, + amount: adjustment.amount, + description: adjustment.description, + promotion_id: adjustment.promotion_id, + provider_id: adjustment.provider_id + })) + })), + } + }) + + await remoteLink.create({ + [MARKETPLACE_MODULE]: { + vendor_id: vendorId + }, + [Modules.ORDER]: { + order_id: childOrder.id + } + }) + + createdOrders.push({ + ...childOrder, + vendor + }) + }) +) + +return new StepResponse({ + orders: createdOrders +}) +``` + +In this snippet, you create multiple child orders for each vendor and link the orders to the vendors. + +The created orders are returned in the response. + + + +When creating the child orders, the shipping method of the parent is used as-is for simplicity. A better practice would be to allow the customer to choose different shipping methods for each vendor’s items and then store those details in the `data` property of the shipping method. + + + +### Create Workflow + +Finally, create the workflow at the file `src/workflows/marketplace/create-vendor-orders/index.ts`: + +```ts title="src/workflows/marketplace/create-vendor-orders/index.ts" collapsibleLines="1-7" expandMoreLabel="Show Imports" +import { createWorkflow, transform } from "@medusajs/workflows-sdk" +import { OrderDTO } from "@medusajs/types" +import retrieveCartStep from "./steps/retrieve-cart" +import groupVendorItemsStep from "./steps/group-vendor-items" +import createParentOrderStep from "./steps/create-parent-order" +import createVendorOrdersStep, { VendorOrder } from "./steps/create-vendor-orders" + +type WorkflowInput = { + cart_id: string +} + +type WorkflowOutput = { + parent_order: OrderDTO + vendor_orders: VendorOrder[] +} + +const createVendorOrdersWorkflow = createWorkflow< + WorkflowInput, WorkflowOutput +>( + "create-vendor-order", + (input) => { + const { cart } = retrieveCartStep(input) + + const { order } = createParentOrderStep(input) + + const { vendorsItems } = groupVendorItemsStep( + transform({ + cart + }, + (data) => data + ) + ) + + const { orders } = createVendorOrdersStep( + transform({ + order, + vendorsItems + }, + (data) => { + return { + parentOrder: data.order, + vendorsItems: data.vendorsItems + } + } + ) + ) + + return transform({ + order, + orders + }, + (data) => ({ + parent_order: data.order, + vendor_orders: data.orders + }) + ) + } +) + +export default createVendorOrdersWorkflow +``` + +This workflow runs the steps and returns the parent and vendor orders. + +### Create API Route Executing the Workflow + +You’ll now create the API route that executes the workflow. + +Create the file `src/api/store/carts/[id]/complete/route.ts` with the following content: + +```ts title="src/api/store/carts/[id]/complete/route.ts" +import { + AuthenticatedMedusaRequest, + MedusaResponse +} from "@medusajs/medusa"; +import createVendorOrdersWorkflow from "../../../../../workflows/marketplace/create-vendor-orders"; + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const cartId = req.params.id + + const { result } = await createVendorOrdersWorkflow(req.scope) + .run({ + input: { + cart_id: cartId + } + }) + + res.json({ + type: "order", + order: result.parent_order + }) +} +``` + +This API route replaces the [existing API route in the Medusa application](!api!/store#carts_postcartsidcomplete) used to complete the cart and place an order. It executes the workflow and returns the parent order in the response. + +### Test it Out + +To test this out, it’s recommended to install the [Next.js Starter storefront](../../../../nextjs-starter/page.mdx). Then, add products to the cart and place an order. You can also try placing an order with products from different vendors. + +--- + +## Step 8: Retrieve Vendor Orders API Route + +In this step, you’ll create an API route that retrieves a vendor’s orders. + +Create the file `src/api/vendors/orders/route.ts` with the following content: + +export const getOrderHighlights = [ + ["15", "retrieveVendorAdmin", "Retrive the vendor admin to retrieve its vendor's ID."], + ["32", "remoteQuery", "Retrieve the orders of the vendor."], + ["34", "getOrdersListWorkflow", "Use Medusa's workflow to retrieve the list of orders."], +] + +```ts title="src/api/vendors/orders/route.ts" highlights={getOrderHighlights} collapsibleLines="1-6" expandMoreLabel="Show Imports" +import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/medusa"; +import { remoteQueryObjectFromString } from "@medusajs/utils" +import { getOrdersListWorkflow } from "@medusajs/core-flows" +import MarketplaceModuleService from "../../../modules/marketplace/service"; +import { MARKETPLACE_MODULE } from "../../../modules/marketplace"; + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve("remoteQuery") + const marketplaceModuleService: MarketplaceModuleService = + req.scope.resolve(MARKETPLACE_MODULE) + + const vendorAdmin = await marketplaceModuleService.retrieveVendorAdmin( + req.auth_context.actor_id, + { + relations: ["vendor"] + } + ) + + const query = remoteQueryObjectFromString({ + entryPoint: "vendor", + fields: ["orders.*"], + variables: { + filters: { + id: [vendorAdmin.vendor.id] + } + } + }) + + const result = await remoteQuery(query) + + const { result: orders } = await getOrdersListWorkflow(req.scope) + .run({ + input: { + fields: [ + "metadata", + "total", + "subtotal", + "shipping_total", + "tax_total", + "items.*", + "items.tax_lines", + "items.adjustments", + "items.variant", + "items.variant.product", + "items.detail", + "shipping_methods", + "payment_collections", + "fulfillments", + ], + variables: { + filters: { + id: result[0].orders.map((order) => order.id) + } + } + } + }) + + res.json({ + orders + }) +} +``` + +This adds a `GET` API route at `/vendors/orders` that returns a vendor’s list of orders. + +### Test it Out + +To test it out, send a `GET` request to `/vendors/orders` : + +```bash +curl 'http://localhost:9000/vendors/orders' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace the `{token}` with the vendor admin’s token. + +You’ll receive in the response the orders of the vendor created in the previous step. + +--- + +## Next Steps + +The next steps of this example depend on your use case. This section provides some insight into implementing them. + +### Use Existing Features + +You can use [Medusa’s admin API routes for orders](!api!/admin) to allow vendors to manage their orders. This requires you to add the following middleware in `src/api/middlewares.ts`: + +```ts title="src/api/middlewares.ts" +export const config: MiddlewaresConfig = { + routes: [ + // ... + { + matcher: "/admin/orders/*", + method: "POST", + middlewares: [ + authenticate("vendor", ["session", "bearer"]), + ], + }, + ] +} +``` + +You can also re-create or override any of the existing API routes, similar to what you did with the complete cart API route. + +### Link Other Data Models to Vendors + +Similar to linking an order and a product to a vendor, you can link other data models to vendors as well. + +For example, you can link sales channels to vendors or other settings. + + + +[Learn more about module links](!docs!/advanced-development/modules/module-links). + + + +### Storefront Development + +Medusa provides a Next.js Starter storefront that you can customize to your use case. + +You can also create a custom storefront. Check out the [Storefront Development](../../../../storefront-development/page.mdx) section to learn how to create a storefront. + +### Admin Development + +The Medusa Admin is extendable, allowing you to add widgets to existing pages or create new pages. Learn more about it in [this documentation](!docs!/advanced-development/admin). + +If your use case requires bigger customizations to the admin, such as showing different products and orders based on the logged-in vendor, use the [admin API routes](!api!/admin) to build a custom admin. diff --git a/www/apps/resources/app/recipes/marketplace/page.mdx b/www/apps/resources/app/recipes/marketplace/page.mdx index 951a5ad2ef..f5b72568cd 100644 --- a/www/apps/resources/app/recipes/marketplace/page.mdx +++ b/www/apps/resources/app/recipes/marketplace/page.mdx @@ -8,17 +8,11 @@ export const metadata = { This recipe provides the general steps to implement a marketplace in your Medusa application. - - -This recipe is a work in progress, as some features are not ready yet in Medusa V2. - - - ## Overview A marketplace is an online commerce store that allows different vendors to sell their products within the same commerce system. Customers can purchase products from any of these vendors, and vendors can manage their orders separately. -In Medusa, you can create a Marketplace Module that establishes the relations between a store, users, products, and orders. Using these relations, you can implement different stores for different users, with products and orders associated with that store. +In Medusa, you can create a Marketplace Module that implements custom data models, such as vendors or sellers, and link those data models to existing ones such as products and orders. You also expose custom features using API routes, and implement complex flows using workflows. @@ -28,783 +22,107 @@ In Medusa, you can create a Marketplace Module that establishes the relations be --- -## Create Relationships Between Data Models +## Create Custom Module with Data Models -In a marketplace, an admin user has a store where they manage their products and orders, among other details. +In a marketplace, a business or a vendor has a user, and they can use that user to authenticate and manage the vendor's data. -Create a Marketplace Module that holds and manages these relationships. - - - -Module Relationships is coming soon. - - +You can create a marketplace module that implements data models for vendors, their admins, anything else that fits your use case. , showLinkIcon: false }, { - href: "!docs!/advanced-development/modules/module-relationships", - title: "Module Relationships", - text: "Create relationships between modules.", + href: "!docs!/basics/data-models", + title: "Create Data Models", + text: "Create data models in the module.", startIcon: , showLinkIcon: false }, ]} /> -
- - In this section, you’ll create a Marketplace Module with the necessary relationships and functionalities in the main service. - - Start by creating the directory `src/modules/marketplace`. - - Then, create the file `src/modules/marketplace/models/store-user.ts` with the following content: - - ```ts title="src/modules/marketplace/models/store-user.ts" - import { model } from "@medusajs/utils" - - const StoreUser = model.define("store_user", { - id: model.id().primaryKey(), - store_id: model.text(), - user_id: model.text(), - }) - - export default StoreUser - ``` - - This creates a `StoreUser` data model with the `store_id` and `user_id` properties. - - {/* These properties will be used later to establish relationships to the Store and User modules. */} - - Next, create the file `src/modules/marketplace/models/store-product.ts` with the following content: - - ```ts title="src/modules/marketplace/models/store-product.ts" - import { model } from "@medusajs/utils" - - const StoreProduct = model.define("store_product", { - id: model.id().primaryKey(), - store_id: model.text(), - product_id: model.text(), - }) - - export default StoreProduct - ``` - - This creates a `StoreProduct` data model with the `store_id` and `product_id` properties. - - {/* These properties will be used later to establish relationships to the Store and Product modules. */} - - Finally, create the file `src/modules/marketplace/models/store-order.ts` with the following content: - - ```ts title="src/modules/marketplace/models/store-order.ts" - import { model } from "@medusajs/utils" - - const StoreOrder = model.define("store_order", { - id: model.id().primaryKey(), - store_id: model.text(), - order_id: model.text(), - parent_order_id: model.text(), - }) - - export default StoreOrder - ``` - - This creates a `StoreOrder` data model with the `store_id`, `order_id`, and `parent_order_id` properties. - - {/* The `store_id` and `order_id` properties will be used to establish relationships to the Store and Order modules. You’ll learn about the use of `parent_order_id` in a later section. */} - - To reflect these changes on the database, create the migration `src/modules/marketplace/migrations/Migration20240514143248.ts` with the following content: - - ```ts title="src/modules/marketplace/migrations/Migration20240514143248.ts" - import { Migration } from "@mikro-orm/migrations" - - export class Migration20240514143248 extends Migration { - - async up(): Promise { - this.addSql("create table if not exists \"store_order\" (\"id\" varchar(255) not null, \"store_id\" text not null, \"order_id\" text not null, \"parent_order_id\" text not null, constraint \"store_order_pkey\" primary key (\"id\"));") - - this.addSql("create table if not exists \"store_product\" (\"id\" varchar(255) not null, \"store_id\" text not null, \"product_id\" text not null, constraint \"store_product_pkey\" primary key (\"id\"));") - - this.addSql("create table if not exists \"store_user\" (\"id\" varchar(255) not null, \"store_id\" text not null, \"user_id\" text not null, constraint \"store_user_pkey\" primary key (\"id\"));") - } - - async down(): Promise { - this.addSql("drop table if exists \"store_order\" cascade;") - - this.addSql("drop table if exists \"store_product\" cascade;") - - this.addSql("drop table if exists \"store_user\" cascade;") - } - - } - ``` - - You’ll run the migration after registering the module in the Medusa configurations. - - Then, create the module’s main service at `src/modules/marketplace/service.ts` with the following content: - -export const mainServiceHighlights = [ - ["6", "MedusaService", "Extends the service factory to generate data management features."] -] - - ```ts title="src/modules/marketplace/service.ts" highlights={mainServiceHighlights} collapsibleLines="1-5" expandButtonLabel="Show Imports" - import { MedusaService } from "@medusajs/utils" - import StoreUser from "./models/store-user" - import StoreProduct from "./models/store-product" - import StoreOrder from "./models/store-order" - - class MarketplaceModuleService extends MedusaService({ - StoreUser, - StoreProduct, - StoreOrder, - }){ - // TODO add custom methods - } - - export default MarketplaceModuleService - ``` - - The module’s main service extends the service factory to generate data management features for the `StoreUser`, `StoreProduct`, and `StoreOrder` data models. - - Finally, create the module definition at `src/modules/marketplace/index.ts` with the following content: - - ```ts title="src/modules/marketplace/index.ts" - import MarketplaceModuleService from "./service" - import { Module } from "@medusajs/utils" - - export default Module("marketplace", { - service: MarketplaceModuleService, - }) - ``` - - To use the module, add it to the `modules` object in `medusa-config.js`: - - ```js title="medusa-config.js" - module.exports = defineConfig({ - // ... - modules: { - marketplaceModuleService: { - resolve: "./modules/marketplace", - definition: { - isQueryable: true, - }, - }, - }, - }) - ``` - - Then, run the migrations of the module: - - ```bash npm2yarn - npx medusa migrations run - ``` - -
- - --- -## Attach Users to Stores +## Define Module Links -To attach admin users to their own stores, create a subscriber that listens to the `user.created` event and attaches the user to the store. +Since a vendor has products, orders, and other models based on your use case, define module links between your module's data models and the commerce module's data models. - - -- The `user.created` event is currently not emitted. -- Module Relationships is coming soon. - - +For example, if you defined a vendor data model in a marketplace module, you can define a module link between the vendor and the Product Module's product data model. } showLinkIcon={false} /> -{/*
- - Create the file `src/subscribers/user-created.ts` with the following content: - -export const userSubscriberHighlights = [ - ["13", "data", "The event data payload with the created user's ID."], - ["27", "retrieveUser", "Retrieve the created user."], - ["29", "createStores", "Create a store for that user using the Store Module."], - ["33", "createStoreUsers", "Create a relationship between the user and the store by creating a `StoreUser` record."] -] - - ```ts title="src/subscribers/user-created.ts" highlights={userSubscriberHighlights} collapsibleLines="1-11" expandButtonLabel="Show Imports" - import type { - SubscriberArgs, - SubscriberConfig, - } from "@medusajs/medusa" - import { ModuleRegistrationName } from "@medusajs/utils" - import { - IUserModuleService, - IStoreModuleService, - } from "@medusajs/types" - import MarketplaceModuleService from "../modules/marketplace/service" - - export default async function userCreatedHandler({ - data, - container, - }: SubscriberArgs<{ id: string }>) { - const { id } = "data" in data ? data.data : data - const userModuleService: IUserModuleService = container.resolve( - ModuleRegistrationName.USER - ) - const storeModuleService: IStoreModuleService = container.resolve( - ModuleRegistrationName.STORE - ) - const marketplaceModuleService: MarketplaceModuleService = container.resolve( - "marketplaceModuleService" - ) - - const user = await userModuleService.retrieveUser(id) - - const store = await storeModuleService.createStores({ - name: `${user.first_name}'s Store`, - }) - - const storeUser = await marketplaceModuleService.createStoreUsers({ - store_id: store.id, - user_id: user.id, - }) - - console.log(`Created StoreUser ${storeUser.id}`) - } - - export const config: SubscriberConfig = { - event: "user.created", - } - ``` - - This adds a subscriber to the `user.created` event. In the subscriber, you: - - - Retrieve the created user. The created user’s ID is passed in the event’s data payload. - - Create a store for that user using the Store Module. - - Create a relationship between the user and the store by creating a `StoreUser` record. - - To test it out, use the `medusa user` command to create a user: - - ```bash npm2yarn - npx medusa user -e my-admin@medusa-test.com -p supersecret - ``` - - At the end of the output, you should see the message `Created StoreUser {store_user_id}` where the `{store_user_id}` is the ID of the created `StoreUser`. - -
*/} - --- -## Attach Products to Stores +## Allow Vendor Admins to Authenticate -Similar to the previous section, to attach products to stores, create a subscriber that listens to the `product.created` event. In the subscriber, you attach the product to the store it’s created in. +If your marketplace module implements admins for sellers or vendors, you can create an actor type for it. - - -- The `product.created` event is currently not emitted. -- Module Relationships is coming soon. - - +Then, you guard custom API routes to only allow authenticated vendor admins. } showLinkIcon={false} /> -{/*
- - Create the file `src/subscribers/product-created.ts` with the following content: - -export const productSubscriberHighlights = [ - ["24", "retrieveProduct", "Retrieve the created product."], - ["26", "", "This subscriber requires the store ID to be set in `product.metadata.store_id`. If not, the subscriber ends execution."], - ["30", "createStoreProducts", "Create a relationship between the product and the store by creating a `StoreProduct` record."] -] - - ```ts title="src/subscribers/product-created.ts" highlights={productSubscriberHighlights} collapsibleLines="1-10" expandButtonLabel="Show Imports" - import type { - SubscriberArgs, - SubscriberConfig, - } from "@medusajs/medusa" - import { ModuleRegistrationName } from "@medusajs/utils" - import { - IProductModuleService, - } from "@medusajs/types" - import MarketplaceModuleService from "../modules/marketplace/service" - - export default async function productCreateHandler({ - data, - container, - }: SubscriberArgs<{ id: string }>) { - const { id } = "data" in data ? data.data : data - const productModuleService: IProductModuleService = container.resolve( - ModuleRegistrationName.PRODUCT - ) - - const marketplaceModuleService: MarketplaceModuleService = container.resolve( - "marketplaceModuleService" - ) - - const product = await productModuleService.retrieveProduct(id) - - if (!product.metadata?.store_id) { - return - } - - await marketplaceModuleService.createStoreProducts({ - store_id: product.metadata.store_id as string, - product_id: id, - }) - } - - export const config: SubscriberConfig = { - event: "product.created", - } - ``` - - This adds a subscriber to the `product.created` event. In the subscriber, you: - - - Retrieve the created product. - - This subscriber requires the store ID to be set in `product.metadata.store_id`. If not, the subscriber ends execution. - - If the store ID is found, create a relationship between the product and the store by creating a `StoreProduct` record. - - To test it out, start the Medusa application: - - ```bash npm2yarn - npm run dev - ``` - - Then, send an authenticated `POST` request to `/admin/products`: - - ```bash - curl -X POST 'http://localhost:9000/admin/products' \ - -H 'Content-Type: application/json' \ - -H 'Authorization: Bearer {jwt_token}' \ - --data '{ - "title": "My Shirt 13", - "metadata": { - "store_id": "store_01HXV74JCCAHA3F2X3EWZTZG5R" - } - }' - ``` - - This returns the created product. In the next section, you’ll implement the API route to fetch the products of a store. - -
*/} - - --- -## Retrieve Store’s Products +## Create Vendor API Routes -To allow admin users to view their store’s products, create an API route that uses the remote query to fetch the products based on the logged-in user’s store. - - - -Retrieving module relationship data using the remote query is coming soon. - - +If you provide vendor or sellers with custom features, or a different way of managing products and orders, create API routes only accessible by vendors exposing your custom features. , showLinkIcon: false }, { - href: "!docs!/advanced-development/modules/remote-query", - title: "Remote Query", - text: "Use the remote query to fetch data across modules.", + href: "!docs!/advanced-development/api-routes/protected-routes", + title: "Protect Routes", + text: "Learn how to guard routes to allow authenticated users only.", startIcon: , showLinkIcon: false }, ]} /> -{/*
- - Create the file `src/api/admin/marketplace/products/route.ts` with the following content: - -export const productRoutesHighlights = [ - ["26", "storeUsers", "Retrieve the store of the logged-in user."], - ["38", "query", "Build a query that retrieves the products of that store."], - ["50", "remoteQuery", "Retrieve the products using remote query."] -] - - ```ts title="src/api/admin/marketplace/products/route.ts" highlights={productRoutesHighlights} collapsibleLines="1-13" expandButtonLabel="Show Imports" - import { - AuthenticatedMedusaRequest, - MedusaResponse, - } from "@medusajs/medusa" - import { RemoteQueryFunction } from "@medusajs/modules-sdk" - import { - remoteQueryObjectFromString, - ContainerRegistrationKeys, - MedusaError, - } from "@medusajs/utils" - import MarketplaceModuleService - from "../../../../modules/marketplace/service" - - export async function GET( - req: AuthenticatedMedusaRequest, - res: MedusaResponse - ): Promise { - const marketplaceModuleService: MarketplaceModuleService = - req.scope.resolve( - "marketplaceModuleService" - ) - const remoteQuery: RemoteQueryFunction = req.scope.resolve( - ContainerRegistrationKeys.REMOTE_QUERY - ) - - const storeUsers = await marketplaceModuleService - .listStoreUsers({ - user_id: req.auth_context.actor_id, - }) - - if (!storeUsers.length) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - "This user doesn't have an associated store." - ) - } - - const query = remoteQueryObjectFromString({ - entryPoint: "store_product", - fields: [ - "product.*", - ], - variables: { - filters: { - store_id: storeUsers[0].store_id, - }, - }, - }) - - const result = await remoteQuery(query) - - res.json({ - store_id: storeUsers[0].store_id, - products: result.map((data) => data.product), - }) - } - ``` - - This creates a `GET` API route at `/admin/marketplace/products`. In the API route, you: - - - Retrieve the store of the logged-in user. - - Build a query that retrieves the products of that store. - - Retrieve the products using remote query and return them. - - Next, create the file `src/api/middlewares.ts` with the following content: - - ```ts title="src/api/middlewares.ts" - import { - MiddlewaresConfig, - authenticate, - } from "@medusajs/medusa" - - export const config: MiddlewaresConfig = { - routes: [ - { - matcher: "/admin/marketplace*", - middlewares: [ - authenticate( - "admin", - ["session", "bearer", "api-key"] - ), - ], - }, - ], - } - ``` - - This ensures that only authenticated admin users can access your API route. - - To test it out, start the Medusa application. Then, send a `GET` request to `/admin/marketplace/products`: - - ```bash - curl 'localhost:9000/admin/marketplace/products' \ - -H 'Authorization: Bearer {jwt_token}' - ``` - - This will return the product you created in the previous section, if the `{jwt_token}` belongs to the user of the same store ID. - -
*/} - - --- -## Split Orders Based on Stores +## Split Orders Based on Vendors -An order may contain items from different stores. To ensure that users can only view and manage their orders, create a subscriber that listens to the `order.placed` event and handles splitting the order into multiple orders based on the items’ stores. +If your use case allows a customer's orders to have items from different vendors, you can override the [Complete Cart API route](https://docs.medusajs.com/v2/api/store#carts_postcartsidcomplete) to change how the order should be created. - - -The `order.placed` event is still not emitted in Medusa V2. - - +If your flow requires different steps, such as creating a parent order then creating a child order, create a workflow and execute it from the API route. } showLinkIcon={false} /> -{/*
- - Create the file `src/subscribers/order-created.ts` with the following content: - -export const orderSubscriberHighlights = [ - ["35", "", "Loop over the created order’s items."], - ["57", "", "Group the items by their store ID."], - ["72", "createStoreOrders", "If the items have the same store ID, then associate the created order with the store."], - ["88", "createStoreOrders", "If there are items from more than one store in the order, create child orders and associate each of them with the store."], - ["91", "parent_order_id", "The `parent_order_id` property in the `StoreOrder` data model points to the original order."] -] - - ```ts title="src/subscribers/order-created.ts" highlights={orderSubscriberHighlights} collapsibleLines="1-13" expandButtonLabel="Show Imports" - import type { - SubscriberArgs, - SubscriberConfig, - } from "@medusajs/medusa" - import { ModuleRegistrationName } from "@medusajs/utils" - import { - IOrderModuleService, - CreateOrderDTO, - } from "@medusajs/types" - import MarketplaceModuleService - from "../modules/marketplace/service" - import { createOrdersWorkflow } from "@medusajs/core-flows" - - export default async function orderCreatedHandler({ - data, - container, - }: SubscriberArgs<{ id: string }>) { - const { id } = "data" in data ? data.data : data - const orderModuleService: IOrderModuleService = - container.resolve( - ModuleRegistrationName.ORDER - ) - - const marketplaceModuleService: MarketplaceModuleService = - container.resolve( - "marketplaceModuleService" - ) - - const storeToOrders: Record = {} - - const order = await orderModuleService.retrieveOrder(id, { - relations: ["items"], - }) - - await Promise.all(order.items?.map(async (item) => { - const storeProduct = await marketplaceModuleService - .listStoreProducts({ - product_id: item.product_id, - }) - - if (!storeProduct.length) { - return - } - - const storeId = storeProduct[0].store_id - - if (!storeToOrders[storeId]) { - const { id, ...orderDetails } = order - storeToOrders[storeId] = { - ...orderDetails, - items: [], - } - } - - const { id, ...itemDetails } = item - - storeToOrders[storeId].items.push(itemDetails) - })) - - const storeToOrdersKeys = Object.keys(storeToOrders) - - if (!storeToOrdersKeys.length) { - return - } - - if ( - storeToOrdersKeys.length === 1 && - storeToOrders[0].items.length === order.items.length - ) { - // The order is composed of items from one store, so - // associate the order as-is with the store. - await marketplaceModuleService.createStoreOrders({ - store_id: storeToOrdersKeys[0], - order_id: order.id, - }) - - return - } - - // create store orders for each child order - await Promise.all( - storeToOrdersKeys.map(async (storeId) => { - const { result } = await createOrdersWorkflow(container) - .run({ - input: storeToOrders[storeId], - }) - - await marketplaceModuleService.createStoreOrders({ - store_id: storeId, - order_id: result.id, - parent_order_id: order.id, - }) - }) - ) - } - - export const config: SubscriberConfig = { - event: "order.placed", - } - ``` - - This adds a subscriber to the `order.placed` event. In the subscriber, you: - - - Loop over the created order’s items. - - Group the items by their store ID. - - If the items have the same store ID, then associate the created order with the store. - - If there are items from more than one store in the order, create child orders and associate each of them with the store. Here, you use the `parent_order_id` property in the `StoreOrder` data model to point to the original order. - - To test this out, create an order in your store. That will run the subscriber and create the child orders. - - The next section covers how to retrieve the store’s orders. - -
*/} - - ---- - -## Retrieve Store’s Orders - -Similar to products, to allow admin users to view their store’s orders, create an API route that uses the remote query to fetch the orders based on the logged-in user’s store. - - - -Retrieving module relationship data using the remote query is coming soon. - - - -, - showLinkIcon: false - }, - { - href: "!docs!/advanced-development/modules/remote-query", - title: "Remote Query", - text: "Use the remote query to fetch data across modules.", - startIcon: , - showLinkIcon: false - }, -]} /> - -{/*
- - Create the file `src/api/admin/marketplace/orders/route.ts` with the following content: - -export const orderRoutesHighlights = [ - ["25", "", "Retrieve the store of the logged-in user."], - ["29", "", "Build a query that retrieves the orders of that store."], - ["41", "", "Retrieve the orders using remote query."] -] - - ```ts title="src/api/admin/marketplace/orders/route.ts" highlights={orderRoutesHighlights} collapsibleLines="1-12" expandButtonLabel="Show Imports" - import { - AuthenticatedMedusaRequest, - MedusaResponse, - } from "@medusajs/medusa" - import { RemoteQueryFunction } from "@medusajs/modules-sdk" - import { - remoteQueryObjectFromString, - ContainerRegistrationKeys, - } from "@medusajs/utils" - import MarketplaceModuleService - from "../../../../modules/marketplace/service" - - export async function GET( - req: AuthenticatedMedusaRequest, - res: MedusaResponse - ): Promise { - const marketplaceModuleService: MarketplaceModuleService = - req.scope.resolve( - "marketplaceModuleService" - ) - const remoteQuery: RemoteQueryFunction = req.scope.resolve( - ContainerRegistrationKeys.REMOTE_QUERY - ) - - const storeUsers = await marketplaceModuleService.listStoreUsers({ - user_id: req.auth_context.actor_id, - }) - - const query = remoteQueryObjectFromString({ - entryPoint: "store_order", - fields: [ - "order.*", - ], - variables: { - filters: { - store_id: storeUsers[0].store_id, - }, - }, - }) - - const result = await remoteQuery(query) - - res.json({ - orders: result.map((data) => data.order), - }) - } - ``` - - This creates a `GET` API route at `/admin/marketplace/orders`. In the API route, you: - - - Retrieve the store of the logged-in user. - - Build a query that retrieves the orders of that store. - - Retrieve the orders using remote query and return them. - - To test it out, start the Medusa application. Then, send a `GET` request to `/admin/marketplace/orders`: - - ```bash - curl 'localhost:9000/admin/marketplace/orders' \ - -H 'Authorization: Bearer {jwt_token}' - ``` - - This will return the orders you created in the previous section if the `{jwt_token}` belongs to the user of the same store ID. - -
*/} - --- ## Customize Storefront -Medusa provides a Next.js storefront to use with your application. You can either customize it or build your own to represent your marketplace features. +Medusa provides a Next.js Starter storefront that you can customize for your use case. -For example, you can create an API route that retrieves available stores and another API route that retrieves the products of each store using the remote query as done in previous sections. +You can also create a custom storefront from scratch. , showLinkIcon: false }, ]} /> + +--- + +## Customize Admin + +The Medusa Admin is extendable, allowing you to add widgets to existing pages or create new pages. + +If your use case requires bigger customizations to the admin, such as showing different products and orders based on the logged-in vendor, use the [admin API routes](!api!/admin) to build a custom admin. diff --git a/www/apps/resources/app/recipes/page.mdx b/www/apps/resources/app/recipes/page.mdx new file mode 100644 index 0000000000..a0758ec215 --- /dev/null +++ b/www/apps/resources/app/recipes/page.mdx @@ -0,0 +1,11 @@ +import { ChildDocs } from "docs-ui" + +export const metadata = { + title: `Recipes`, +} + +# {metadata.title} + +This section of the documentation provides recipes for common use cases with example implementations. + + \ No newline at end of file diff --git a/www/apps/resources/generated/files-map.mjs b/www/apps/resources/generated/files-map.mjs index 49308e5f8a..0aa370dee2 100644 --- a/www/apps/resources/generated/files-map.mjs +++ b/www/apps/resources/generated/files-map.mjs @@ -731,6 +731,10 @@ export const filesMap = [ "filePath": "/www/apps/resources/app/recipes/integrate-ecommerce-stack/page.mdx", "pathname": "/recipes/integrate-ecommerce-stack" }, + { + "filePath": "/www/apps/resources/app/recipes/marketplace/examples/vendors/page.mdx", + "pathname": "/recipes/marketplace/examples/vendors" + }, { "filePath": "/www/apps/resources/app/recipes/marketplace/page.mdx", "pathname": "/recipes/marketplace" @@ -747,6 +751,10 @@ export const filesMap = [ "filePath": "/www/apps/resources/app/recipes/oms/page.mdx", "pathname": "/recipes/oms" }, + { + "filePath": "/www/apps/resources/app/recipes/page.mdx", + "pathname": "/recipes" + }, { "filePath": "/www/apps/resources/app/recipes/personalized-products/page.mdx", "pathname": "/recipes/personalized-products" diff --git a/www/apps/resources/generated/sidebar.mjs b/www/apps/resources/generated/sidebar.mjs index 72b7b41d5f..1fa6ac0c37 100644 --- a/www/apps/resources/generated/sidebar.mjs +++ b/www/apps/resources/generated/sidebar.mjs @@ -6159,9 +6159,26 @@ export const generatedSidebar = [ { "loaded": true, "isPathHref": true, + "path": "/recipes", "title": "Recipes", "hasTitleStyling": true, + "isChildSidebar": true, "children": [ + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/marketplace", + "title": "Marketplace", + "children": [ + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/marketplace/examples/vendors", + "title": "Example: Vendors", + "children": [] + } + ] + }, { "loaded": true, "isPathHref": true, @@ -6197,13 +6214,6 @@ export const generatedSidebar = [ "title": "Integrate Ecommerce Stack", "children": [] }, - { - "loaded": true, - "isPathHref": true, - "path": "/recipes/marketplace", - "title": "Marketplace", - "children": [] - }, { "loaded": true, "isPathHref": true, diff --git a/www/apps/resources/sidebar.mjs b/www/apps/resources/sidebar.mjs index 69c5e937a2..e67c747aa9 100644 --- a/www/apps/resources/sidebar.mjs +++ b/www/apps/resources/sidebar.mjs @@ -1280,9 +1280,21 @@ export const sidebar = sidebarAttachHrefCommonOptions([ }, { + path: "/recipes", title: "Recipes", hasTitleStyling: true, + isChildSidebar: true, children: [ + { + path: "/recipes/marketplace", + title: "Marketplace", + children: [ + { + path: "/recipes/marketplace/examples/vendors", + title: "Example: Vendors", + }, + ], + }, { path: "/recipes/b2b", title: "B2B", @@ -1303,10 +1315,6 @@ export const sidebar = sidebarAttachHrefCommonOptions([ path: "/recipes/integrate-ecommerce-stack", title: "Integrate Ecommerce Stack", }, - { - path: "/recipes/marketplace", - title: "Marketplace", - }, { path: "/recipes/multi-region-store", title: "Multi-Region Store",