đš The content of this tutorial may be outdated. You can instead check out the full code for the series in this GitHub Repository.đš
In the first part of this series, you learned about Medusa Extender and how to use it to create a marketplace ecommerce platform. The first part demonstrated how to link stores to users so that each new user has their own store, then how to link products to a store so that the user can see and manage only their products.
In this part, youâll learn how to link orders to their respective stores. This will cover use cases where a customer purchases products from multiple stores, and how to manage the overall status of that order.
You can find the full code for this tutorial in this GitHub repository.
You can alternatively use the Medusa Marketplace plugin as indicated in the README of the GitHub repository. If youâre already using it make sure to update to the latest version:
npm install medusa-marketplace@latest
Prerequisites
It is assumed that youâve followed along with the first part of the series before continuing this part. If you havenât, please start from there.
If you donât have the Medusa Admin installed, it is recommended that you install it so that you can easily view products and orders, among other functionalities.
Alternatively, you can use Medusaâs Admin APIs to access the data on your server. However, the rest of the tutorial will mostly showcase features through the Medusa Admin.
To add products with images you also need a file service plugin like MinIO installed on your Medusa server. You can also check the documentation for more options for a file service.
This tutorial additionally makes use of the Next.js starter storefront to showcase placing orders. This is also optional and you are free to use other storefronts or Medusaâs Storefront APIs instead.
Finally, this part uses version 1.6.5
of the Medusa Extender which introduced new features and a better developer experience.
If you have an old version of the Medusa Extender installed, update the extender on your Medusa server:
npm install medusa-extender@1.6.5
Change the content of tsconfig.json
to the following:
{
"compilerOptions": {
"module": "CommonJS",
"declaration": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"target": "es2017",
"sourceMap": true,
"skipLibCheck": true,
"allowJs": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true
},
"include": [
"src",
],
"exclude": [
"dist",
"node_modules",
"**/*.spec.ts",
"medusa-config.js",
]
}
And change scripts
in package.json
to the following:
"scripts": {
"seed": "medusa seed -f ./data/seed.json",
"build": "rm -rf dist && ./node_modules/.bin/tsc -p tsconfig.json",
"start": "npm run build && NODE_ENV=development node ./dist/main.js",
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js",
"start:watch": "nodemon --watch './src/**/*.ts' --exec 'ts-node' ./src/main.ts",
"start:prod": "npm run build && NODE_ENV=production node dist/main"
},
What You Will Be Building
As mentioned in the introduction, this part of the series will guide you through linking orders to stores. To do that, youâll override the Order
model to add the relation between it and the Store
model.
In a marketplace, customers should be able to purchase products from multiple vendors at the same time. So, youâll also add a subscriber that, when an order is placed, will create âchildâ orders. Child orders will be linked to a store, will only have products from the original order that belongs to that store, and will be linked to the parent order.
For that reason, youâll also add a parent-child relation between Order
models. This relation will additionally let you manage the parent orderâs status based on the statuses of child orders.
Moreover, youâll add a filter that ensures when a user retrieves the list of orders in their store, only orders that belong to their store are retrieved. This will also allow a super admin who does not belong to any store to track the parent orders.
Add Relations to the Order Model
The first step is adding the relation between the Order
and Store
model, and between Order
models. To do that, you need to override the Order
model.
Create a new directory src/modules/order
which will hold all order-related classes that you create throughout this tutorial.
Then, create the file src/modules/order/order.entity.ts
with the following content:
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from "typeorm";
import { Entity as MedusaEntity } from "medusa-extender";
import { Order as MedusaOrder } from "@medusajs/medusa";
import { Store } from "../store/entities/store.entity";
@MedusaEntity({override: MedusaOrder})
@Entity()
export class Order extends MedusaOrder {
@Index()
@Column({ nullable: true })
store_id: string;
@Index()
@Column({ nullable: false })
order_parent_id: string;
@ManyToOne(() => Store, (store) => store.orders)
@JoinColumn({ name: 'store_id' })
store: Store;
@ManyToOne(() => Order, (order) => order.children)
@JoinColumn({ name: 'order_parent_id' })
parent: Order;
@OneToMany(() => Order, (order) => order.parent)
@JoinColumn({ name: 'id', referencedColumnName: 'order_parent_id' })
children: Order[];
}
You add the model Order
which overrides and extends Medusaâs Order
model. In this model, you add 2 new columns: store_id
and order_parent_id
. The store_id
column will be used for the many-to-one relation between the Order
model and Store
model, which you demonstrate through the store
property.
The order_parent_id
column will be used for the many-to-one and one-to-many relation between Order
models. This leads to parent
and children
properties resulting from these relations.
Next, in src/modules/store/entities/store.entity.ts
add a new import for the Order
model at the beginning of the file:
import { Order } from '../../order/order.entity';
And inside the Store
class add the relation to the Order
model:
@OneToMany(() => Order, (order) => order.store)
@JoinColumn({ name: 'id', referencedColumnName: 'store_id' })
orders: Order[];
Add a New Migration
To reflect the new columns in the database, you need to create a migration file in the order module.
As migration files have the format <timestamp>-order.migration.ts
, a migration file is unique to you so you need to create it yourself.
Luckily, the new update of Medusa Extender added a lot of helpful CLI commands to make redundant tasks easier for you. You can generate the migration file using the following command:
./node_modules/.bin/medex g -mi order
This will create the file src/modules/order/<timestamp>-order.migration.ts
for you. Open that file and replace the up
and down
methods with the following implementation:
public async up(queryRunner: QueryRunner): Promise<void> {
const query = `
ALTER TABLE public."order" ADD COLUMN IF NOT EXISTS "store_id" text;
ALTER TABLE public."order" ADD COLUMN IF NOT EXISTS "order_parent_id" text;
ALTER TABLE public."order" ADD CONSTRAINT "FK_8a96dde86e3cad9d2fcc6cb171f87" FOREIGN KEY ("order_parent_id") REFERENCES "order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
`;
await queryRunner.query(query);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const query = `
ALTER TABLE public."order" DROP COLUMN "store_id";
ALTER TABLE public."order" DROP COLUMN "order_parent_id";
ALTER TABLE public."order" DROP FOREIGN KEY "FK_8a96dde86e3cad9d2fcc6cb171f87cb2";
`;
await queryRunner.query(query);
}
The up
method adds the columns store_id
and order_parent_id
to the order
table with a foreign key, and the down
method removes those columns and foreign key from the order
table.
Run Migrations
Part of the Medusa Extender new CLI commands is the [migrate
command](https://adrien2p.github.io/medusa-extender/#/?id=command-migrate-reference) which looks inside the src
and dist
directories for both files ending with .migration.js
and JavaScript files inside a migrations
sub-directory of the 2 directories.
You can refer to the Medusa Marketplace plugin to learn how you can run migrations from it.
Then, if the migrations inside those files havenât been run before it runs or show them based on the option you pass to the command.
As the migration file youâve created is a TypeScript file, you need to transpile it to JavaScript first before migrating the changes. So, run the following command:
npm run build
This will transpile all TypeScript files inside the src
directory into JavaScript files inside the dist
directory.
Finally, run the migration with the following command:
./node_modules/.bin/medex migrate --run
If you get an error about duplicate migrations because of migrations from the previous part of this series, go ahead and remove the old ones from the dist
directory and try running the command again.
If you check your database once the migration is run successfully, you can see that the 2 new columns have been added to the order
table.
Override OrderRepository
Since youâve overridden the Order
model, you should override OrderRepository
to make sure that when an order is retrieved, the overridden model is used.
Create the file src/modules/order/order.repository.ts
with the following content:
import { Repository as MedusaRepository, Utils } from "medusa-extender";
import { EntityRepository } from "typeorm";
import { OrderRepository as MedusaOrderRepository } from "@medusajs/medusa/dist/repositories/order";
import { Order } from "./order.entity";
@MedusaRepository({override: MedusaOrderRepository})
@EntityRepository(Order)
export class OrderRepository extends Utils.repositoryMixin<Order, MedusaOrderRepository>(MedusaOrderRepository) {}
List Orders By Store
In this section, youâll retrieve orders based on the store of the currently logged-in user.
Modify LoggedInUserMiddleware
In the previous part, you created a middleware LoggedInUserMiddleware
which checks if a user is logged in and registers them in the scope. This allows you to access the logged-in user from services and subscribers, and this was used to retrieve products based on the logged-in userâs store.
However, the previous implementation affects both storefront and admin routes in Medusa. This can cause inconsistencies for customers accessing the storefront.
To ensure that the logged-in user is only added to the scope for admin routes, change the code in src/modules/user/middlewares/loggedInUser.middleware.ts
to the following content:
import { MedusaAuthenticatedRequest, MedusaMiddleware, Middleware } from 'medusa-extender';
import { NextFunction, Response } from 'express';
import UserService from '../services/user.service';
@Middleware({ requireAuth: true, routes: [{ method: "all", path: '*' }] })
export class LoggedInUserMiddleware implements MedusaMiddleware {
public async consume(req: MedusaAuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
let loggedInUser = null;
if (req.user && req.user.userId && **/^\/admin/.test(req.originalUrl)**) {
const userService = req.scope.resolve('userService') as UserService;
loggedInUser = await userService.retrieve(req.user.userId, {
select: ['id', 'store_id'],
});
}
req.scope.register({
loggedInUser: {
resolve: () => loggedInUser,
},
});
next();
}
}
The new change adds a new condition to check if the current route starts with /admin
. If it does and if the user is logged in, the logged-in user is added to the scope. Otherwise, the value of loggedInUser
in the scope will be null
.
Although you can specify the path of the middleware to be /admin/*
to register this middleware for admin routes only, this approach is necessary because if the loggedInUser
is not registered in the scope an error will be thrown in any service or subscriber that uses it.
Override the OrderService
The Medusa server uses the method [buildQuery_](https://github.com/adrien2p/medusa-extender/releases/tag/v1.7.0)
in OrderService
to build the query necessary to retrieve the orders from the database. Youâll be overriding the OrderService
, and particularly the buildQuery_
method to add a selector condition for the store_id
if there is a currently logged-in user that has a store.
Create the file src/modules/order.service.ts
with the following content:
import { EntityManager } from 'typeorm';
import { OrderService as MedusaOrderService } from "@medusajs/medusa/dist/services";
import { OrderRepository } from './order.repository';
import { Service } from 'medusa-extender';
import { User } from "../user/entities/user.entity";
type InjectedDependencies = {
manager: EntityManager;
orderRepository: typeof OrderRepository;
customerService: any;
paymentProviderService: any;
shippingOptionService: any;
shippingProfileService: any;
discountService: any;
fulfillmentProviderService: any;
fulfillmentService: any;
lineItemService: any;
totalsService: any;
regionService: any;
cartService: any;
addressRepository: any;
giftCardService: any;
draftOrderService: any;
inventoryService: any;
eventBusService: any;
loggedInUser: User;
orderService: OrderService;
};
@Service({ scope: 'SCOPED', override: MedusaOrderService })
export class OrderService extends MedusaOrderService {
private readonly manager: EntityManager;
private readonly container: InjectedDependencies;
constructor(container: InjectedDependencies) {
super(container);
this.manager = container.manager;
this.container = container;
}
buildQuery_(selector: object, config: {relations: string[], select: string[]}): object {
if (this.container.loggedInUser && this.container.loggedInUser.store_id) {
selector['store_id'] = this.container.loggedInUser.store_id;
}
config.select.push('store_id')
config.relations = config.relations ?? []
config.relations.push("children", "parent", "store")
return super.buildQuery_(selector, config);
}
}
Inside buildQuery_
you first check if there is a logged-in user and if that user has a store. If true, you add to the selector
parameter (which is used to filter out data from the database) a new property store_id
and set its value to the store ID of the logged-in user.
You also add to the selected fields store_id
, and you add the children
, parent
, and store
relations to be retrieved along with the order.
Create the Order Module
The last thing before you can test out the changes youâve just made is you need to create an order module that imports those new classes you created into Medusa.
Create the file src/modules/order/order.module.ts
with the following content:
import { Module } from 'medusa-extender';
import { Order } from './order.entity';
import { OrderMigration1652101349791 } from './1652101349791-order.migration';
import { OrderRepository } from './order.repository';
import { OrderService } from './order.service';
import { OrderSubscriber } from './order.subscriber';
@Module({
imports: [Order, OrderRepository, OrderService, OrderMigration1652101349791]
})
export class OrderModule {}
Please notice that you need to change the import and class name of the migration class based on your migrationâs name.
Then, import this new module at the beginning of the file src/main.ts
:
import { OrderModule } from './modules/order/order.module';
And inside the array passed to the load
method pass the OrderModule
:
await new Medusa(__dirname + '/../', expressInstance).load([
UserModule,
ProductModule,
OrderModule,
StoreModule,
]);
Test it Out
To test it out, start the server with the following command:
npm start
This will start the server on port 9000
Then, start your Medusa admin and log in with the user you created in the first part of the series. You should see on the orders page that there are no orders for this user.
If youâre using Medusaâs APIs you can view the orders by sending a
GET
request tolocalhost:9000/admin/orders
.
Handle Order Place Event
In this section, youâll add a subscriber to handle the order.placed
event that is triggered whenever a new order is placed by a customer. As mentioned earlier in the tutorial, youâll use this handler to create child orders for each store that the customer purchased products from in their order.
Create a new file src/modules/order/order.subscriber.ts
with the following content:
import { EventBusService, OrderService } from "@medusajs/medusa/dist/services";
import { LineItem, OrderStatus } from '@medusajs/medusa';
import { EntityManager } from "typeorm";
import { LineItemRepository } from '@medusajs/medusa/dist/repositories/line-item';
import { Order } from './order.entity';
import { OrderRepository } from "./order.repository";
import { PaymentRepository } from "@medusajs/medusa/dist/repositories/payment";
import { Product } from "../product/entities/product.entity";
import { ProductService } from './../product/services/product.service';
import { ShippingMethodRepository } from "@medusajs/medusa/dist/repositories/shipping-method";
import { Subscriber } from 'medusa-extender';
type InjectedDependencies = {
eventBusService: EventBusService;
orderService: OrderService;
orderRepository: typeof OrderRepository;
productService: ProductService;
manager: EntityManager;
lineItemRepository: typeof LineItemRepository;
shippingMethodRepository: typeof ShippingMethodRepository;
paymentRepository: typeof PaymentRepository;
};
@Subscriber()
export class OrderSubscriber {
private readonly manager: EntityManager;
private readonly eventBusService: EventBusService;
private readonly orderService: OrderService;
private readonly orderRepository: typeof OrderRepository;
private readonly productService: ProductService;
private readonly lineItemRepository: typeof LineItemRepository;
private readonly shippingMethodRepository: typeof ShippingMethodRepository;
constructor({ eventBusService, orderService, orderRepository, productService, manager, lineItemRepository, shippingMethodRepository, paymentRepository}: InjectedDependencies) {
this.eventBusService = eventBusService;
this.orderService = orderService;
this.orderRepository = orderRepository;
this.productService = productService;
this.manager = manager;
this.lineItemRepository = lineItemRepository;
this.shippingMethodRepository = shippingMethodRepository;
this.eventBusService.subscribe(
OrderService.Events.PLACED,
this.handleOrderPlaced.bind(this)
);
}
private async handleOrderPlaced({ id }: {id: string}): Promise<void> {
//create child orders
//retrieve order
const order: Order = await this.orderService.retrieve(id, {
relations: ['items', 'items.variant', 'cart', 'shipping_methods', 'payments']
});
//group items by store id
const groupedItems = {};
for (const item of order.items) {
const product: Product = await this.productService.retrieve(item.variant.product_id, { select: ['store_id']});
const store_id = product.store_id;
if (!store_id) {
continue;
}
if (!groupedItems.hasOwnProperty(store_id)) {
groupedItems[store_id] = [];
}
groupedItems[store_id].push(item);
}
const orderRepo = this.manager.getCustomRepository(this.orderRepository);
const lineItemRepo = this.manager.getCustomRepository(this.lineItemRepository);
const shippingMethodRepo = this.manager.getCustomRepository(this.shippingMethodRepository);
for (const store_id in groupedItems) {
//create order
const childOrder = orderRepo.create({
...order,
order_parent_id: id,
store_id: store_id,
cart_id: null,
cart: null,
id: null,
shipping_methods: []
}) as Order;
const orderResult = await orderRepo.save(childOrder);
//create shipping methods
for (const shippingMethod of order.shipping_methods) {
const newShippingMethod = shippingMethodRepo.create({
...shippingMethod,
id: null,
cart_id: null,
cart: null,
order_id: orderResult.id
});
await shippingMethodRepo.save(newShippingMethod);
}
//create line items
const items: LineItem[] = groupedItems[store_id];
for (const item of items) {
const newItem = lineItemRepo.create({
...item,
id: null,
order_id: orderResult.id,
cart_id: null
})
await lineItemRepo.save(newItem);
}
}
}
}
Hereâs a summary of this code:
- In the constructor, you register the method
handleOrderPlaced
as a handler for the eventorder.placed
. - Inside
handleOrderPlaced
you first retrieve the order using the ID passed to the method with the necessary relations for the creation of child orders. - You then loop over the items purchased in the order and group then inside the object
groupedItems
with the key being the unique store IDs and the value being an array of items. - You then loop over the keys in
groupedItems
and create achildOrder
for each store. The child orders have the same data as the parent order but they haveparent_id
set to the ID of the parent order andstore_id
set to the ID of the store itâs associated with. - For each child order, you need to create
shippingMethods
that are identical to the shipping methods of the parent order but associated with the child order. - For each child order, you need to add the items that were in the order for that specific store, as each vendor should only see the items ordered from their store.
Make sure you have Redis installed and configured with Medusa for this subscriber to work.
Test it Out
To test it out, first, restart your Medusa server, then run the storefront that youâre using for your store and add one of the products you created for a vendor to the cart then place an order.
If you then open the admin panel again and check orders, you should see a new order on the orders page of the vendor. If you open it youâll see details related to the order.
Try creating more users and adding products for different users and stores. Youâll see that each user will see the order with items only related to their store.
Handle Order Status Changed Events
To ensure that the status of the parent order changes as necessary with the change of status of the child orders, itâs necessary to listen to the events triggered whenever an orderâs status changes.
In the constructor
of the OrderSubscriber
class in src/modules/order/order.subscriber.ts
add the following code:
//add handler for different status changes
this.eventBusService.subscribe(
OrderService.Events.CANCELED,
this.checkStatus.bind(this)
);
this.eventBusService.subscribe(
OrderService.Events.UPDATED,
this.checkStatus.bind(this)
);
this.eventBusService.subscribe(
OrderService.Events.COMPLETED,
this.checkStatus.bind(this)
);
This adds the same method checkStatus
as the order handler of the events Canceled, Updated, and Completed of an order.
Next, add inside the class the following methods:
public async checkStatus({ id }: {id: string}): Promise<void> {
//retrieve order
const order: Order = await this.orderService.retrieve(id);
if (order.order_parent_id) {
//retrieve parent
const orderRepo = this.manager.getCustomRepository(this.orderRepository);
const parentOrder = await this.orderService.retrieve(order.order_parent_id, {
relations: ['children']
});
const newStatus = this.getStatusFromChildren(parentOrder);
if (newStatus !== parentOrder.status) {
switch (newStatus) {
case OrderStatus.CANCELED:
this.orderService.cancel(parentOrder.id);
break;
case OrderStatus.ARCHIVED:
this.orderService.archive(parentOrder.id);
break;
case OrderStatus.COMPLETED:
this.orderService.completeOrder(parentOrder.id);
break;
default:
parentOrder.status = newStatus;
parentOrder.fulfillment_status = newStatus;
parentOrder.payment_status = newStatus;
await orderRepo.save(parentOrder);
}
}
}
}
public getStatusFromChildren (order: Order): string {
if (!order.children) {
return order.status;
}
//collect all statuses
let statuses = order.children.map((child) => child.status);
//remove duplicate statuses
statuses = [...new Set(statuses)];
if (statuses.length === 1) {
return statuses[0];
}
//remove archived and canceled orders
statuses = statuses.filter((status) => status !== OrderStatus.CANCELED && status !== OrderStatus.ARCHIVED);
if (!statuses.length) {
//all child orders are archived or canceled
return OrderStatus.CANCELED;
}
if (statuses.length === 1) {
return statuses[0];
}
//check if any order requires action
const hasRequiresAction = statuses.some((status) => status === OrderStatus.REQUIRES_ACTION);
if (hasRequiresAction) {
return OrderStatus.REQUIRES_ACTION;
}
//since more than one status is left and we filtered out canceled, archived,
//and requires action statuses, only pending and complete left. So, return pending
return OrderStatus.PENDING;
}
Hereâs a summary of the code snippet:
- In
checkStatus
you first retrieve the orderâs data using its ID. - You check if the order has a parent order. This is to avoid handling events triggered for the parent order as it is not necessary.
- You then retrieve the parent order with its relation to its
children
orders. - You make use of another method
getStatusFromChildren
to deduce the status of the parent order from the children:- You first retrieve all statuses from the child orders then remove any duplicates.
- If the result of removing the duplicates leads to only one status, then it means that all orders have the same status and the parent can have that same status as well.
- Otherwise, if thereâs more than one status, you remove the archived and canceled orders.
- If this leads to no statuses, this means that all children are either canceled or archived and the parent should have the same status. The code snippet defaults to the âcanceledâ status here but you can change that.
- Otherwise, if thereâs only status left after removing canceled and archived orders you return that status.
- Otherwise, if thereâs more than one status left, you check if one of those statuses is
requires_action
and return that as the status. - If thereâs no
requires_action
status you can infer there are onlypending
andcomplete
orders left. Since itâs logical to consider that if at least one order ispending
then you can consider the parent orderpending
, you default to that status.
- After retrieving the deduced status of the parent order, if that status is different than the current status of the parent order, you update its status. Depending on the new status, you either use existing methods in the
OrderService
to update the status, or manually set the status in the order.
Test it Out
Restart your Medusa server. Then, open the Medusa admin to the order you just created earlier. Try canceling the order by clicking on the top 3 dots then clicking âCancel Orderâ.
After canceling the order, log out and log in with a super admin user. By default, the super admin user is the user created when you seed the database at the beginning of your Medusa server set up. This user has the email âadmin@medusa-test.comâ and password âsupersecretâ.
If you open the parent order youâll see that itâs now canceled as well.
Conclusion
By following those 2 parts, you should now have stores for each user with products and orders linked to them.
In the next part of the series, youâll learn about how to add more than one user to a store, how to add super admins, and how to customize other settings.
Should you have any issues or questions related to Medusa, then feel free to reach out to the Medusa team via Discord.