Medusa Marketplace #2.2 | Extending ProductService
Hello everyone!
Now that our vendors have access to their store, I can tell they are getting bored. Indeed, without any things to sell, our back office is useless, therefore we're spending this time expanding the product concept and adding a decent bit of customization.
What is the goal here ?
The benefit of this section of the tutorial is that it allows you to apply everything you've learned in prior sections.
First, we will:
Create a new file in the
models
folder, that will extend the core entity with new propertiesCreate a new migration using the TypeORM CLI
Create a new file in the
services
folder that will initially only include our basic changes (LIFE_TIME
,this.loggedInUser_
...)
Once these steps are completed, we'll get down to the essential of adding these features :
Create a product related to a vendor
List the products of a vendor
Extend the Product entity
Here is where we define the new schema for the product table. In fact, we want to associate a product with a store, therefore we use the same reasoning as the User table we extended earlier :
// src/models/product.ts
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'
import { Product as MedusaProduct } from '@medusajs/medusa'
import { Store } from './store'
@Entity()
export class Product extends MedusaProduct {
@Index('ProductStoreId')
@Column({ nullable: true })
store_id?: string
@ManyToOne(() => Store, (store) => store.products)
@JoinColumn({ name: 'store_id', referencedColumnName: 'id' })
store?: Store
}
A relationship with the Store
implies an update of the previously extended Store
model:
// src/models/store.ts
import { Entity, OneToMany } from 'typeorm'
import { Store as MedusaStore } from '@medusajs/medusa'
import { Product } from './product'
import { User } from './user'
@Entity()
export class Store extends MedusaStore {
@OneToMany(() => User, (user) => user.store)
members?: User[]
@OneToMany(() => Product, (product) => product.store)
products?: Product[]
}
Create a Migration
Once the preceding parts are completed, we may create a migration to notify the database of certain changes; the command is as follows :
npx typeorm migration:create src/migrations/add-product-store-id
Once the migration file has been created, we can update the up
and down
functions like this :
// src/migrations/...-add-product-store-id.ts
// ...
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "product" ADD "store_id" character varying`)
await queryRunner.query(`CREATE INDEX "ProductStoreId" ON "product" ("store_id")`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."ProductStoreId"`)
await queryRunner.query(`ALTER TABLE "product" DROP COLUMN "store_id"`)
}
// ...
Perfect, now we can run build our server and run the migrations, after that, the database we should see the new column in the product
table :
Adding new features
Create a Product for a Store
To create a Product tied to a Store, we'll start by extending the ProductService
and overriding the create
function to force the logged-in user's store_id
into the product before inserting it. However, you will see type problems with the input type expected on the method, therefore we will need to expand the type to add our new store_id
property :
// src/services/product.ts
import type { CreateProductInput as MedusaCreateProductInput } from '@medusajs/medusa/dist/types/product'
import { ProductService as MedusaProductService } from '@medusajs/medusa'
import { Lifetime } from 'awilix'
import type { User } from '../models/user'
import type { Product } from '../models/product'
// We override the type definition so it will not throw TS errors in the `create` method
type CreateProductInput = {
store_id?: string
} & MedusaCreateProductInput
class ProductService extends MedusaProductService {
static LIFE_TIME = Lifetime.TRANSIENT
protected readonly loggedInUser_: User | null
constructor(container) {
// @ts-ignore
super(...arguments)
try {
this.loggedInUser_ = container.loggedInUser
} catch (e) {
// avoid errors when backend first runs
}
}
async create(productObject: CreateProductInput): Promise<Product> {
if (!productObject.store_id && this.loggedInUser_?.store_id) {
productObject.store_id = this.loggedInUser_.store_id
}
return await super.create(productObject)
}
}
export default ProductService
Okay, at our level here, we can be sure that a User who is logged in AND has a µ store_id
must be able to create a product for its store.
On the other hand, when a User wants to retrieve products, there are currently no constraints preventing him from seeing ONLY its store's products, let's implement this feature.
List Products for a Store
ProductService.listAndCount
is the function that retrieves products used in the back-office, therefore we'll alter it to include the logged-in user's store_id
to retrieve only his products. However, the selector of this function does not know the store_id
property at all, so we will expand the selector to make it aware of that new property :
// ... rest
import { ProductSelector as MedusaProductSelector } from '@medusajs/medusa/dist/types/product'
type ProductSelector = {
store_id?: string
} & MedusaProductSelector
class ProductService extends MedusaProductService {
// ... rest
async listAndCount(selector: ProductSelector, config?: FindProductConfig): Promise<[Product[], number]> {
if (!selector.store_id && this.loggedInUser_?.store_id) {
selector.store_id = this.loggedInUser_.store_id
}
config.select?.push('store_id')
config.relations?.push('store')
const products = await super.listAndCount(selector, config)
return products
}
}
You can now try it on the Admin UI or directly with the API, and you should get the behavior expected.
ProductService.retrieve
, ProductService.update
and more, depending on your needsWhat's Next ?
We now have the foundation; vendors can access the admin UI and create/list products associated with their store; in the next phase, we will extend entities to allow each store to create its own shipping options.
Common Issues
A product handle must be unique
The product.handle
might create an issue in the long term when creating a product for a store, as it's supposed to be unique. You can for example either add a prefix or suffix containing the store_id
to avoid any problems :
// src/services/product.ts
class ProductService extends MedusaProductService {
// ... rest
async create(productObject: CreateProductInput): Promise<Product> {
if (!productObject.store_id && this.loggedInUser_?.store_id) {
productObject.store_id = this.loggedInUser_.store_id
// This will generate a handle for the product based on the title and store_id
// e.g. "sunglasses-01HXVYMJF9DW..."
const title = productObject.title.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g, '-').toLowerCase()
const store_id = this.loggedInUser_.store_id.replace("store_", "")
productObject.handle = `${title}-${store_id}`
}
return await super.create(productObject)
}
}
I want to retrieve products from a specific store only from my storefront
For that you'll need to create a loader, that will extend the default /store/products
relations and allowed fields.
First you'll need to create a new loader in the /src/loaders/
directory :
// src/loaders/extend-store-products.ts
export default async function (): Promise<void> {
const module = await import('@medusajs/medusa/dist/api/routes/store/products/index')
Object.assign(module, {
...module,
defaultStoreProductsRelations: [...module.defaultStoreProductsRelations, 'store'],
defaultStoreProductsFields: [...module.defaultStoreProductsFields, 'store_id'],
allowedStoreProductsRelations: [...module.allowedStoreProductsRelations, 'store'],
allowedStoreProductsFields: [...module.allowedStoreProductsFields, 'store_id'],
})
}
Then we'll extend the validators of the /store/products
API route, the goal here is to be able to add a query parameter /store/products?store_id=<STORE_ID>
, and the default Medusa validator for that API is not aware of the new property we wants to add.
Next step, is to create a file in the /src/api/
folder, named specifically index.ts
.
// src/api/index.ts
import { registerOverriddenValidators } from "@medusajs/medusa"
// Here we are importing the original Medusa validator as an alias :
import {
StoreGetProductsParams as MedusaStoreGetProductsParams,
} from "@medusajs/medusa/dist/api/routes/store/products/list-products"
import { IsString, IsOptional } from "class-validator"
// Here we add the new allowed property `store_id` :
class StoreGetProductsParams extends MedusaStoreGetProductsParams {
@IsString()
@IsOptional() // Optional of course
store_id?: string
}
// The following function will replace the original validator by the new one.
registerOverriddenValidators(StoreGetProductsParams)
Now you can successfully retrieve all products from a specific store like this.
curl http://localhost:9000/store/products?store_id=<YOUR_STORE_ID>
I have TypeScript errors !
In the same way that we have notified the database of new changes thanks to migrations, we will have to do the same with our packages, which are not aware of new properties.
Remember to declare your types according to your needs. In our case, we only need the following types :
// src/index.d.ts
import type { Product } from './models/product'
import type { Store } from './models/store'
declare module '@medusajs/medusa/dist/models/product' {
interface Product {
store_id?: string
store?: Store
}
}
GitHub Branch
You can access the complete part's code here.
Contact
You can contact me on Discord and X with the same username : @adevinwild
Subscribe to my newsletter
Read articles from Adil directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Adil
Adil
Software Engineer