Extending Vendure's functionality with custom plugins
prasanna malla
Posted on April 4, 2023
TL;DR monkey-patch is monkey-business; use plugin architecture instead!
Monkey-patching is a way to change, extend, or modify software locally. While this may be done as a workaround to a bug or a feature, a better approach is to contribute to the project if it is open-source to help the community and/or if you are writing a new feature, extend it as a custom plugin when/where supported.
Vendure is a modern, open-source headless commerce framework built with TypeScript & Nodejs with an awesome plugin architecture to keep the monkey-business at bay.
Use-case: product inventory goes out of stock; this is a good thing! but when a potential customer sees "out of stock" is when it's not.
Wish-case: let the customer know when the product is back in stock.
Let's build a custom Back-In-Stock plugin!
Here is quick intro to writing a Vendure plugin to get you started with the basics. We will be using the starter plugin-template provided by Vendure for reference to start our journey. Here is the initial plugin setup that includes the entity, GraphQL api extensions, resolver for it and the service that talks to the database. We will be adding email notification here later.
// src/plugins/vendure-plugin-back-in-stock/back-in-stock.plugin.ts
import { PluginCommonModule, VendurePlugin } from '@vendure/core';
import { PLUGIN_INIT_OPTIONS } from './constants';
import { commonApiExtensions } from './api/api-extensions';
import { BackInStockResolver } from './api/back-in-stock.resolver';
import { BackInStock } from './entity/back-in-stock.entity';
import { BackInStockService } from './service/back-in-stock.service';
export interface BackInStockOptions {
enabled: boolean;
}
@VendurePlugin({
imports: [PluginCommonModule],
entities: [BackInStock],
providers: [
{
provide: PLUGIN_INIT_OPTIONS,
useFactory: () => BackInStockPlugin.options,
},
BackInStockService,
],
shopApiExtensions: {
schema: commonApiExtensions,
resolvers: [BackInStockResolver],
},
})
export class BackInStockPlugin {
static options: BackInStockOptions;
static init(options: BackInStockOptions) {
this.options = options;
return BackInStockPlugin;
}
}
Define the custom enum type that will be used as status in the entity
// src/plugins/vendure-plugin-back-in-stock/types.ts
export enum BackInStockSubscriptionStatus {
Created = 'Created',
Notified = 'Notified',
}
Entity is a class that maps to a database table. You can create an entity by defining a new class and mark it with @Entity()
// src/plugins/vendure-plugin-back-in-stock/entity/back-in-stock.entity.ts
import { DeepPartial } from '@vendure/common/lib/shared-types';
import { Channel, Customer, ProductVariant, VendureEntity } from '@vendure/core';
import { Column, Entity, ManyToOne } from 'typeorm';
import { BackInStockSubscriptionStatus } from '../types';
/**
* @description
* A back-in-stock notification is subscribed to by a {@link Customer}
* or an user with email address.
*
*/
@Entity()
export class BackInStock extends VendureEntity {
constructor(input?: DeepPartial<BackInStock>) {
super(input);
}
@Column('enum', { nullable: false, enum: BackInStockSubscriptionStatus })
status: BackInStockSubscriptionStatus;
@ManyToOne(type => ProductVariant, { nullable: false })
productVariant: ProductVariant;
@ManyToOne(type => Channel, { nullable: false })
channel: Channel;
@ManyToOne(type => Customer, { nullable: true })
customer: Customer;
@Column('varchar', { nullable: false })
email: string;
}
After defining a new database entity, we expose the entity in GraphQL API. We add our types, queries and mutations here and will use GraphQL codegen to generate our types later
// src/plugins/vendure-plugin-back-in-stock/api/api-extensions.ts
import { gql } from 'graphql-tag';
export const commonApiExtensions = gql`
type BackInStock implements Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
status: BackInStockSubscriptionStatus!
productVariant: ProductVariant!
channel: Channel!
customer: Customer
email: String!
}
enum BackInStockSubscriptionStatus {
Created
Notified
Converted
}
input CreateBackInStockEmailInput {
email: String!
productVariantId: ID!
}
input ProductVariantInput {
productVariantId: ID!
}
type BackInStockList implements PaginatedList {
items: [BackInStock!]!
totalItems: Int!
}
extend type Query {
backInStockSubscriptions: BackInStockList!
activeBackInStockSubscriptionsForProductVariant(input: ProductVariantInput): BackInStockList!
}
extend type Mutation {
createBackInStockSubscription(input: CreateBackInStockInput!): BackInStock!
}
`;
export const shopApiExtensions = gql`
${commonApiExtensions}
`;
export const adminApiExtensions = gql`
${commonApiExtensions}
`;
Resolver for the GraphQL query to find all subscriptions
// src/plugins/vendure-plugin-back-in-stock/api/back-in-stock.resolver.ts
import { Inject } from '@nestjs/common';
import { Args, Resolver, Query } from '@nestjs/graphql';
import { RequestContext, Ctx, PaginatedList } from '@vendure/core';
import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants';
import { BackInStockOptions } from '../back-in-stock.plugin';
import { BackInStock } from '../entity/back-in-stock.entity';
import { BackInStockService } from '../service/back-in-stock.service';
@Resolver()
export class BackInStockResolver {
constructor(
@Inject(PLUGIN_INIT_OPTIONS) private options: BackInStockOptions,
private backInStockService: BackInStockService,
) {}
@Query()
async backInStockSubscriptions(
@Ctx() ctx: RequestContext,
@Args() args: any,
): Promise<PaginatedList<BackInStock>> {
return this.backInStockService.findAll(ctx, args.options);
}
}
Service uses methods/functions to perform operations with the model/entity. Here we find all subscriptions and return a paginated list
// src/plugins/vendure-plugin-back-in-stock/service/back-in-stock.service.ts
import { Injectable } from '@nestjs/common';
import { PaginatedList } from '@vendure/common/lib/shared-types';
import {
ListQueryBuilder,
RequestContext,
ListQueryOptions,
RelationPaths,
Channel,
Customer,
ProductVariant,
} from '@vendure/core';
import { BackInStock } from '../entity/back-in-stock.entity';
/**
* @description
* Contains methods relating to {@link BackInStock} entities.
*
*/
@Injectable()
export class BackInStockService {
private readonly relations = ['productVariant', 'channel', 'customer'];
constructor(private listQueryBuilder: ListQueryBuilder) {}
async findAll(
ctx: RequestContext,
options?: ListQueryOptions<BackInStock>,
relations?: RelationPaths<ProductVariant> | RelationPaths<Channel> | RelationPaths<Customer>,
): Promise<PaginatedList<BackInStock>> {
return this.listQueryBuilder
.build(BackInStock, options, {
relations: relations || this.relations,
ctx,
})
.getManyAndCount()
.then(async ([items, totalItems]) => {
return {
items,
totalItems,
};
});
}
}
The following is boilerplate setup common to all plugins
// src/plugins/vendure-plugin-back-in-stock/constants.ts
export const loggerCtx = 'BackInStockPlugin';
export const PLUGIN_INIT_OPTIONS = Symbol('PLUGIN_INIT_OPTIONS');
// src/plugins/vendure-plugin-back-in-stock/index.ts
export * from './back-in-stock.plugin';
Finally, the vendure-config.ts
for an example dev server, a starter app created with @vendure/create
, using Postgres for database defined in an .env file at the root of the project
// src/vendure-config.ts
require('dotenv').config();
import {
dummyPaymentHandler,
DefaultJobQueuePlugin,
DefaultSearchPlugin,
VendureConfig,
} from '@vendure/core';
import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
import { AssetServerPlugin } from '@vendure/asset-server-plugin';
import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
import path from 'path';
import { BackInStockPlugin } from './plugins/back-in-stock-plugin';
const IS_DEV = process.env.APP_ENV === 'dev';
export const config: VendureConfig = {
apiOptions: {
port: 3000,
adminApiPath: 'admin-api',
shopApiPath: 'shop-api',
// The following options are useful in development mode,
// but are best turned off for production for security
// reasons.
...(IS_DEV
? {
adminApiPlayground: {
settings: { 'request.credentials': 'include' } as any,
},
adminApiDebug: true,
shopApiPlayground: {
settings: { 'request.credentials': 'include' } as any,
},
shopApiDebug: true,
}
: {}),
},
authOptions: {
tokenMethod: ['bearer', 'cookie'],
superadminCredentials: {
identifier: process.env.SUPERADMIN_USERNAME,
password: process.env.SUPERADMIN_PASSWORD,
},
cookieOptions: {
secret: process.env.COOKIE_SECRET,
},
},
dbConnectionOptions: {
type: 'postgres',
// See the README.md "Migrations" section for an explanation of
// the `synchronize` and `migrations` options.
synchronize: true,
migrations: [path.join(__dirname, './migrations/*.+(js|ts)')],
logging: false,
database: process.env.DB_NAME,
schema: process.env.DB_SCHEMA,
host: process.env.DB_HOST,
port: +process.env.DB_PORT,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
},
paymentOptions: {
paymentMethodHandlers: [dummyPaymentHandler],
},
// When adding or altering custom field definitions, the database will
// need to be updated. See the "Migrations" section in README.md.
customFields: {},
plugins: [
AssetServerPlugin.init({
route: 'assets',
assetUploadDir: path.join(__dirname, '../static/assets'),
// For local dev, the correct value for assetUrlPrefix should
// be guessed correctly, but for production it will usually need
// to be set manually to match your production url.
assetUrlPrefix: IS_DEV ? undefined : 'https://www.my-shop.com/assets',
}),
DefaultJobQueuePlugin.init({ useDatabaseForBuffer: true }),
DefaultSearchPlugin.init({
bufferUpdates: false,
indexStockStatus: true,
}),
EmailPlugin.init({
devMode: true,
outputPath: path.join(__dirname, '../static/email/test-emails'),
route: 'mailbox',
handlers: [...defaultEmailHandlers],
templatePath: path.join(__dirname, '../static/email/templates'),
globalTemplateVars: {
// The following variables will change depending on your storefront implementation.
// Here we are assuming a storefront running at http://localhost:8080.
fromAddress: '"example" <noreply@example.com>',
verifyEmailAddressUrl: 'http://localhost:8080/verify',
passwordResetUrl: 'http://localhost:8080/password-reset',
changeEmailAddressUrl:
'http://localhost:8080/verify-email-address-change',
},
}),
AdminUiPlugin.init({
route: 'admin',
port: 3002,
}),
// ADD CUSTOM PLUGINS HERE
BackInStockPlugin.init({ enabled: true }),
],
};
Notice BackInStockPlugin added to the plugins array towards the end. Start the server with yarn dev
and go to the GraphQL playground at http://localhost:3000/shop-api to find backInStockSubscriptions query.
Now let's setup codegen to generate types from the schema. Create the following json file in the root of your project. Install it with
yarn add -D @graphql-codegen/cli
and run it with
yarn graphql-codegen --config ./codegen.json
// codegen.json
{
"overwrite": true,
"config": {
"strict": true,
"namingConvention": {
"enumValues": "keep"
},
"scalars": {
"ID": "string | number"
},
"maybeValue": "T"
},
"generates": {
"generated/generated-admin-types.ts": {
"schema": "http://localhost:3000/admin-api",
"plugins": [
{
"add": {
"content": "/* eslint-disable */"
}
},
"typescript",
"typescript-compatibility"
]
},
"generated/generated-shop-types.ts": {
"schema": "http://localhost:3000/shop-api",
"plugins": [
{
"add": {
"content": "/* eslint-disable */"
}
},
"typescript",
"typescript-compatibility"
]
}
}
}
Next, we need to add the mutation so we can create some back-in-stock subscriptions. Add the following changes to the resolver and service and remember to import everything missing, your IDE should guide you here
// resolver
@Mutation()
async createBackInStockSubscription(
@Ctx() ctx: RequestContext,
@Args() args: MutationCreateBackInStockSubscriptionArgs,
): Promise<BackInStock> {
return this.backInStockService.create(ctx, args.input);
}
// service
constructor(
private connection: TransactionalConnection,
private listQueryBuilder: ListQueryBuilder,
private channelService: ChannelService,
private customerService: CustomerService,
private productVariantService: ProductVariantService,
) {}
async findAll... // This is unchanged
async create(ctx: RequestContext, input: CreateBackInStockInput): Promise<BackInStock> {
const { email, productVariantId } = input;
const channel = await this.channelService.getChannelFromToken(ctx.channel.token);
const customer = await this.customerService.findOneByUserId(ctx, ctx.activeUserId as ID);
const productVariant = await this.productVariantService.findOne(ctx, productVariantId);
const backInStockSubscription = new BackInStock({
email,
productVariant,
channel,
customer,
status: BackInStockSubscriptionStatus.Created,
});
return await this.connection.getRepository(ctx, BackInStock).save(backInStockSubscription);
}
Create a subscription in the shop-api playground
Next, make changes to resolver and service to find active subscriptions
// resolver
@Query()
async activeBackInStockSubscriptionsForProductVariant(
@Ctx() ctx: RequestContext,
@Args() args: QueryCreatedBackInStockWithProductVariantArgs,
): Promise<PaginatedList<BackInStock>> {
return this.backInStockService.findActiveForProductVariant(ctx, args.input.productVariantId);
}
// service
async findActiveForProductVariant(
ctx: RequestContext,
productVariantId: ID,
options?: ListQueryOptions<BackInStock>,
relations?: RelationPaths<Channel> | RelationPaths<Customer>,
): Promise<PaginatedList<BackInStock>> {
const productVariant = await this.productVariantService.findOne(ctx, productVariantId);
return this.listQueryBuilder
.build(BackInStock, options, {
relations: relations || this.relations,
ctx,
where: {
productVariant,
status: BackInStockSubscriptionStatus.Created,
},
})
.getManyAndCount()
.then(async ([items, totalItems]) => {
return {
items,
totalItems,
};
});
}
Finally, we have all the pieces to make the actual use-case work, which is to send an email notification when a subscribed out-of-stock product is replenished. We will leverage the EventBus in Vendure to achieve this
// src/plugins/vendure-plugin-back-in-stock/events/back-in-stock.event.ts
import { ProductVariant, RequestContext, VendureEvent } from '@vendure/core';
import { BackInStock } from '../entity/back-in-stock.entity';
/**
* @description
* This event is fired when a {@link BackInStock} is updated
*
*/
export class BackInStockEvent extends VendureEvent {
constructor(
public ctx: RequestContext,
public entity: BackInStock,
public productVariant: ProductVariant,
public type: 'created' | 'updated' | 'deleted',
public emailAddress: string,
) {
super();
}
}
Now, update the plugin to listen for events and setup the email handler, remember to import it to vendure-config.ts
handlers: [...defaultEmailHandlers, backInStockEmailHandler]
// back-in-stock.plugin.ts
constructor(
private eventBus: EventBus,
private backInStockService: BackInStockService,
private productVariantService: ProductVariantService,
) {}
/**
* @description
* Subscribes to {@link ProductVariantEvent} inventory changes
* and sends FIFO BackInStock {@link BackInStock} notifications
* to the amount of saleable stock.
*/
async onApplicationBootstrap() {
this.eventBus.ofType(ProductVariantEvent).subscribe(async event => {
for (const productVariant of event.entity) {
const saleableStock = await this.productVariantService.getSaleableStockLevel(
event.ctx,
productVariant,
);
const backInStockSubscriptions = await this.backInStockService.findActiveForProductVariant(
event.ctx,
productVariant.id,
{
take: saleableStock,
sort: {
createdAt: SortOrder.ASC,
},
},
);
if (saleableStock >= 1 && backInStockSubscriptions.totalItems >= 1) {
for (const subscription of backInStockSubscriptions.items) {
this.eventBus.publish(
new BackInStockEvent(
event.ctx,
subscription,
productVariant,
'updated',
subscription.email,
),
);
this.backInStockService.update(event.ctx, {
id: subscription.id,
status: BackInStockSubscriptionStatus.Notified,
});
}
}
}
});
}
// email handler
export const backInStockEmailHandler = new EmailEventListener('back-in-stock')
.on(BackInStockEvent)
.setRecipient(event => event?.emailAddress)
.setFrom(`{{ fromAddress }}`)
.setSubject(`{{ productVariant.name }} - Back in Stock!`)
.loadData(async ({ event, injector }) => {
const hydrator = injector.get(EntityHydrator);
await hydrator.hydrate(event.ctx, event.productVariant, {
relations: ['product'],
});
return { productVariant: event.productVariant };
})
.setTemplateVars(event => ({
productVariant: event.productVariant,
url: 'http://localhost:8080/products',
}));
We are using loadData
to hydrate the product variant here so we can use the product's slug in the email template
// static/email/templates/back-in-stock/body.hbs
{{> header title="{{productVariant.name}} - Back In Stock!" }}
<mj-section background-color="#fafafa">
<mj-column>
<mj-text color="#525252">
{{ productVariant.name }} is now back in stock!
</mj-text>
<mj-button font-family="Helvetica"
background-color="#f45e43"
color="white"
href="{{ url }}/{{ productVariant.product.slug }}">
View Product
</mj-button>
</mj-column>
</mj-section>
{{> footer }}
Finally, finally, replenish an out-of-stock variant with active back-in-stock subscription at http://localhost:3000/admin and verify the email is being sent at http://localhost:3000/mailbox
I have previously written about getting started with Vendure and contributing to this awesome community of open-source developers.
For further questions, you can ^slack me^ outside :D Happy coding!
Posted on April 4, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.