DEV Community

Cover image for Extending Vendure's functionality with custom plugins
prasanna malla
prasanna malla

Posted on • Updated on

Extending Vendure's functionality with custom plugins

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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}
`;
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
                };
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode
// src/plugins/vendure-plugin-back-in-stock/index.ts

export * from './back-in-stock.plugin';
Enter fullscreen mode Exit fullscreen mode

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 }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

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"
            ]
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode
// 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);
}
Enter fullscreen mode Exit fullscreen mode

Create a subscription in the shop-api playground

Creating a subscription in GraphQL 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);
}
Enter fullscreen mode Exit fullscreen mode
// 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,
            };
        });
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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',
    }));
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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

Dev mailbox to verify email is sent

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!

Top comments (0)