Medusa Marketplace #2.4 | Extending Shipping services

Medusa Marketplace #2.4 | Extending Shipping services

·

8 min read

Hello everyone!

Now that we've extended the ShippingOption and ShippingProfile entities, we can start integrating new features into our services.

What's the goal here ?

The aim of this part is to override various functions of the ShippingOption and ShippingProfile services, so that vendors can only access their own shipping options/profiles, and create a shipping option/profile for their store only.

We'll also look at the concept of loaders, to create a default ShippingProfile for each store.

ShippingOptionService

Extend the service

Let's start by creating the ShippingOptionService file :

// src/services/shipping-option.ts
import { ShippingOptionService as MedusaShippingOptionService } from '@medusajs/medusa'
import { Lifetime } from 'awilix'

import type { User } from '../models/user'

class ShippingOptionService extends MedusaShippingOptionService {
    static LIFE_TIME = Lifetime.TRANSIENT
    protected readonly loggedInUser_: User | null

    constructor(container, options) {
        // @ts-ignore
        super(...arguments)

        try {
            this.loggedInUser_ = container.loggedInUser
        } catch (e) {
            // avoid errors when backend first runs
        }
    }

}

export default ShippingOptionService

Override the ShippingOptionService.list function

We will now override the ShippingOptionService.list function and the ShippingOptionService.listAndCount function:

import { ShippingOptionService as MedusaShippingOptionService } from '@medusajs/medusa'
import { Lifetime } from 'awilix'

import { FindConfig, Selector } from '@medusajs/medusa'

import type { ShippingOption } from '../models/shipping-option'
import type { User } from '../models/user'

type ShippingOptionSelector = {
    store_id?: string
} & Selector<ShippingOption>

class ShippingOptionService extends MedusaShippingOptionService {   
     // ... rest

    async list(
        selector?: ShippingOptionSelector & { q?: string },
        config?: FindConfig<ShippingOption>,
    ): Promise<ShippingOption[]> {
        if (!selector.store_id && this.loggedInUser_?.store_id) {
            selector.store_id = this.loggedInUser_.store_id
        }

        config.select?.push('store_id')

        config.relations?.push('store')

        return await super.list(selector, config)
    }

    async listAndCount(
        selector?: ShippingOptionSelector & { q?: string },
        config?: FindConfig<ShippingOption>,
    ): Promise<[ShippingOption[], 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')

        return await super.listAndCount(selector, config)
    }
}

Override the ShippingOptionService.create function

We also override the ShippingOptionService.create function and the CreateShippingOptionInput type to allow for a store_id property :

// ... other imports
import { CreateShippingOptionInput as MedusaCreateShippingOptionInput } from '@medusajs/medusa/dist/types/shipping-options'

type CreateShippingOptionInput = {
    store_id?: string
} & MedusaCreateShippingOptionInput

class ShippingOptionService extends MedusaShippingOptionService {
    // ... rest 
   async create(data: CreateShippingOptionInput): Promise<ShippingOption> {
        if (!data.store_id && this.loggedInUser_?.store_id) {
            data.store_id = this.loggedInUser_.store_id
        }

        return await super.create(data)
    }
}

export default ShippingOptionService

Override the ShippingOptionService.validateCartOption

A final function to be overrided is the validateCartOption function, which validates a ShippingOption for a Cart before saving it in the Cart. Here, we're going to make sure that a chosen shipping option, has one or more products that belongs to the same store :

class ShippingOptionService extends MedusaShippingOptionService {
    // ... rest
    async validateCartOption(option: ShippingOption, cart: Cart): Promise<ShippingOption | null> {
        const validatedOption = await super.validateCartOption(option, cart)

        const hasAnItemFromStore = cart.items.some((item) => item.variant.product.store_id === option.store_id)

        if (!hasAnItemFromStore) {
            throw new MedusaError(MedusaError.Types.NOT_ALLOWED, 'Shipping option does not exist for store')
        }

        return validatedOption
    }
}

export default ShippingOptionService

ShippingProfileService

Extend the service

Before launching our server and starting to create ShippingOption, we need to extend ShippingProfileService :

import {
    ShippingProfileService as MedusaShippingProfileService
} from '@medusajs/medusa'
import { Lifetime } from 'awilix'

import type { User } from '../models/user'


