DEV Community

prasanna malla
prasanna malla

Posted on

Shipping new features to our NPM package

Earlier, we published a Vendure plugin to NPM and fixed our first bug. Now, it is time to add new features and publish an update.

On the Todo after extending the AdminUI in Vendure, was the ability for sending back in stock email notifications from the admin. We have the button; let's get to the functionality! All interactions with the server need to be done via GraphQL API calls in the admin ui.

Add the function call to the button and create the notify method

// back-in-stock-list.component.html

<td class="right align-middle">
    <button
        *ngIf="subscription.status === 'Created' && subscription.productVariant.stockOnHand > 0"
        class="icon-button"
        title="Send notification email"
        (click)="notify(subscription.id)"
    >
        <clr-icon shape="envelope"></clr-icon>
    </button>
</td>
Enter fullscreen mode Exit fullscreen mode
// back-in-stock-list.component.ts

async notify(id: ID): Promise<void> {
    try {
        await this.dataService
            .mutate(
                gql`
                    mutation {
                        updateBackInStockSubscription(input: {id: "${id}", status: Notified}) {
                            id
                            status
                        }
                    }
                `,
            )
            .toPromise();
        this.notificationService.success('Notification email sent');
        this.refresh();
    } catch (e) {
        this.notificationService.error('Error');
    }
}
Enter fullscreen mode Exit fullscreen mode

The hard rule is that in an admin UI extension, you cannot import anything from @vendure/core or anything else outside the ui folder. The setup for sending off emails is event driven and came across this hard rule when importing EventBus with this misleading error screen, having nothing to do with other packages as shown. Removing EventBus made this go away

VSCode error screenshot

Instead, we publish the BackInStockEvent from the update method in BackInStockService. Email sending from admin works! Next Todo item was limiting emails sent to amount of saleable stock with configurable options.

Vendure is built-on solid foundations using TypeORM with Nest.js which allows us to define database subscribers. With a subscriber, we can listen to specific entity events and take actions based on inserts, updates, deletions and more.

An important factor when working with TypeORM subscribers is that they are very low-level and require some understanding of the Vendure schema. By defining the subscriber as an injectable provider, and passing it to a Vendure plugin, you can take advantage of Nest's dependency injection inside the subscriber methods.

Under the hood, TypeORM uses the Observer design pattern to implement database subscribers. This pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. When you register a subscriber, TypeORM sets up database triggers or hooks on the entities that the subscriber is interested in.

When the trigger or hook is fired, the database notifies TypeORM of the event. TypeORM then invokes the corresponding method on the subscriber class, passing in the event data. The subscriber can then perform any custom logic it needs to in response to the event.

This approach is much more efficient than polling the database because it eliminates the need to constantly query the database for changes. Instead, the database itself is responsible for detecting events and notifying the subscribers. This leads to better performance and reduced database load.

Create a subscriber for ProductVariant

/**
 * @description
 * Subscribes to {@link ProductVariant} entity changes
 * and FIFO updates {@link BackInStock} to be notified
 * to the amount of saleable stock with plugin init option
 * limitEmailToStock = true or false to notify all subscribers
 *
 */
@Injectable()
@EventSubscriber()
export class ProductVariantSubscriber implements EntitySubscriberInterface<ProductVariant> {
    constructor(
        private connection: TransactionalConnection,
        private backInStockService: BackInStockService,
        private productVariantService: ProductVariantService,
        private requestContextService: RequestContextService,
    ) {
        this.connection.rawConnection.subscribers.push(this);
    }
    listenTo() {
        return ProductVariant;
    }

    // set subscriptions to be notified only on replenishment event
    async afterUpdate(event: UpdateEvent<ProductVariant>) {
        if (
            event.entity?.stockOnHand > event.databaseEntity?.stockOnHand
        ) {
            const ctx = await this.requestContextService.create({ apiType: getApiType() });
            const productVariant = await this.productVariantService.findOne(ctx, event.entity?.id);
            //! calculate saleable manually as this context is not aware of the current db transaction
            const saleableStock =
                event.entity?.stockOnHand -
                productVariant!.stockAllocated -
                productVariant!.outOfStockThreshold;

            const backInStockSubscriptions = await this.backInStockService.findActiveForProductVariant(
                ctx,
                productVariant!.id,
                {
                    take: BackInStockPlugin.options.limitEmailToStock ? saleableStock : undefined,
                    sort: {
                        createdAt: SortOrder.ASC,
                    },
                },
            );

            if (saleableStock >= 1 && backInStockSubscriptions.totalItems >= 1) {
                for (const subscription of backInStockSubscriptions.items) {
                    this.backInStockService.update(ctx, {
                        id: subscription.id,
                        status: BackInStockSubscriptionStatus.Notified,
                    });
                }
            }
        }
    }
}

@VendurePlugin({
    // .. config
    providers: [
        {
            provide: PLUGIN_INIT_OPTIONS,
            useFactory: () => BackInStockPlugin.options,
        },
        BackInStockService,
        ProductVariantSubscriber,
    ],
})

export class BackInStockPlugin {
    static options: BackInStockOptions = {
        enableEmail: true,
        limitEmailToStock: true,
    };
    static init(options: BackInStockOptions): typeof BackInStockPlugin {
        this.options = options;
        return BackInStockPlugin;
    }
    // .. ui extensions
}
Enter fullscreen mode Exit fullscreen mode

By comparing the stockOnHand on the ongoing DB transaction event.entity and the existing value on event.databaseEntity we can determine replenishment event. Since the new context in the subscriber is not aware of the ongoing DB transaction, we need to change setting saleableStock from ProductVariantService to manual calculation. With the ProductVariantSubscriber added to the providers array and we are ready to test it!

Instead of publishing your changes, you can run yarn build and copy over the dist folder to the node_modules/@callit-today/vendure-plugin-back-in-stock in a test app. Since there is no un-publishing versions in NPM this ensures our new features work and protects against { sometimes 🙋 } forgetting to build before publishing.

Finally, run npm publish and done! You can catch me on Vendure slack for more)

Top comments (0)