Extending Vendure's functionality with custom plugins
Tags: typescript, graphql, tutorial, vendure
Published: 2023-04-04 13:36:44

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](https://www.vendure.io/) is a modern, open-source headless commerce framework built with TypeScript & Nodejs with an awesome [plugin architecture](https://www.vendure.io/docs/plugins/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](https://www.vendure.io/docs/plugins/writing-a-vendure-plugin/) to get you started with the basics. We will be using the starter [plugin-template](https://github.com/vendure-ecommerce/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.

```ts
// 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

```ts
// src/plugins/vendure-plugin-back-in-stock/types.ts

export enum BackInStockSubscriptionStatus {
    Created = 'Created',
    Notified = 'Notified',
}
```

[Entity](https://typeorm.io/entities) is a class that maps to a database table. You can create an entity by defining a new class and mark it with `@Entity()`

```ts
// 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](https://www.vendure.io/docs/plugins/plugin-examples/defining-db-entity/), we expose the entity in GraphQL API. We add our types, queries and mutations here and will use [GraphQL codegen](https://the-guild.dev/graphql/codegen) to generate our types later

```ts
// 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

```ts
// 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

```ts
// 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

```ts
// src/plugins/vendure-plugin-back-in-stock/constants.ts

export const loggerCtx = 'BackInStockPlugin';
export const PLUGIN_INIT_OPTIONS = Symbol('PLUGIN_INIT_OPTIONS');
```

```ts
// 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](https://github.com/vendure-ecommerce/one-click-deploy/blob/master/.env.example) at the root of the project

```ts
// 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`

```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

```ts
// resolver

@Mutation()
async createBackInStockSubscription(
    @Ctx() ctx: RequestContext,
    @Args() args: MutationCreateBackInStockSubscriptionArgs,
): Promise<BackInStock> {
    return this.backInStockService.create(ctx, args.input);
}
```

```ts
// 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

![Creating a subscription in GraphQL playground](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xzy35bkcq6di1a6rscla.png)

Next, make changes to resolver and service to find active subscriptions

```ts
// resolver

@Query()
async activeBackInStockSubscriptionsForProductVariant(
    @Ctx() ctx: RequestContext,
    @Args() args: QueryCreatedBackInStockWithProductVariantArgs,
): Promise<PaginatedList<BackInStock>> {
    return this.backInStockService.findActiveForProductVariant(ctx, args.input.productVariantId);
}
```

```ts
// 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

```ts
// 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]`

```ts
// 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

```hbs
// 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

![Dev mailbox to verify email is sent](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n7e9fv9vfqmis2kpw6e1.png)

I have previously written about [getting started with Vendure](https://dev.to/prasmalla/in-2023-set-up-a-nodejs-e-commerce-app-in-1-minute-with-vendure-qwik-bl7) and [contributing](https://dev.to/prasmalla/getting-started-with-open-source-contribution-66k) to this [awesome community](https://join.slack.com/t/vendure-ecommerce/shared_invite/zt-1exzio25w-vjL5TYkyJZjK52d6jkOsIA) of open-source developers. 
For further questions, you can ^slack me^ outside :D Happy coding!