Medusa Marketplace #2.2 | Extending ProductService

AdilAdil
7 min read

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 themodelsfolder, that will extend the core entity with new properties

  • Create a new migration using the TypeORM CLI

  • Create a new file in theservicesfolder 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.

💡
You can also override others method like ProductService.retrieve, ProductService.update and more, depending on your needs

What'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>
💡
The URL is just an example, replace by your own backend URL.

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

4
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