class ShippingProfileService extends MedusaShippingProfileService {
    static LIFE_TIME = Lifetime.TRANSIENT
    protected readonly loggedInUser_: User | null

    constructor(container, options) {
        // @ts-ignore
        super(...arguments)

        try {
            this.loggedInUser_ = container.loggedInUser
        } catch (e) {
            // avoid errors when backend first runs
        }
    }
}

export default ShippingProfileService

Override the ShippingProfileService.list

By overriding this function, we can ensure that only the ShippingProfiles of the connected user's store are listed:

import {
    FindConfig,
    ShippingProfileService as MedusaShippingProfileService,
    Selector
} from '@medusajs/medusa'
import { Lifetime } from 'awilix'

import { ShippingProfile } from '../models/shipping-profile'
import type { User } from '../models/user'

type ShippingProfileSelector = {
    store_id?: string
} & Selector<ShippingProfile>

class ShippingProfileService extends MedusaShippingProfileService {
    static LIFE_TIME = Lifetime.TRANSIENT
    protected readonly loggedInUser_: User | null

    constructor(container, options) {
        // @ts-ignore
        super(...arguments)

        try {
            this.loggedInUser_ = container.loggedInUser
        } catch (e) {
            //  avoid errors when backend first runs
        }
    }

    async list(
        selector: ShippingProfileSelector = {},
        config: FindConfig<ShippingProfile> = {}
    ): Promise<ShippingProfile[]> {
        if (!selector.store_id && this.loggedInUser_?.store_id){
            selector.store_id = this.loggedInUser_.store_id
        }

        config.relations = [...(config.relations ?? []), 'store'];

        config.select = [
            ...(config.select ?? []),
            'id',
            'name',
            'created_at',
            'deleted_at',
            'updated_at',
            'type',
            'store_id',
            'metadata',
        ];

        return await super.list(selector, config);
    }
}

export default ShippingProfileService

Override the ShippingProfileService.create

When creating a ShippingProfile, if a user is logged in, we can assign its store_id :

// ...imports
import { CreateShippingProfile as MedusaCreateShippingProfile } from '@medusajs/medusa/dist/types/shipping-profile'

type CreateShippingProfile = {
    store_id?: string
} & MedusaCreateShippingProfile

class ShippingProfileService extends MedusaShippingProfileService { 
    // ...rest
    async create(profile: CreateShippingProfile): Promise<ShippingProfile> {
        if (!profile.store_id && this.loggedInUser_?.store_id) {
            profile.store_id = this.loggedInUser_.store_id
        }

        return await super.create(profile)
    }
}

export default ShippingProfileService

Override the ShippingProfile.retrieveDefault function

Overriding this function allows for new created products being linked to the store's shipping profile, this is useful for the storefront, since it will fetch Cart options depending on the shipping profile of the current products in the Cart :

    async retrieveDefault(): Promise<ShippingProfile> {
        if (this.loggedInUser_?.store_id) {
            return this.shippingProfileRepository_.findOne({
                where: {
                    type: ShippingProfileType.CUSTOM,
                    store_id: this.loggedInUser_.store_id
                }
            })
        }

        return super.retrieveDefault()
    }

Create the ShippingProfile.createDefaultForStore function

This function will later allow us to create default ShippingProfile for our stores:

class ShippingProfileService extends MedusaShippingProfileService { 
    // ...rest
    async createDefaultForStore(storeId: string): Promise<ShippingProfile> {
        return await this.atomicPhase_(async (manager) => {
            const profileRepository = manager.withRepository(this.shippingProfileRepository_)

            const profile = await profileRepository.findOne({ where: { store_id: storeId } })
            if (profile) {
                return profile
            }

            const toCreate: Partial<ShippingProfile> = {
                type: ShippingProfileType.CUSTOM,
                name: 'Default Shipping Profile',
                store_id: storeId,
            }

            const created = profileRepository.create(toCreate)

            return await profileRepository.save(created)
        })
    }
}

export default ShippingProfileService

Here we have wrapped our process into a transaction (this.atomicPhase_) , so that in the case of an error throwed when creating a user (for example, an email already in use), no event is sent and it will rollback changes.

Create your first loader

Before launching our server and creating ShippingOption, we're going to make sure that each store has its own default ShippingProfile.

To do this, we're going to make sure that, when the server starts, shipping profiles are created automatically for each new store.

