Table of contents
- What's the goal here ?
- ShippingOptionService
- ShippingProfileService
- Extend the service
- Override the ShippingProfileService.list
- Override the ShippingProfileService.create
- Override the ShippingProfile.retrieveDefault function
- Create the ShippingProfile.createDefaultForStore function
- Create your first loader
- Update the StoreService
- Update the UserService
- Create your first subscriber
- What's Next ?
- GitHub Branch
- Contact
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