In our case, we're going to create a file in the src/loaders/create-defaults-shipping-profiles.ts folder :

import { Logger, MedusaContainer } from '@medusajs/medusa'
import type StoreRepository from '@medusajs/medusa/dist/repositories/store'
import type ShippingProfileService from '../services/shipping-profile'

export default async function (container: MedusaContainer): Promise<void> {
    const shippingProfileService = container.resolve<ShippingProfileService>('shippingProfileService')
    const storeRepo = container.resolve<typeof StoreRepository>('storeRepository')
    const logger = container.resolve<Logger>('logger')

    const allStores = await storeRepo.find({
        select: ['id'],
    })

    if (!allStores.length) {
        return
    }

    const allShippingProfiles = await shippingProfileService.list()

    for (const store of allStores) {
        const storeId = store.id

        // For each store, we check that there is a default shipping profile
        // if not, we create one
        if (!allShippingProfiles.find((profile) => profile.store_id === storeId)) {
            const promise = logger.activity(`Creating default shipping profile for store ${storeId}...`)
            await shippingProfileService.createDefaultForStore(storeId)
            logger.success(promise, `Created default shipping profile for store ${storeId}!`)
        }
    }
}

Perfect, but this will only work when we start our server, so we'll also make sure that when a store is created, a default shipping profile is created, so that nothing needs to be restarted.

Update the StoreService

We're going to update our StoreService so that it exposes a kind of enum that will represent events. These events will be emitted and listened to afterwards:

// src/services/store.ts

// ... imports

class StoreService extends MedusaStoreService {
    static LIFE_TIME = Lifetime.TRANSIENT
    protected readonly loggedInUser_: User | null

    static Events = {
        CREATED: 'store.created', // ✅ We had a static property to access easily events
    }

   // ... rest

}

export default StoreService

Update the UserService

Now we can use this event name in our UserService when creating a new store:

// src/services/user.ts

// ...imports
class UserService extends MedusaUserService {
    // ... rest

    async create(
        user: CreateUserInput,
        password: string
    ): Promise<User> {
        return await this.atomicPhase_(async (m) => {
            const storeRepo = m.withRepository(this.storeRepository_)

            if (!user.store_id) {
                let newStore = storeRepo.create()
                newStore = await storeRepo.save(newStore)
                user.store_id = newStore.id
            }

            const savedUser = await super.create(user, password)

            this.eventBus_.emit(StoreService.Events.CREATED, { id: user.store_id })

            return savedUser
        })
    }
}

export default UserService

Create your first subscriber

Once our event is ready to emit, all we need to do is create a Subscriber that will listen and execute code when it receives the event. In this case, we'll create a ShippingProfile each time a new store is created:

// src/subscribers/store-created.ts
import type { Logger, SubscriberArgs, SubscriberConfig } from '@medusajs/medusa'

import ShippingProfileService from '../services/shipping-profile'
import StoreService from '../services/store'

export default async function handleStoreCreated({
    data,
    eventName,
    container,
    pluginOptions,
}: SubscriberArgs<Record<string, string>>) {

    const shippingProfileService = container.resolve<ShippingProfileService>("shippingProfileService")
    const logger = container.resolve<Logger>("logger")

    const promise = logger.activity(`Creating default shipping profile for store ${data.id}`)

    await shippingProfileService.createDefaultForStore(data.id).catch(e => {
        logger.error(e, `Error creating default shipping profile for store ${data.id}`)
        throw e
    })

    logger.success(promise, `Default shipping profile for store ${data.id} created`)
}

export const config: SubscriberConfig = {
    event: StoreService.Events.CREATED,
    context: {
        subscriberId: 'store-created-handler',
    },
}

The ShippingProfile is now inserted each time a new store is created, without having to restart our server and wait for our loader to process :

We can now take the time to test the Admin UI with two separate accounts, for example.

In the screenshot below, we can see that each one sees only its own Shipping options, linked to their own Shipping Profile under the hood :

What's Next ?

In the next part, we'll look into Orders, specifically how to ensure that each vendor can handle their own order. This will be an opportunity to use all we've learned in the previous parts, as well as to reuse the Subscriber notion from that current part!

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

Did you find this article valuable?

Support perseides by becoming a sponsor. Any amount is appreciated!