--- title: "Custom Template Loader" weight: 10 date: 2023-07-14T16:57:50.756Z showtoc: true generated: true --- # Custom Template Loader
# TemplateLoader {{< generation-info sourceFile="packages/email-plugin/src/types.ts" sourceLine="390" packageName="@vendure/email-plugin">}} Load an email template based on the given request context, type and template name and return the template as a string. *Example* ```TypeScript import { EmailPlugin, TemplateLoader } from '@vendure/email-plugin'; class MyTemplateLoader implements TemplateLoader { loadTemplate(injector, ctx, { type, templateName, templateVars }){ return myCustomTemplateFunction(ctx); } } // In vendure-config.ts: ... EmailPlugin.init({ templateLoader: new MyTemplateLoader() ... }) ``` ## Signature ```TypeScript interface TemplateLoader { loadTemplate(injector: Injector, ctx: RequestContext, input: LoadTemplateInput): Promise; loadPartials?(): Promise; } ``` ## Members ### loadTemplate {{< member-info kind="method" type="(injector: Injector, ctx: RequestContext, input: LoadTemplateInput) => Promise<string>" >}} {{< member-description >}}{{< /member-description >}} ### loadPartials {{< member-info kind="method" type="() => Promise<Partial[]>" >}} {{< member-description >}}{{< /member-description >}}
--- --- title: TODO --- This part still needs to be written! ``` ( ° ᴗ°)~ð (/❛o❛\) ``` --- --- title: 'Auth' --- **Authentication** is the process of determining the identity of a user. Common ways of authenticating a user are by asking the user for secret credentials (username & password) or by a third-party authentication provider such as Facebook or Google login. **Authorization** is a related concept, which means that once we have verified the identity of a user, we can then determine what that user is allowed to do. For example, a user may be authorized to view a product, but not to edit it. The term **auth** is shorthand for _both_ authentication and authorization. Auth in Vendure applies to both **administrators** and **customers**. Authentication is controlled by the configured `AuthenticationStrategies`, and authorization is controlled by the configured `Roles` and `Permissions`. ## Administrator auth Administrators are required to authenticate before they can perform any operations in the Admin API. Here is a diagram of the parts that make up Administrator authentication: ![Administrator authentication](./admin.webp) Roles can be created to allow fine-grained control over what a particular administrator has access to (see the section below). ## Customer auth Customer only need to authenticate if they want to access a restricted operation related to their account, such as viewing past orders or updating an address. Here are the parts that make up Customer authentication: ![Customer authentication](./customer.webp) ### Guest customers Vendure also supports **guest customers**, meaning that a customer can place an order without needing to register an account, and thus not getting an associated user or role. A guest customer, having no roles and thus no permissions, is then unable to view past orders or access any other restricted API operations. However, a guest customer can at a later point register an account using the same email address, at which point they will get a user with the "Customer" role, and be able to view their past orders. ## Roles & Permissions Both the `Customer` and `Administrator` entities relate to a single [`User`](/reference/typescript-api/entities/user/) entity which in turn has one or more [`Roles`](/reference/typescript-api/entities/role/) for controlling permissions. ![Administrators & Roles](./admin-role.webp) In the example above, the administrator Sam Bailey has two roles assigned: "Order Manager" and "Catalog Manager". An administrator can have any number of roles assigned, and the permissions of all roles are combined to determine the permissions of the administrator. In this way, you can have fine-grained control over which administrators can perform which actions. There are 2 special roles which are created by default and cannot be changed: - **SuperAdmin**: This role has all permissions, and cannot be edited or deleted. It is assigned to the first administrator created when the server is started. - **Customer**: This role is assigned to all registered customers. All other roles can be user-defined. Here's an example of an "Inventory Manager" role being defined in the Admin UI: ![Inventory Manager role](./roles.webp) ## Native authentication By default, Vendure uses a username/email address and password to authenticate users, which is implemented by the [`NativeAuthenticationStrategy`](/reference/typescript-api/auth/native-authentication-strategy/). There is a `login` mutation available in both the Shop API and Admin API which allows a customer or administrator to authenticate using native authentication: ```graphql title="Admin API" mutation { login(username: "superadmin", password: "superadmin") { ...on CurrentUser { id identifier } ...on ErrorResult { errorCode message } } } ``` :::info See the [Managing Sessions guide](/guides/storefront/connect-api/#managing-sessions) for how to manage authenticated sessions in your storefront/client applications. ::: ## External authentication In addition to the built-in `NativeAuthenticationStrategy`, it is possible to define a custom [`AuthenticationStrategy`](/reference/typescript-api/auth/authentication-strategy) which allows your Vendure server to support other authentication methods such as: - Social logins (Facebook, Google, GitHub, etc.) - Single Sign-On (SSO) providers such as Keycloak, Auth0, etc. - Alternative factors such as SMS, TOTP, etc. Custom authentication strategies are set via the [`VendureConfig.authOptions` object](/reference/typescript-api/auth/auth-options/#shopauthenticationstrategy): ```ts title="src/vendure-config.ts" import { VendureConfig, NativeAuthenticationStrategy } from '@vendure/core'; import { FacebookAuthenticationStrategy } from './plugins/authentication/facebook-authentication-strategy'; import { GoogleAuthenticationStrategy } from './plugins/authentication/google-authentication-strategy'; import { KeycloakAuthenticationStrategy } from './plugins/authentication/keycloak-authentication-strategy'; export const config: VendureConfig = { authOptions: { shopAuthenticationStrategy: [ new NativeAuthenticationStrategy(), new FacebookAuthenticationStrategy(), new GoogleAuthenticationStrategy(), ], adminAuthenticationStrategy: [ new NativeAuthenticationStrategy(), new KeycloakAuthenticationStrategy(), ], } } ``` In the above example, we define the strategies available for authenticating in the Shop API and the Admin API. The `NativeAuthenticationStrategy` is the only one actually provided by Vendure out-of-the-box, and this is the default username/email + password strategy. The other strategies would be custom-built (or provided by future npm packages) by creating classes that implement the [`AuthenticationStrategy` interface](/reference/typescript-api/auth/authentication-strategy). Let's take a look at a couple of examples of what a custom AuthenticationStrategy implementation would look like. ## Custom authentication examples ### Google authentication This example demonstrates how to implement a Google login flow. #### Storefront setup In your storefront, you need to integrate the Google sign-in button as described in ["Integrating Google Sign-In into your web app"](https://developers.google.com/identity/sign-in/web/sign-in). Successful authentication will result in a `onSignIn` function being called in your app. It will look something like this: ```ts function onSignIn(googleUser) { graphQlQuery( `mutation Authenticate($token: String!) { authenticate(input: { google: { token: $token } }) { ...on CurrentUser { id identifier } } }`, { token: googleUser.getAuthResponse().id_token } ).then(() => { // redirect to account page }); } ``` #### Backend On the backend, you'll need to define an AuthenticationStrategy to take the authorization token provided by the storefront in the `authenticate` mutation, and use it to get the necessary personal information on that user from Google. To do this you'll need to install the `google-auth-library` npm package as described in the ["Authenticate with a backend server" guide](https://developers.google.com/identity/sign-in/web/backend-auth). ```ts title="src/plugins/authentication/google-authentication-strategy.ts" import { AuthenticationStrategy, ExternalAuthenticationService, Injector, RequestContext, User, } from '@vendure/core'; import { OAuth2Client } from 'google-auth-library'; import { DocumentNode } from 'graphql'; import gql from 'graphql-tag'; export type GoogleAuthData = { token: string; }; export class GoogleAuthenticationStrategy implements AuthenticationStrategy { readonly name = 'google'; private client: OAuth2Client; private externalAuthenticationService: ExternalAuthenticationService; constructor(private clientId: string) { // The clientId is obtained by creating a new OAuth client ID as described // in the Google guide linked above. this.client = new OAuth2Client(clientId); } init(injector: Injector) { // The ExternalAuthenticationService is a helper service which encapsulates much // of the common functionality related to dealing with external authentication // providers. this.externalAuthenticationService = injector.get(ExternalAuthenticationService); } defineInputType(): DocumentNode { // Here we define the expected input object expected by the `authenticate` mutation // under the "google" key. return gql` input GoogleAuthInput { token: String! } `; } async authenticate(ctx: RequestContext, data: GoogleAuthData): Promise { // Here is the logic that uses the token provided by the storefront and uses it // to find the user data from Google. const ticket = await this.client.verifyIdToken({ idToken: data.token, audience: this.clientId, }); const payload = ticket.getPayload(); if (!payload || !payload.email) { return false; } // First we check to see if this user has already authenticated in our // Vendure server using this Google account. If so, we return that // User object, and they will be now authenticated in Vendure. const user = await this.externalAuthenticationService.findCustomerUser(ctx, this.name, payload.sub); if (user) { return user; } // If no user was found, we need to create a new User and Customer based // on the details provided by Google. The ExternalAuthenticationService // provides a convenience method which encapsulates all of this into // a single method call. return this.externalAuthenticationService.createCustomerAndUser(ctx, { strategy: this.name, externalIdentifier: payload.sub, verified: payload.email_verified || false, emailAddress: payload.email, firstName: payload.given_name, lastName: payload.family_name, }); } } ``` ### Facebook authentication This example demonstrates how to implement a Facebook login flow. #### Storefront setup In this example, we are assuming the use of the [Facebook SDK for JavaScript](https://developers.facebook.com/docs/javascript/) in the storefront. An implementation in React might look like this: ```tsx title="/storefront/src/components/FacebookLoginButton.tsx" /** * Renders a Facebook login button. */ export const FBLoginButton = () => { const fnName = `onFbLoginButtonSuccess`; const router = useRouter(); const [error, setError] = useState(''); const [socialLoginMutation] = useMutation(AuthenticateDocument); useEffect(() => { (window as any)[fnName] = function() { FB.getLoginStatus(login); }; return () => { delete (window as any)[fnName]; }; }, []); useEffect(() => { window?.FB?.XFBML.parse(); }, []); const login = async (response: any) => { const {status, authResponse} = response; if (status === 'connected') { const result = await socialLoginMutation({variables: {token: authResponse.accessToken}}); if (result.data?.authenticate.__typename === 'CurrentUser') { // The user has logged in, refresh the browser trackLogin('facebook'); router.reload(); return; } } setError('An error occurred!'); }; return (
{error &&
{error}
}
); }; ``` #### Backend ```ts title="/src/plugins/authentication/facebook-authentication-strategy.ts" import { AuthenticationStrategy, ExternalAuthenticationService, Injector, Logger, RequestContext, User, UserService, } from '@vendure/core'; import { DocumentNode } from 'graphql'; import gql from 'graphql-tag'; import fetch from 'node-fetch'; export type FacebookAuthData = { token: string; }; export type FacebookAuthConfig = { appId: string; appSecret: string; clientToken: string; }; export class FacebookAuthenticationStrategy implements AuthenticationStrategy { readonly name = 'facebook'; private externalAuthenticationService: ExternalAuthenticationService; private userService: UserService; constructor(private config: FacebookAuthConfig) { } init(injector: Injector) { // The ExternalAuthenticationService is a helper service which encapsulates much // of the common functionality related to dealing with external authentication // providers. this.externalAuthenticationService = injector.get(ExternalAuthenticationService); this.userService = injector.get(UserService); } defineInputType(): DocumentNode { // Here we define the expected input object expected by the `authenticate` mutation // under the "google" key. return gql` input FacebookAuthInput { token: String! } `; } private async getAppAccessToken() { const resp = await fetch( `https://graph.facebook.com/oauth/access_token?client_id=${this.config.appId}&client_secret=${this.config.appSecret}&grant_type=client_credentials`, ); return await resp.json(); } async authenticate(ctx: RequestContext, data: FacebookAuthData): Promise { const {token} = data; const {access_token} = await this.getAppAccessToken(); const resp = await fetch( `https://graph.facebook.com/debug_token?input_token=${token}&access_token=${access_token}`, ); const result = await resp.json(); if (!result.data) { return false; } const uresp = await fetch(`https://graph.facebook.com/me?access_token=${token}&fields=email,first_name,last_name`); const uresult = (await uresp.json()) as { id?: string; email: string; first_name: string; last_name: string }; if (!uresult.id) { return false; } const existingUser = await this.externalAuthenticationService.findCustomerUser(ctx, this.name, uresult.id); if (existingUser) { // This will select all the auth methods return (await this.userService.getUserById(ctx, existingUser.id))!; } Logger.info(`User Create: ${JSON.stringify(uresult)}`); const user = await this.externalAuthenticationService.createCustomerAndUser(ctx, { strategy: this.name, externalIdentifier: uresult.id, verified: true, emailAddress: uresult.email, firstName: uresult.first_name, lastName: uresult.last_name, }); user.verified = true; return user; } } ``` ### Keycloak authentication Here's an example of an AuthenticationStrategy intended to be used on the Admin API. The use-case is when the company has an existing identity server for employees, and you'd like your Vendure shop admins to be able to authenticate with their existing accounts. This example uses [Keycloak](https://www.keycloak.org/), a popular open-source identity management server. To get your own Keycloak server up and running in minutes, follow the [Keycloak on Docker](https://www.keycloak.org/getting-started/getting-started-docker) guide. #### Configure a login page & Admin UI In this example, we'll assume the login page is hosted at `http://intranet/login`. We'll also assume that a "login to Vendure" button has been added to that page and that the page is using the [Keycloak JavaScript adapter](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter), which can be used to get the current user's authorization token: ```js title="/login/index.html" const vendureLoginButton = document.querySelector('#vendure-login-button'); vendureLoginButton.addEventListener('click', () => { return graphQlQuery(` mutation Authenticate($token: String!) { authenticate(input: { keycloak: { token: $token } }) { ...on CurrentUser { id } } }`, { token: keycloak.token }, ) .then((result) => { if (result.data?.authenticate.user) { // successfully authenticated - redirect to Vendure Admin UI window.location.replace('http://localhost:3000/admin'); } }); }); ``` We also need to tell the Admin UI application about the custom login URL, since we have no need for the default "username/password" login form. This can be done by setting the [`loginUrl` property](/reference/typescript-api/common/admin-ui/admin-ui-config#loginurl) in the AdminUiConfig: ```ts title="/src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ port: 5001, adminUiConfig: { loginUrl: 'http://intranet/login', }, }), ], }; ``` #### Backend First we will need to be making an HTTP call to our Keycloak server to validate the token and get the user's details. We'll use the [`node-fetch`](https://www.npmjs.com/package/node-fetch) library to make the HTTP call: ```bash npm install node-fetch ``` The strategy is very similar to the Google authentication example (they both use the OpenID Connect standard), so we'll not duplicate the explanatory comments here: ```ts title="/src/plugins/authentication/keycloak-authentication-strategy.ts" import fetch from 'node-fetch'; import { AuthenticationStrategy, ExternalAuthenticationService, Injector, Logger, RequestContext, RoleService, User, } from '@vendure/core'; import { DocumentNode } from 'graphql'; import gql from 'graphql-tag'; export type KeycloakAuthData = { token: string; }; export class KeycloakAuthenticationStrategy implements AuthenticationStrategy { readonly name = 'keycloak'; private externalAuthenticationService: ExternalAuthenticationService; private httpService: HttpService; private roleService: RoleService; init(injector: Injector) { this.externalAuthenticationService = injector.get(ExternalAuthenticationService); this.httpService = injector.get(HttpService); this.roleService = injector.get(RoleService); } defineInputType(): DocumentNode { return gql` input KeycloakAuthInput { token: String! } `; } async authenticate(ctx: RequestContext, data: KeycloakAuthData): Promise { const { data: userInfo } = await fetch( 'http://localhost:9000/auth/realms/myrealm/protocol/openid-connect/userinfo', { headers: { Authorization: `Bearer ${data.token}`, }, }).then(res => res.json()); if (!userInfo) { return false; } const user = await this.externalAuthenticationService.findAdministratorUser(ctx, this.name, userInfo.sub); if (user) { return user; } // When creating an Administrator, we need to know what Role(s) to assign. // In this example, we've created a "merchant" role and assign that to all // new Administrators. In a real implementation, you can have more complex // logic to map an external user to a given role. const roles = await this.roleService.findAll(); const merchantRole = roles.items.find((r) => r.code === 'merchant'); if (!merchantRole) { Logger.error(`Could not find "merchant" role`); return false; } return this.externalAuthenticationService.createAdministratorAndUser(ctx, { strategy: this.name, externalIdentifier: userInfo.sub, identifier: userInfo.preferred_username, emailAddress: userInfo.email, firstName: userInfo.given_name, lastName: userInfo.family_name, roles: [merchantRole], }); } } ``` --- --- title: "Channels" --- Channels are a feature of Vendure which allows multiple sales channels to be represented in a single Vendure instance. A Channel allows you to: * Set Channel-specific currency, language, tax and shipping defaults * Assign only specific products to the channel (with channel-specific prices) * Create administrator roles limited to one or more channels * Assign specific stock locations, assets, facets, collections, promotions, and other entities to the channel * Have orders and customers associated with specific channels. This is useful for a number of use-cases, including: - **Multi-tenancy**: Each channel can be configured with its own set of products, shipping methods, payment methods, etc. This allows you to run multiple shops from a single Vendure server. - **Multi-vendor**: Each channel can represent a distinct vendor or seller, which can be used to implement a marketplace. - **Region-specific stores**: Each channel can be configured with its own set of languages, currencies, tax rates, etc. This allows you to run multiple stores for different regions from a single Vendure server. - **Distinct sales channels**: Each channel can represent a sales channel of a single business, with one channel for the online store, one for selling via Amazon, one for selling via Facebook etc. Every Vendure server always has a **default Channel**, which contains _all_ entities. Subsequent channels can then contain a subset of channel-aware entities. ![Channels high level](./channels.webp) ## Channel-aware entities Many entities are channel-aware, meaning that they can be associated with a multiple channels. The following entities are channel-aware: - [`Asset`](/reference/typescript-api/entities/asset/) - [`Collection`](/reference/typescript-api/entities/collection/) - [`Customer`](/reference/typescript-api/entities/customer/) - [`Facet`](/reference/typescript-api/entities/facet/) - [`FacetValue`](/reference/typescript-api/entities/facet-value/) - [`Order`](/reference/typescript-api/entities/order/) - [`PaymentMethod`](/reference/typescript-api/entities/payment-method/) - [`Product`](/reference/typescript-api/entities/product/) - [`ProductVariant`](/reference/typescript-api/entities/product-variant/) - [`Promotion`](/reference/typescript-api/entities/promotion/) - [`Role`](/reference/typescript-api/entities/role/) - [`ShippingMethod`](/reference/typescript-api/entities/shipping-method/) - [`StockLocation`](/reference/typescript-api/entities/stock-location/) ## Channels & Sellers Each channel is also assigned a single [`Seller`](/reference/typescript-api/entities/seller/). This entity is used to represent the vendor or seller of the products in the channel. This is useful for implementing a marketplace, where each channel represents a distinct vendor. The `Seller` entity can be extended with [custom fields](/guides/developer-guide/custom-fields/) to store additional information about the seller, such as a logo, contact details etc. ## Channels, Currencies & Prices Each Channel has a set of `availableCurrencyCodes`, and one of these is designated as the `defaultCurrencyCode`, which sets the default currency for all monetary values in that channel. ![Default currencies](./default-currency.webp) Internally, there is a one-to-many relation from [`ProductVariant`](/reference/typescript-api/entities/product-variant/) to [`ProductVariantPrice`](/reference/typescript-api/entities/product-variant-price). So the ProductVariant does _not_ hold a price for the product - this is actually stored on the `ProductVariantPrice` entity, and there will be at least one for each Channel to which the ProductVariant has been assigned. ![Product variant prices](./variant-prices.webp) In this diagram we can see that every channel has at least 1 `ProductVariantPrice`. In the case of the UK Channel, there are 2 prices assigned - one for GBP and one for USD. This means that you are able to define multiple prices in different currencies on a single product variant for a single channel. :::info **Note:** in the diagram above that the ProductVariant is **always assigned to the default Channel**, and thus will have a price in the default channel too. Likewise, the default Channel also has a defaultCurrencyCode. Depending on your requirements, you may or may not make use of the default Channel. ::: ### Keeping prices synchronized When you have products assigned to multiple channels, updates to the price of a product in one channel will not automatically be reflected in other channels. For instance, in the diagram above, both the Default channel and the UK channel have a price in USD for the same product variant. If an administrator of the UK channel changes the USD price to $20, the price in the Default channel will remain at $30. This is the default behavior, and is controlled by the [ProductVariantPriceUpdateStrategy](/reference/typescript-api/configuration/product-variant-price-update-strategy). If you want to keep prices synchronized across all channels, you can set the `syncPricesAcrossChannels` property of the [DefaultProductVariantPriceUpdateStrategy](/reference/typescript-api/configuration/product-variant-price-update-strategy#defaultproductvariantpriceupdatestrategy) to `true`. This will ensure that when the price of a product variant is updated in one channel, the price in all other channels (of that particular currency) will be updated to match. ```ts import { DefaultProductVariantPriceUpdateStrategy, VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... // highlight-start productVariantPriceUpdateStrategy: new DefaultProductVariantPriceUpdateStrategy({ syncPricesAcrossChannels: true, }), // highlight-end // ... }; ``` You may however require even more sophisticated logic. For instance, you may want a one-way synchronization, where the price in the Default channel is always the master price, and the prices in other channels are updated to match. In this case, you can create a custom `ProductVariantPriceUpdateStrategy` which implements the desired logic. ## Use cases ### Single shop This is the simplest set-up. You just use the default Channel for everything. ### Multiple separate shops Let's say you are running multiple distinct businesses, each with its own distinct inventory and possibly different currencies. In this case, you set up a Channel for each shop and create the Product & Variants in the relevant shop's Channel. The default Channel can then be used by the superadmin for administrative purposes, but other than that the default Channel would not be used. Storefronts would only target a specific shop's Channel. ### Multiple shops sharing inventory Let's say you have a single inventory but want to split it between multiple shops. There might be overlap in the inventory, e.g. the US & EU shops share 80% of inventory, and then the rest is specific to either shop. In this case, you can create the entire inventory in the default Channel and then assign the Products & ProductVariants to each Channel as needed, setting the price as appropriate for the currency used by each shop. :::caution **Note:** When creating a new Product & ProductVariants inside a sub-Channel, it will also **always get assigned to the default Channel**. If your sub-Channel uses a different currency from the default Channel, you should be aware that in the default Channel, that ProductVariant will be assigned the **same price** as it has in the sub-Channel. If the currency differs between the Channels, you need to make sure to set the correct price in the default Channel if you are exposing it to Customers via a storefront. ::: ### Multi-vendor marketplace This is the most advanced use of channels. For a detailed guide to this use-case, see our [Multi-vendor marketplace guide](/guides/how-to/multi-vendor-marketplaces/). ## Specifying channel in the GraphQL API To specify which channel to use when making an API call, set the `'vendure-token'` header to match the token of the desired Channel. For example, if we have a UK Channel with the token set to "uk-channel" as shown in this screenshot: ![UK Channel](./channel-token.webp) Then we can make a GraphQL API call to the UK Channel by setting the `'vendure-token'` header to `'uk-channel'`: ```ts title="GraphQL API call to UK Channel" const { loading, error, data } = useQuery(GET_PRODUCT_LIST, { context: { // highlight-start headers: { 'vendure-token': 'uk-channel', }, // highlight-end }, }); ``` :::note This is an example using Apollo Client in React. The same principle applies to any GraphQL client library - set the `'vendure-token'` header to the token of the desired Channel. ::: With the above header set, the API call will be made to the UK Channel, and the response will contain only the entities which are assigned to that Channel. --- --- title: 'Collections' --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; [`Collections`](/reference/typescript-api/entities/collection/) are used to categorize and organize your catalog. A collection contains multiple product variants, and a product variant can belong to multiple collections. Collections can be nested to create a hierarchy of categories, which is typically used to create a menu structure in the storefront. ![Collections](./collections.webp) Collections are not _only_ used as the basis of storefront navigation. They are a general-purpose organization tool which can be used for many purposes, such as: - Creating a collection of "new arrivals" which is used on the homepage. - Creating a collection of "best sellers" which is used to display a list of popular products. - Creating a collection of "sale items" which is used to apply a discount to all products in the collection, via a promotion. ## Collection filters The specific product variants that belong to a collection are determined by the collection's [`CollectionFilters`](/reference/typescript-api/configuration/collection-filter/). A collection filter is a piece of logic which is used to determine whether a product variant should be included in the collection. By default, Vendure includes a number of collection filters: - **Filter by facet values**: Include all product variants which have a specific set of facet values. - **Filter by product variant name**: Include all product variants whose name matches a specific string. - **Manually select product variants**: Allows manual selection of individual product variants. - **Manually select products**: Allows manual selection of entire products, and then includes all variants of those products. ![Collection filters](./collection-filters.webp) It is also possible to create your own custom collection filters, which can be used to implement more complex logic. See the section on [creating a collection filter](#creating-a-collection-filter) for more details. ### Filter inheritance When a collection is nested within another collection, the child collection can inherit the parent's collection filters. This means that the child collection will _combine_ its own filters with the parent's filters. ![Filter inheritance](./filter-inheritance.webp) In the example above, we have a parent collection "Menswear", with a child collection "Mens' Casual". The parent collection has a filter which includes all product variants with the "clothing" and "mens" facet values. The child collection is set to inherit the parent's filters, and has an additional filter which includes all product variants with the "casual" facet value. Thus, the child collection will include all product variants which have the "clothing", "mens" and "casual" facet values. :::note When filter inheritance is enabled, a child collection will contain a **subset** of the product variants of its parent collection. In order to create a child collection which contains product variants _not_ contained by the parent collection, you must disable filter inheritance in the child collection. ::: ### Creating a collection filter You can create your own custom collection filters with the [`CollectionFilter`](/reference/typescript-api/configuration/collection-filter/) class. This class is a [configurable operation](/guides/developer-guide/strategies-configurable-operations/#configurable-operations) where the specific filtering logic is implemented in the `apply()` method passed to its constructor. The `apply()` method receives an instance of the [TypeORM SelectQueryBuilder](https://typeorm.io/select-query-builder) which should have filtering logic added to it using the `.andWhere()` method. Here's an example of a collection filter which filters by SKU: ```ts title="src/config/sku-collection-filter.ts" import { CollectionFilter, LanguageCode } from '@vendure/core'; export const skuCollectionFilter = new CollectionFilter({ args: { // The `args` object defines the user-configurable arguments // which will get passed to the filter's `apply()` function. sku: { type: 'string', label: [{ languageCode: LanguageCode.en, value: 'SKU' }], description: [ { languageCode: LanguageCode.en, value: 'Matches any product variants with an SKU containing this value', }, ], }, }, code: 'variant-sku-filter', description: [{ languageCode: LanguageCode.en, value: 'Filter by matching SKU' }], // This is the function that defines the logic of the filter. apply: (qb, args) => { // Sometimes syntax differs between database types, so we use // the `type` property of the connection options to determine // which syntax to use. const LIKE = qb.connection.options.type === 'postgres' ? 'ILIKE' : 'LIKE'; return qb.andWhere(`productVariant.sku ${LIKE} :sku`, { sku: `%${args.sku}%` }); }, }); ``` In the `apply()` method, the product variant entity is aliased as `'productVariant'`. This custom filter is then added to the defaults in your config: ```ts title="src/vendure-config.ts" import { defaultCollectionFilters, VendureConfig } from '@vendure/core'; import { skuCollectionFilter } from './config/sku-collection-filter'; export const config: VendureConfig = { // ... catalogOptions: { collectionFilters: [ ...defaultCollectionFilters, // highlight-next-line skuCollectionFilter ], }, }; ``` :::info To see some more advanced collection filter examples, you can look at the source code of the [default collection filters](https://github.com/vendure-ecommerce/vendure/blob/master/packages/core/src/config/catalog/default-collection-filters.ts). ::: --- --- title: "Customers" --- A [`Customer`](/reference/typescript-api/entities/customer/) is a person who can buy from your shop. A customer can have one or more [`Addresses`](/reference/typescript-api/entities/address/), which are used for shipping and billing. If a customer has registered an account, they will have an associated [`User`](/reference/typescript-api/entities/user/). The user entity is used for authentication and authorization. **Guest checkouts** are also possible, in which case a customer will not have a user. :::info See the [Auth guide](/guides/core-concepts/auth/#customer-auth) for a detailed explanation of the relationship between customers and users. ::: ![Customer](./customer.webp) Customers can be organized into [`CustomerGroups`](/reference/typescript-api/entities/customer-group/). These groups can be used in logic relating to promotions, shipping rules, payment rules etc. For example, you could create a "VIP" customer group and then create a promotion which grants members of this group free shipping. Or a "B2B" group which is used in a custom tax calculator to apply a different tax rate to B2B customers. --- --- title: "Email & Notifications" --- A typical ecommerce application needs to notify customers of certain events, such as when they place an order or when their order has been shipped. This is usually done via email, but can also be done via SMS or push notifications. ## Email Email is the most common way to notify customers of events, so a default Vendure installation includes our [EmailPlugin](/reference/core-plugins/email-plugin). The EmailPlugin by default uses [Nodemailer](https://nodemailer.com/about/) to send emails via a variety of different transports, including SMTP, SendGrid, Mailgun, and more. The plugin is configured with a list of [EmailEventHandlers](/reference/core-plugins/email-plugin/email-event-handler) which are responsible for sending emails in response to specific events. :::note This guide will cover some of the main concepts of the EmailPlugin, but for a more in-depth look at how to configure and use it, see the [EmailPlugin API docs](/reference/core-plugins/email-plugin). ::: Here's an illustration of the flow of an email being sent: ![Email plugin flow](./email-plugin-flow.webp) All emails are triggered by a particular [Event](/guides/developer-guide/events/) - in this case when the state of an Order changes. The EmailPlugin ships with a set of [default email handlers](https://github.com/vendure-ecommerce/vendure/blob/master/packages/email-plugin/src/default-email-handlers.ts), one of which is responsible for sending "order confirmation" emails. ### EmailEventHandlers Let's take a closer look at a simplified version of the `orderConfirmationHandler`: ```ts import { OrderStateTransitionEvent } from '@vendure/core'; import { EmailEventListener, transformOrderLineAssetUrls, hydrateShippingLines } from '@vendure/email-plugin'; // The 'order-confirmation' string is used by the EmailPlugin to identify // which template to use when rendering the email. export const orderConfirmationHandler = new EmailEventListener('order-confirmation') .on(OrderStateTransitionEvent) // Only send the email when the Order is transitioning to the // "PaymentSettled" state and the Order has a customer associated with it. .filter( event => event.toState === 'PaymentSettled' && !!event.order.customer, ) // We commonly need to load some additional data to be able to render the email // template. This is done via the `loadData()` method. In this method we are // mutating the Order object to ensure that product images are correctly // displayed in the email, as well as fetching shipping line data from the database. .loadData(async ({ event, injector }) => { transformOrderLineAssetUrls(event.ctx, event.order, injector); const shippingLines = await hydrateShippingLines(event.ctx, event.order, injector); return { shippingLines }; }) // Here we are setting the recipient of the email to be the // customer's email address. .setRecipient(event => event.order.customer!.emailAddress) // We can interpolate variables from the EmailPlugin's configured // `globalTemplateVars` object. .setFrom('{{ fromAddress }}') // We can also interpolate variables made available by the // `setTemplateVars()` method below .setSubject('Order confirmation for #{{ order.code }}') // The object returned here defines the variables which are // available to the email template. .setTemplateVars(event => ({ order: event.order, shippingLines: event.data.shippingLines })) ``` To recap: - The handler listens for a specific event - It optionally filters those events to determine whether an email should be sent - It specifies the details of the email to be sent, including the recipient, subject, template variables, etc. The full range of methods available when setting up an EmailEventHandler can be found in the [EmailEventHandler API docs](/reference/core-plugins/email-plugin/email-event-handler). ### Email variables In the example above, we used the `setTemplateVars()` method to define the variables which are available to the email template. Additionally, there are global variables which are made available to _all_ email templates & EmailEventHandlers. These are defined in the `globalTemplateVars` property of the EmailPlugin config: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { EmailPlugin } from '@vendure/email-plugin'; export const config: VendureConfig = { // ... plugins: [ EmailPlugin.init({ // ... // highlight-start globalTemplateVars: { fromAddress: '"MyShop" ', verifyEmailAddressUrl: 'https://www.myshop.com/verify', passwordResetUrl: 'https://www.myshop.com/password-reset', changeEmailAddressUrl: 'https://www.myshop.com/verify-email-address-change' }, // highlight-end }), ], }; ``` ### Email integrations The EmailPlugin is designed to be flexible enough to work with many different email services. The default configuration uses Nodemailer to send emails via SMTP, but you can easily configure it to use a different transport. For instance: - [AWS SES](https://www.vendure.io/marketplace/aws-ses) - [SendGrid](https://www.vendure.io/marketplace/sendgrid) ## Other notification methods The pattern of listening for events and triggering some action in response is not limited to emails. You can use the same pattern to trigger other actions, such as sending SMS messages or push notifications. For instance, let's say you wanted to create a plugin which sends an SMS message to the customer when their order is shipped. :::note This is just a simplified example to illustrate the pattern. ::: ```ts title="src/plugins/sms-plugin/sms-plugin.ts" import { OnModuleInit } from '@nestjs/common'; import { PluginCommonModule, VendurePlugin, EventBus } from '@vendure/core'; import { OrderStateTransitionEvent } from '@vendure/core'; // A custom service which sends SMS messages // using a third-party SMS provider such as Twilio. import { SmsService } from './sms.service'; @VendurePlugin({ imports: [PluginCommonModule], providers: [SmsService], }) export class SmsPlugin implements OnModuleInit { constructor( private eventBus: EventBus, private smsService: SmsService, ) {} onModuleInit() { this.eventBus .ofType(OrderStateTransitionEvent) .filter(event => event.toState === 'Shipped') .subscribe(event => { this.smsService.sendOrderShippedMessage(event.order); }); } } ``` --- --- title: "Images & Assets" --- [`Assets`](/reference/typescript-api/entities/asset/) are used to store files such as images, videos, PDFs, etc. Assets can be assigned to **products**, **variants** and **collections** by default. By using [custom fields](/guides/developer-guide/custom-fields/) it is possible to assign assets to other entities. For example, for implementing customer profile images. The handling of assets in Vendure is implemented in a modular way, allowing you full control over the way assets are stored, named, imported and previewed. ![Asset creation and retrieval](./asset-flow.webp) 1. An asset is created by uploading an image. Internally the [`createAssets` mutation](/reference/graphql-api/admin/mutations/#createassets) will be executed. 2. The [`AssetNamingStrategy`](/reference/typescript-api/assets/asset-naming-strategy/) is used to generate file names for the source image and the preview. This is useful for normalizing file names as well as handling name conflicts. 3. The [`AssetPreviewStrategy`](/reference/typescript-api/assets/asset-preview-strategy) generates a preview image of the asset. For images, this typically involves creating a version with constraints on the maximum dimensions. It could also be used to e.g. generate a preview image for uploaded PDF files, videos or other non-image assets (such functionality would require a custom `AssetPreviewStrategy` to be defined). 4. The source file as well as the preview image are then passed to the [`AssetStorageStrategy`](/reference/typescript-api/assets/asset-storage-strategy) which stores the files to some form of storage. This could be the local disk or an object store such as AWS S3 or Minio. 5. When an asset is later read, e.g. when a customer views a product detail page which includes an image of the product, the `AssetStorageStrategy` can be used to read the file from the storage location. ## AssetServerPlugin Vendure comes with the `@vendure/asset-server-plugin` package pre-installed. This provides the [`AssetServerPlugin`](/reference/core-plugins/asset-server-plugin/) which provides many advanced features to make working with assets easier. The plugin provides a ready-made set of strategies for handling assets, but also allows you to replace these defaults with your own implementations. For example, here are instructions on how to replace the default storage strategy with one that stores your assets on AWS S3 or Minio: [configure S3 asset storage](/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy#configures3assetstorage) It also features a powerful image transformation API, which allows you to specify the dimensions, crop, and image format using query parameters. :::info See the [AssetServerPlugin docs](/reference/core-plugins/asset-server-plugin/) for a detailed description of all the features. ::: ## Asset Tags Assets can be tagged. A [`Tag`](/reference/typescript-api/entities/tag/) is a simple text label that can be applied to an asset. An asset can have multiple tags or none. Tags are useful for organizing assets, since assets are otherwise organized as a flat list with no concept of a directory structure. ![Asset tags](./asset-tags.webp) --- --- title: "Money & Currency" --- In Vendure, monetary values are stored as **integers** using the **minor unit** of the selected currency. For example, if the currency is set to USD, then the integer value `100` would represent $1.00. This is a common practice in financial applications, as it avoids the rounding errors that can occur when using floating-point numbers. For example, here's the response from a query for a product's variant prices: ```json { "data": { "product": { "id": "42", "variants": [ { "id": "74", "name": "Bonsai Tree", "currencyCode": "USD", // highlight-start "price": 1999, "priceWithTax": 2399, // highlight-end } ] } } } ``` In this example, the tax-inclusive price of the variant is `$23.99`. :::info To illustrate the problem with storing money as decimals, imagine that we want to add the price of two items: - Product A: `$1.21` - Product B: `$1.22` We should expect the sum of these two amounts to equal `$2.43`. However, if we perform this addition in JavaScript (and the same holds true for most common programming languages), we will instead get `$2.4299999999999997`! For a more in-depth explanation of this issue, see [this StackOverflow answer](https://stackoverflow.com/a/3730040/772859) ::: ## Displaying monetary values When you are building your storefront, or any other client that needs to display monetary values in a human-readable form, you need to divide by 100 to convert to the major currency unit and then format with the correct decimal & grouping dividers. In JavaScript environments such as browsers & Node.js, we can take advantage of the excellent [`Intl.NumberFormat` API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat). Here's a function you can use in your projects: ```ts title="src/utils/format-currency.ts" export function formatCurrency(value: number, currencyCode: string, locale?: string) { const majorUnits = value / 100; try { // Note: if no `locale` is provided, the browser's default // locale will be used. return new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode, }).format(majorUnits); } catch (e: any) { // A fallback in case the NumberFormat fails for any reason return majorUnits.toFixed(2); } } ``` If you are building an Admin UI extension, you can use the built-in [`LocaleCurrencyPipe`](/reference/admin-ui-api/pipes/locale-currency-pipe/): ```html title="src/plugins/my-plugin/ui/components/my-component/my.component.html"
Variant price: {{ variant.price | localeCurrency : variant.currencyCode }}
``` ## Support for multiple currencies Vendure supports multiple currencies out-of-the-box. The available currencies must first be set at the Channel level (see the [Channels, Currencies & Prices section](/guides/core-concepts/channels/#channels-currencies--prices)), and then a price may be set on a `ProductVariant` in each of the available currencies. When using multiple currencies, the [ProductVariantPriceSelectionStrategy](/reference/typescript-api/configuration/product-variant-price-selection-strategy/) is used to determine which of the available prices to return when fetching the details of a `ProductVariant`. The default strategy is to return the price in the currency of the current session request context, which is determined firstly by any `?currencyCode=XXX` query parameter on the request, and secondly by the `defaultCurrencyCode` of the Channel. ## The GraphQL `Money` scalar In the GraphQL APIs, we use a custom [`Money` scalar type](/reference/graphql-api/admin/object-types/#money) to represent all monetary values. We do this for two reasons: 1. The built-in `Int` type is that the GraphQL spec imposes an upper limit of `2147483647`, which in some cases (especially currencies with very large amounts) is not enough. 2. Very advanced use-cases might demand more precision than is possible with an integer type. Using our own custom scalar gives us the possibility of supporting more precision. Here's how the `Money` scalar is used in the `ShippingLine` type: ```graphql type ShippingLine { id: ID! shippingMethod: ShippingMethod! // highlight-start price: Money! priceWithTax: Money! discountedPrice: Money! discountedPriceWithTax: Money! // highlight-end discounts: [Discount!]! } ``` If you are defining custom GraphQL types, or adding fields to existing types (see the [Extending the GraphQL API doc](/guides/developer-guide/extend-graphql-api/)), then you should also use the `Money` scalar for any monetary values. ## The `@Money()` decorator When [defining new database entities](/guides/developer-guide/database-entity), if you need to store a monetary value, then rather than using the TypeORM `@Column()` decorator, you should use Vendure's [`@Money()` decorator](/reference/typescript-api/money/money-decorator). Using this decorator allows Vendure to correctly store the value in the database according to the configured `MoneyStrategy` (see below). ```ts title="src/plugins/quote/entities/quote.entity.ts" import { DeepPartial } from '@vendure/common/lib/shared-types'; import { VendureEntity, Order, EntityId, Money, CurrencyCode, ID } from '@vendure/core'; import { Column, Entity, ManyToOne } from 'typeorm'; @Entity() class Quote extends VendureEntity { constructor(input?: DeepPartial) { super(input); } @ManyToOne(type => Order) order: Order; @EntityId() orderId: ID; @Column() text: string; // highlight-start @Money() value: number; // highlight-end // Whenever you store a monetary value, it's a good idea to also // explicitly store the currency code too. This makes it possible // to support multiple currencies and correctly format the amount // when displaying the value. @Column('varchar') currencyCode: CurrencyCode; @Column() approved: boolean; } ``` ## Advanced configuration: MoneyStrategy For advanced use-cases, it is possible to configure aspects of how Vendure handles monetary values internally by defining a custom [`MoneyStrategy`](/reference/typescript-api/money/money-strategy/). The `MoneyStrategy` allows you to define: - How the value is stored and retrieved from the database - How rounding is applied internally - The precision represented by the monetary value (since v2.2.0) For example, in addition to the [`DefaultMoneyStrategy`](/reference/typescript-api/money/default-money-strategy), Vendure also provides the [`BigIntMoneyStrategy`](/reference/typescript-api/money/big-int-money-strategy) which stores monetary values using the `bigint` data type, allowing much larger amounts to be stored. Here's how you would configure your server to use this strategy: ```ts title="src/vendure-config.ts" import { VendureConfig, BigIntMoneyStrategy } from '@vendure/core'; export const config: VendureConfig = { // ... entityOptions: { moneyStrategy: new BigIntMoneyStrategy(), } } ``` ### Example: supporting three decimal places Let's say you have a B2B store which sells products in bulk, and you want to support prices with three decimal places. For example, you want to be able to sell a product for `$1.234` per unit. To do this, you would need to: 1. Configure the `MoneyStrategy` to use three decimal places ```ts import { DefaultMoneyStrategy, VendureConfig } from '@vendure/core'; export class ThreeDecimalPlacesMoneyStrategy extends DefaultMoneyStrategy { // highlight-next-line readonly precision = 3; } export const config: VendureConfig = { // ... entityOptions: { moneyStrategy: new ThreeDecimalPlacesMoneyStrategy(), } }; ``` 2. Set up your storefront to correctly convert the integer value to a decimal value with three decimal places. Using the `formatCurrency` example above, we can modify it to divide by 1000 instead of 100: ```ts title="src/utils/format-currency.ts" export function formatCurrency(value: number, currencyCode: string, locale?: string) { // highlight-next-line const majorUnits = value / 1000; try { return new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode, // highlight-start minimumFractionDigits: 3, maximumFractionDigits: 3, // highlight-end }).format(majorUnits); } catch (e: any) { // highlight-next-line return majorUnits.toFixed(3); } } ``` --- --- title: 'Orders' --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; In Vendure, the [`Order`](/reference/typescript-api/entities/order/) entity represents the entire lifecycle of an order, from the moment a customer adds an item to their cart, through to the point where the order is completed and the customer has received their goods. An [`Order`](/reference/typescript-api/entities/order/) is composed of one or more [`OrderLines`](/reference/typescript-api/entities/order-line/). Each order line represents a single product variant, and contains information such as the quantity, price, tax rate, etc. In turn, the order is associated with a [`Customer`](/reference/typescript-api/entities/customer/) and contains information such as the shipping address, billing address, shipping method, payment method, etc. ![Order](./order.webp) ## The Order Process Vendure defines an order process which is based on a [finite state machine](/reference/typescript-api/state-machine/fsm/) (a method of precisely controlling how the order moves from one state to another). This means that the [`Order.state` property](/reference/typescript-api/entities/order/#state) will be one of a set of [pre-defined states](/reference/typescript-api/orders/order-process/#orderstate). From the current state, the Order can then transition (change) to another state, and the available next states depend on what the current state is. :::note In Vendure, there is no distinction between a "cart" and an "order". The same entity is used for both. A "cart" is simply an order which is still "active" according to its current state. ::: You can see the current state of an order via `state` field on the `Order` type: ```graphql title="Shop API" query ActiveOrder { activeOrder { id // highlight-next-line state } } ``` ```json { "data": { "activeOrder": { "id": "4", // highlight-next-line "state": "AddingItems" } } } ``` The next possible states can be queried via the [`nextOrderStates`](/reference/graphql-api/shop/queries/#nextorderstates) query: ```graphql title="Shop API" query NextStates { nextOrderStates } ``` ```json { "data": { "nextOrderStates": [ "ArrangingPayment", "Cancelled" ] } } ``` The available states and the permissible transitions between them are defined by the configured [`OrderProcess`](/reference/typescript-api/orders/order-process/). By default, Vendure defines a [`DefaultOrderProcess`](/reference/typescript-api/orders/order-process/#defaultorderprocess) which is suitable for typical B2C use-cases. Here's a simplified diagram of the default order process: ![Default order process](./order-process.webp) Let's take a look at each of these states, and the transitions between them: * **`AddingItems:`** All orders begin in the `AddingItems` state. This means that the customer is adding items to his or her shopping cart. This is the state an order would be in as long as the customer is still browsing the store. * **`ArrangingPayment:`** From there, the Order can transition to the `ArrangingPayment`, which will prevent any further modifications to the order, which ensures the price that is sent to the payment provider is the same as the price that the customer saw when they added the items to their cart. At this point, the storefront will execute the [`addPaymentToOrder` mutation](/reference/graphql-api/shop/mutations/#addpaymenttoorder). * **`PaymentAuthorized:`** Depending on the configured payment method, the order may then transition to the `PaymentAuthorized` state, which indicates that the payment has been successfully authorized by the payment provider. This is the state that the order will be in if the payment is not captured immediately. Once the payment is captured, the order will transition to the `PaymentSettled` state. * **`PaymentSettled:`** If the payment captured immediately, the order will transition to the `PaymentSettled` state once the payment succeeds. * At this point, one or more fulfillments can be created. A `Fulfillment` represents the process of shipping one or more items to the customer ("shipping" applies equally to physical or digital goods - it just means getting the product to the customer by any means). A fulfillment can be created via the [`addFulfillmentToOrder` mutation](/reference/graphql-api/admin/mutations/#addfulfillmenttoorder), or via the Admin UI. If multiple fulfillments are created, then the order can end up partial states - `PartiallyShipped` or `PartiallyDelivered`. If there is only a single fulfillment which includes the entire order, then partial states are not possible. * **`Shipped:`** When all fulfillments have been shipped, the order will transition to the `Shipped` state. This means the goods have left the warehouse and are en route to the customer. * **`Delivered:`** When all fulfillments have been delivered, the order will transition to the `Delivered` state. This means the goods have arrived at the customer's address. This is the final state of the order. ## Customizing the Default Order Process It is possible to customize the [defaultOrderProcess](/reference/typescript-api/orders/order-process/#defaultorderprocess) to better match your business needs. For example, you might want to disable some of the constraints that are imposed by the default process, such as the requirement that a customer must have a shipping address before the Order can be completed. This can be done by creating a custom version of the default process using the [configureDefaultOrderProcess](/reference/typescript-api/orders/order-process/#configuredefaultorderprocess) function, and then passing it to the [`OrderOptions.process`](/reference/typescript-api/orders/order-options/#process) config property. ```ts title="src/vendure-config.ts" import { configureDefaultOrderProcess, VendureConfig } from '@vendure/core'; const myCustomOrderProcess = configureDefaultOrderProcess({ // Disable the constraint that requires // Orders to have a shipping method assigned // before payment. arrangingPaymentRequiresShipping: false, // Other constraints which can be disabled. See the // DefaultOrderProcessOptions interface docs for full // explanations. // // checkModificationPayments: false, // checkAdditionalPaymentsAmount: false, // checkAllVariantsExist: false, // arrangingPaymentRequiresContents: false, // arrangingPaymentRequiresCustomer: false, // arrangingPaymentRequiresStock: false, // checkPaymentsCoverTotal: false, // checkAllItemsBeforeCancel: false, // checkFulfillmentStates: false, }); export const config: VendureConfig = { orderOptions: { process: [myCustomOrderProcess], }, }; ``` ## Custom Order Processes Sometimes you might need to extend things beyond what is provided by the default Order process to better match your business needs. This is done by defining one or more [`OrderProcess`](/reference/typescript-api/orders/order-process#orderprocess) objects and passing them to the [`OrderOptions.process`](/reference/typescript-api/orders/order-options/#process) config property. ### Adding a new state Let's say your company can only sell to customers with a valid EU tax ID. We'll assume that you've already used a [custom field](/guides/developer-guide/custom-fields/) to store that code on the Customer entity. Now you want to add a step _before_ the customer handles payment, where we can collect and verify the tax ID. So we want to change the default process of: ```text AddingItems -> ArrangingPayment ``` to instead be: ```text AddingItems -> ValidatingCustomer -> ArrangingPayment ``` Here's how we would define the new state: ```ts title="src/plugins/tax-id/customer-validation-process.ts" import { OrderProcess } from '@vendure/core'; export const customerValidationProcess: OrderProcess<'ValidatingCustomer'> = { transitions: { AddingItems: { to: ['ValidatingCustomer'], mergeStrategy: 'replace', }, ValidatingCustomer: { to: ['ArrangingPayment', 'AddingItems'], }, }, }; ``` This object means: * the `AddingItems` state may _only_ transition to the `ValidatingCustomer` state (`mergeStrategy: 'replace'` tells Vendure to discard any existing transition targets and replace with this one). * the `ValidatingCustomer` may transition to the `ArrangingPayment` state (assuming the tax ID is valid) or back to the `AddingItems` state. And then add this configuration to our main VendureConfig: ```ts title="src/vendure-config.ts" import { defaultOrderProcess, VendureConfig } from '@vendure/core'; import { customerValidationProcess } from './plugins/tax-id/customer-validation-process'; export const config: VendureConfig = { // ... orderOptions: { process: [defaultOrderProcess, customerValidationProcess], }, }; ``` Note that we also include the `defaultOrderProcess` in the array, otherwise we will lose all the default states and transitions. To add multiple new States you need to extend the generic type like this: ```ts import { OrderProcess } from '@vendure/core'; export const customerValidationProcess: OrderProcess<'ValidatingCustomer'|'AnotherState'> = {...} ``` This way multiple custom states get defined. ### Intercepting a state transition Now we have defined our new `ValidatingCustomer` state, but there is as yet nothing to enforce that the tax ID is valid. To add this constraint, we'll use the [`onTransitionStart` state transition hook](/reference/typescript-api/state-machine/state-machine-config#ontransitionstart). This allows us to perform our custom logic and potentially prevent the transition from occurring. We will also assume that we have a provider named `TaxIdService` available which contains the logic to validate a tax ID. ```ts title="src/plugins/tax-id/customer-validation-process.ts" import { OrderProcess } from '@vendure/core'; import { TaxIdService } from './services/tax-id.service'; let taxIdService: TaxIdService; const customerValidationProcess: OrderProcess<'ValidatingCustomer'> = { transitions: { AddingItems: { to: ['ValidatingCustomer'], mergeStrategy: 'replace', }, ValidatingCustomer: { to: ['ArrangingPayment', 'AddingItems'], }, }, init(injector) { taxIdService = injector.get(TaxIdService); }, // The logic for enforcing our validation goes here async onTransitionStart(fromState, toState, data) { if (fromState === 'ValidatingCustomer' && toState === 'ArrangingPayment') { const isValid = await taxIdService.verifyTaxId(data.order.customer); if (!isValid) { // Returning a string is interpreted as an error message. // The state transition will fail. return `The tax ID is not valid`; } } }, }; ``` :::info For an explanation of the `init()` method and `injector` argument, see the guide on [injecting dependencies in configurable operations](/guides/developer-guide/strategies-configurable-operations/#injecting-dependencies). ::: ### Responding to a state transition Once an order has successfully transitioned to a new state, the [`onTransitionEnd` state transition hook](/reference/typescript-api/state-machine/state-machine-config#ontransitionend) is called. This can be used to perform some action upon successful state transition. In this example, we have a referral service which creates a new referral for a customer when they complete an order. We want to create the referral only if the customer has a referral code associated with their account. ```ts import { OrderProcess, OrderState } from '@vendure/core'; import { ReferralService } from '../service/referral.service'; let referralService: ReferralService; export const referralOrderProcess: OrderProcess = { init: (injector) => { referralService = injector.get(ReferralService); }, onTransitionEnd: async (fromState, toState, data) => { const { order, ctx } = data; if (toState === 'PaymentSettled') { if (order.customFields.referralCode) { await referralService.createReferralForOrder(ctx, order); } } }, }; ``` :::caution Use caution when modifying an order inside the `onTransitionEnd` function. The `order` object that gets passed in to this function will later be persisted to the database. Therefore any changes must be made to that `order` object, otherwise the changes might be lost. As an example, let's say we want to add a Surcharge to the order. The following code **will not work as expected**: ```ts export const myOrderProcess: OrderProcess = { async onTransitionEnd(fromState, toState, data) { if (fromState === 'AddingItems' && toState === 'ArrangingPayment') { // highlight-start // WARNING: This will not work! await orderService.addSurchargeToOrder(ctx, order.id, { description: 'Test', listPrice: 42, listPriceIncludesTax: false, }); // highlight-end } } }; ``` Instead, you need to ensure you **mutate the `order` object**: ```ts export const myOrderProcess: OrderProcess = { async onTransitionEnd(fromState, toState, data) { if (fromState === 'AddingItems' && toState === 'ArrangingPayment') { // highlight-start const {surcharges} = await orderService.addSurchargeToOrder(ctx, order.id, { description: 'Test', listPrice: 42, listPriceIncludesTax: false, }); // Important: mutate the order object order.surcharges = surcharges; // highlight-end } }, } ``` ::: ## TypeScript Typings To make your custom states compatible with standard services you should declare your new states in the following way: ```ts title="src/plugins/tax-id/types.ts" import { CustomOrderStates } from '@vendure/core'; declare module '@vendure/core' { interface CustomOrderStates { ValidatingCustomer: never; } } ``` This technique uses advanced TypeScript features - [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#merging-interfaces) and [ambient modules](https://www.typescriptlang.org/docs/handbook/modules.html#ambient-modules). ## Controlling custom states in the Admin UI If you have defined custom order states, the Admin UI will allow you to manually transition an order from one state to another: ![./custom-order-ui.webp](./custom-order-ui.webp) ## Order Interceptors Vendure v3.1 introduces the concept of [Order Interceptors](/reference/typescript-api/orders/order-interceptor/). These are a way to intercept operations that add, modify or remove order lines. Examples use-cases include: * Preventing certain products from being added to the order based on some criteria, e.g. if the product is already in another active order. * Enforcing a minimum or maximum quantity of a given product in the order * Using a CAPTCHA to prevent automated order creation Check the [Order Interceptor](/reference/typescript-api/orders/order-interceptor/) docs for more information as well as a complete example of how to implement an interceptor. --- --- title: 'Payment' --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; Vendure can support many kinds of payment workflows, such as authorizing and capturing payment in a single step upon checkout or authorizing on checkout and then capturing on fulfillment. :::info For complete working examples of real payment integrations, see the [payments-plugins](https://github.com/vendure-ecommerce/vendure/tree/master/packages/payments-plugin/src) ::: ## Authorization & Settlement Typically, there are 2 parts to an online payment: **authorization** and **settlement**: - **Authorization** is the process by which the customer's bank is contacted to check whether the transaction is allowed. At this stage, no funds are removed from the customer's account. - **Settlement** (also known as "capture") is the process by which the funds are transferred from the customer's account to the merchant. Some merchants do both of these steps at once, when the customer checks out of the store. Others do the authorize step at checkout, and only do the settlement at some later point, e.g. upon shipping the goods to the customer. This two-step workflow can also be applied to other non-card forms of payment: e.g. if providing a "payment on delivery" option, the authorization step would occur on checkout, and the settlement step would be triggered upon delivery, either manually by an administrator of via an app integration with the Admin API. ## Creating an integration Payment integrations are created by defining a new [PaymentMethodHandler](/reference/typescript-api/payment/payment-method-handler/) and passing that handler into the [`paymentOptions.paymentMethodHandlers`](/reference/typescript-api/payment/payment-options#paymentmethodhandlers) array in the VendureConfig. ```ts title="src/plugins/payment-plugin/my-payment-handler.ts" import { CancelPaymentResult, CancelPaymentErrorResult, PaymentMethodHandler, VendureConfig, CreatePaymentResult, SettlePaymentResult, SettlePaymentErrorResult } from '@vendure/core'; import { sdk } from 'payment-provider-sdk'; /** * This is a handler which integrates Vendure with an imaginary * payment provider, who provide a Node SDK which we use to * interact with their APIs. */ const myPaymentHandler = new PaymentMethodHandler({ code: 'my-payment-method', description: [{ languageCode: LanguageCode.en, value: 'My Payment Provider', }], args: { apiKey: {type: 'string'}, }, /** This is called when the `addPaymentToOrder` mutation is executed */ createPayment: async (ctx, order, amount, args, metadata): Promise => { try { const result = await sdk.charges.create({ amount, apiKey: args.apiKey, source: metadata.token, }); return { amount: order.total, state: 'Authorized' as const, transactionId: result.id.toString(), metadata: { cardInfo: result.cardInfo, // Any metadata in the `public` field // will be available in the Shop API, // All other metadata is private and // only available in the Admin API. public: { referenceCode: result.publicId, } }, }; } catch (err) { return { amount: order.total, state: 'Declined' as const, metadata: { errorMessage: err.message, }, }; } }, /** This is called when the `settlePayment` mutation is executed */ settlePayment: async (ctx, order, payment, args): Promise => { try { const result = await sdk.charges.capture({ apiKey: args.apiKey, id: payment.transactionId, }); return {success: true}; } catch (err) { return { success: false, errorMessage: err.message, } } }, /** This is called when a payment is cancelled. */ cancelPayment: async (ctx, order, payment, args): Promise => { try { const result = await sdk.charges.cancel({ apiKey: args.apiKey, id: payment.transactionId, }); return {success: true}; } catch (err) { return { success: false, errorMessage: err.message, } } }, }); ``` We can now add this handler to our configuration: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { myPaymentHandler } from './plugins/payment-plugin/my-payment-handler'; export const config: VendureConfig = { // ... paymentOptions: { paymentMethodHandlers: [myPaymentHandler], }, }; ``` :::info If your PaymentMethodHandler needs access to the database or other providers, see the [configurable operation dependency injection guide](/guides/developer-guide/strategies-configurable-operations/#injecting-dependencies). ::: ## The PaymentMethod entity Once the PaymentMethodHandler is defined as above, you can use it to create a new [`PaymentMethod`](/reference/typescript-api/entities/payment-method/) via the Admin UI (_Settings_ -> _Payment methods_, then _Create new payment method_) or via the Admin API `createPaymentMethod` mutation. A payment method consists of an optional [`PaymentMethodEligibilityChecker`](/reference/typescript-api/payment/payment-method-eligibility-checker/), which is used to determine whether the payment method is available to the customer, and a [`PaymentMethodHandler`](/reference/typescript-api/payment/payment-method-handler). The payment method also has a **code**, which is a string identifier used to specify this method when adding a payment to an order. ![Payment method](./payment-method.webp) ## Payment flow ### Eligible payment methods Once the active Order has been transitioned to the `ArrangingPayment` state (see the [Order guide](/guides/core-concepts/orders/)), we can query the available payment methods by executing the [`eligiblePaymentMethods` query](/reference/graphql-api/shop/queries#eligiblepaymentmethods). ```graphql title="Shop API" query GetEligiblePaymentMethods { eligiblePaymentMethods { code name isEligible eligibilityMessage } } ``` ```json { "data": { "eligiblePaymentMethods": [ { "code": "my-payment-method", "name": "My Payment Method", "isEligible": true, "eligibilityMessage": null } ] } } ``` ### Add payment to order One or more Payments are created by executing the [`addPaymentToOrder` mutation](/reference/graphql-api/shop/mutations#addpaymenttoorder). This mutation has a required `method` input field, which _must_ match the `code` of an eligible `PaymentMethod`. In the case above, this would be set to `"my-payment-method"`. ```graphql title="Shop API" mutation { addPaymentToOrder( input: { method: "my-payment-method" metadata: { token: "" } } ) { ... on Order { id code state # ... etc } ... on ErrorResult { errorCode message } ...on PaymentFailedError { paymentErrorMessage } ...on PaymentDeclinedError { paymentErrorMessage } ...on IneligiblePaymentMethodError { eligibilityCheckerMessage } } } ``` ```json { "data": { "addPaymentToOrder": { "id": "12345", "code": "J9AC5PY13BQGRKTF", "state": "PaymentAuthorized" } } } ``` :::info The `metadata` field is used to store the specific data required by the payment provider. E.g. some providers have a client-side part which begins the transaction and returns a token which must then be verified on the server side. The `metadata` field is required, so if your payment provider does not require any additional data, you can simply pass an empty object: `metadata: {}`. ::: 3. This mutation internally invokes the [PaymentMethodHandler's `createPayment()` function](/reference/typescript-api/payment/payment-method-config-options/#createpayment). This function returns a [CreatePaymentResult object](/reference/typescript-api/payment/payment-method-types#createpaymentfn) which is used to create a new [Payment](/reference/typescript-api/entities/payment). If the Payment amount equals the order total, then the Order is transitioned to either the `PaymentAuthorized` or `PaymentSettled` state and the customer checkout flow is complete. ### Single-step If the `createPayment()` function returns a result with the state set to `'Settled'`, then this is a single-step ("authorize & capture") flow, as illustrated below: ![./payment_sequence_one_step.png](./payment_sequence_one_step.png) ### Two-step If the `createPayment()` function returns a result with the state set to `'Authorized'`, then this is a two-step flow, and the settlement / capture part is performed at some later point, e.g. when shipping the goods, or on confirmation of payment-on-delivery. ![./payment_sequence_two_step.png](./payment_sequence_two_step.png) ## Custom Payment Flows If you need to support an entirely different payment flow than the above, it is also possible to do so by configuring a [PaymentProcess](/reference/typescript-api/payment/payment-process). This allows new Payment states and transitions to be defined, as well as allowing custom logic to run on Payment state transitions. Here's an example which adds a new "Validating" state to the Payment state machine, and combines it with a [OrderProcess](/reference/typescript-api/orders/order-process), [PaymentMethodHandler](/reference/typescript-api/payment/payment-method-handler) and [OrderPlacedStrategy](/reference/typescript-api/orders/order-placed-strategy). ```text ├── plugins └── my-payment-plugin ├── payment-process.ts ├── payment-method-handler.ts ├── order-process.ts └── order-placed-strategy.ts ``` ```ts title="src/plugins/my-payment-plugin/payment-process.ts" import { PaymentProcess } from '@vendure/core'; /** * Declare your custom state in special interface to make it type-safe */ declare module '@vendure/core' { interface PaymentStates { Validating: never; } } /** * Define a new "Validating" Payment state, and set up the * permitted transitions to/from it. */ const customPaymentProcess: PaymentProcess<'Validating'> = { transitions: { Created: { to: ['Validating'], mergeStrategy: 'replace', }, Validating: { to: ['Settled', 'Declined', 'Cancelled'], }, }, }; ``` ```ts title="src/plugins/my-payment-plugin/order-process.ts" import { OrderProcess } from '@vendure/core'; /** * Define a new "ValidatingPayment" Order state, and set up the * permitted transitions to/from it. */ const customOrderProcess: OrderProcess<'ValidatingPayment'> = { transitions: { ArrangingPayment: { to: ['ValidatingPayment'], mergeStrategy: 'replace', }, ValidatingPayment: { to: ['PaymentAuthorized', 'PaymentSettled', 'ArrangingAdditionalPayment'], }, }, }; ``` ```ts title="src/plugins/my-payment-plugin/payment-method-handler.ts" import { LanguageCode, PaymentMethodHandler } from '@vendure/core'; /** * This PaymentMethodHandler creates the Payment in the custom "Validating" * state. */ const myPaymentHandler = new PaymentMethodHandler({ code: 'my-payment-handler', description: [{languageCode: LanguageCode.en, value: 'My payment handler'}], args: {}, createPayment: (ctx, order, amount, args, metadata) => { // payment provider logic omitted return { state: 'Validating' as any, amount, metadata, }; }, settlePayment: (ctx, order, payment) => { return { success: true, }; }, }); ``` ```ts title="src/plugins/my-payment-plugin/order-placed-strategy.ts" import { OrderPlacedStrategy, OrderState, RequestContext } from '@vendure/core'; /** * This OrderPlacedStrategy tells Vendure to set the Order as "placed" * when it transitions to the custom "ValidatingPayment" state. */ class MyOrderPlacedStrategy implements OrderPlacedStrategy { shouldSetAsPlaced(ctx: RequestContext, fromState: OrderState, toState: OrderState): boolean | Promise { return fromState === 'ArrangingPayment' && toState === ('ValidatingPayment' as any); } } ``` ```ts title="src/vendure-config.ts" import { defaultOrderProcess, defaultPaymentProcess, VendureConfig } from '@vendure/core'; import { customOrderProcess } from './plugins/my-payment-plugin/order-process'; import { customPaymentProcess } from './plugins/my-payment-plugin/payment-process'; import { myPaymentHandler } from './plugins/my-payment-plugin/payment-method-handler'; import { MyOrderPlacedStrategy } from './plugins/my-payment-plugin/order-placed-strategy'; // Combine the above in the VendureConfig export const config: VendureConfig = { // ... orderOptions: { process: [defaultOrderProcess, customOrderProcess], orderPlacedStrategy: new MyOrderPlacedStrategy(), }, paymentOptions: { process: [defaultPaymentProcess, customPaymentProcess], paymentMethodHandlers: [myPaymentHandler], }, }; ``` ### Integration with hosted payment pages A hosted payment page is a system that works similar to [Stripe checkout](https://stripe.com/payments/checkout). The idea behind this flow is that the customer does not enter any credit card data anywhere on the merchant's site which waives the merchant from the responsibility to take care of sensitive data. The checkout flow works as follows: 1. The user makes a POST to the card processor's URL via a Vendure served page 2. The card processor accepts card information from the user and authorizes a payment 3. The card processor redirects the user back to Vendure via a POST which contains details about the processed payment 4. There is a pre-shared secret between the merchant and processor used to sign cross-site POST requests When integrating with a system like this, you would need to create a Controller to accept POST redirects from the payment processor (usually a success and a failure URL), as well as serve a POST form on your store frontend. With a hosted payment form the payment is already authorized by the time the card processor makes the POST request to Vendure, possibly settled even, so the payment handler won't do anything in particular - just return the data it has been passed. The validation of the POST request is done in the controller or service and the payment amount and payment reference are just passed to the payment handler which passes them on. --- --- title: "Products" --- Your catalog is composed of [`Products`](/reference/typescript-api/entities/product/) and [`ProductVariants`](/reference/typescript-api/entities/product-variant/). A `Product` always has _at least one_ `ProductVariant`. You can think of the product as a "container" which includes a name, description, and images that apply to all of its variants. Here's a visual example, in which we have a "Hoodie" product which is available in 3 sizes. Therefore, we have 3 variants of that product: ![Products and ProductVariants](./products-variants.webp) Multiple variants are made possible by adding one or more [`ProductOptionGroups`](/reference/typescript-api/entities/product-option-group) to the product. These option groups then define the available [`ProductOptions`](/reference/typescript-api/entities/product-option) If we were to add a new option group to the example above for "Color", with 2 options, "Black" and "White", then in total we would be able to define up to 6 variants: - Hoodie Small Black - Hoodie Small White - Hoodie Medium Black - Hoodie Medium White - Hoodie Large Black - Hoodie Large White :::info When a customer adds a product to their cart, they are adding a specific `ProductVariant` to their cart, not the `Product` itself. It is the `ProductVariant` that contains the SKU ("stock keeping unit", or product code) and price information. ::: ## Product price and stock The `ProductVariant` entity contains the price and stock information for a product. Since a given product variant can have more than one price, and more than one stock level (in the case of multiple warehouses), the `ProductVariant` entity contains relations to one or more [`ProductVariantPrice`](/reference/typescript-api/entities/product-variant-price) entities and one or more [`StockLevel`](/reference/typescript-api/entities/stock-level) entities. ![Price and stock](./product-relations.webp) ## Facets [`Facets`](/reference/typescript-api/entities/facet/) are used to add structured labels to products and variants. A facet has one or more [`FacetValues`](/reference/typescript-api/entities/facet-value/). Facet values can be assigned to products or product variants. For example, a "Brand" facet could be used to label products with the brand name, with each facet value representing a different brand. You can also use facets to add other metadata to products and variants such as "New", "Sale", "Featured", etc. ![Facets and FacetValues](./facets.webp) These are the typical uses of facets in Vendure: - As the **basis of [Collections](/guides/core-concepts/collections)**, in order to categorize your catalog. - To **filter products** in the storefront, also known as "faceted search". For example, a customer is on the "hoodies" collection page and wants to filter to only show Nike hoodies. - For **internal logic**, such as a promotion that applies to all variants with the "Summer Sale" facet value, or a shipping calculation that applies a surcharge to all products with the "Fragile" facet value. Such facets can be set to be private so that they are not exposed to the storefront. --- --- title: 'Promotions' --- Promotions are a means of offering discounts on an order based on various criteria. A Promotion consists of _conditions_ and _actions_. - **conditions** are the rules which determine whether the Promotion should be applied to the order. - **actions** specify exactly how this Promotion should modify the order. ## Parts of a Promotion ### Constraints All Promotions can have the following constraints applied to them: - **Date range** Using the "starts at" and "ends at" fields, the Promotion can be scheduled to only be active during the given date range. - **Coupon code** A Promotion can require a coupon code first be activated using the [`applyCouponCode` mutation](/reference/graphql-api/shop/mutations/#applycouponcode) in the Shop API. - **Per-customer limit** A Promotion coupon may be limited to a given number of uses per Customer. ### Conditions A Promotion may be additionally constrained by one or more conditions. When evaluating whether a Promotion should be applied, each of the defined conditions is checked in turn. If all the conditions evaluate to `true`, then any defined actions are applied to the order. Vendure comes with some built-in conditions, but you can also create your own conditions (see section below). ### Actions A promotion action defines exactly how the order discount should be calculated. **At least one** action must be specified for a valid Promotion. Vendure comes with some built-in actions, but you can also create your own actions (see section below). ## Creating custom conditions To create a custom condition, you need to define a new [`PromotionCondition` object](/reference/typescript-api/promotions/promotion-condition/). A promotion condition is an example of a [configurable operation](/guides/developer-guide/strategies-configurable-operations/#configurable-operations). Here is an annotated example of one of the built-in PromotionConditions. ```ts import { LanguageCode, PromotionCondition } from '@vendure/core'; export const minimumOrderAmount = new PromotionCondition({ /** A unique identifier for the condition */ code: 'minimum_order_amount', /** * A human-readable description. Values defined in the * `args` object can be interpolated using the curly-braces syntax. */ description: [ {languageCode: LanguageCode.en, value: 'If order total is greater than { amount }'}, ], /** * Arguments which can be specified when configuring the condition * in the Admin UI. The values of these args are then available during * the execution of the `check` function. */ args: { amount: { type: 'int', // The optional `ui` object allows you to customize // how this arg is rendered in the Admin UI. ui: {component: 'currency-form-input'}, }, taxInclusive: {type: 'boolean'}, }, /** * This is the business logic of the condition. It is a function that * must resolve to a boolean value indicating whether the condition has * been satisfied. */ check(ctx, order, args) { if (args.taxInclusive) { return order.subTotalWithTax >= args.amount; } else { return order.subTotal >= args.amount; } }, }); ``` Custom promotion conditions are then passed into the VendureConfig [PromotionOptions](/reference/typescript-api/promotions/promotion-options/) to make them available when setting up Promotions: ```ts title="src/vendure-config.ts" import { defaultPromotionConditions, VendureConfig } from '@vendure/core'; import { minimumOrderAmount } from './minimum-order-amount'; export const config: VendureConfig = { // ... promotionOptions: { promotionConditions: [ ...defaultPromotionConditions, minimumOrderAmount, ], } } ``` ## Creating custom actions There are three kinds of PromotionAction: - [`PromotionItemAction`](/reference/typescript-api/promotions/promotion-action#promotionitemaction) applies a discount on the `OrderLine` level, i.e. it would be used for a promotion like "50% off USB cables". - [`PromotionOrderAction`](/reference/typescript-api/promotions/promotion-action#promotionorderaction) applies a discount on the `Order` level, i.e. it would be used for a promotion like "5% off the order total". - [`PromotionShippingAction`](/reference/typescript-api/promotions/promotion-action#promotionshippingaction) applies a discount on the shipping, i.e. it would be used for a promotion like "free shipping". The implementations of each type is similar, with the difference being the arguments passed to the `execute()`. Here's an example of a simple PromotionOrderAction. ```ts import { LanguageCode, PromotionOrderAction } from '@vendure/core'; export const orderPercentageDiscount = new PromotionOrderAction({ // See the custom condition example above for explanations // of code, description & args fields. code: 'order_percentage_discount', description: [{languageCode: LanguageCode.en, value: 'Discount order by { discount }%'}], args: { discount: { type: 'int', ui: { component: 'number-form-input', suffix: '%', }, }, }, /** * This is the function that defines the actual amount to be discounted. * It should return a negative number representing the discount in * pennies/cents etc. Rounding to an integer is handled automatically. */ execute(ctx, order, args) { const orderTotal = ctx.channel.pricesIncludeTax ? order.subTotalWithTax : order.subTotal; return -orderTotal * (args.discount / 100); }, }); ``` Custom PromotionActions are then passed into the VendureConfig [PromotionOptions](/reference/typescript-api/promotions/promotion-options) to make them available when setting up Promotions: ```ts title="src/vendure-config.ts" import { defaultPromotionActions, VendureConfig } from '@vendure/core'; import { orderPercentageDiscount } from './order-percentage-discount'; export const config: VendureConfig = { // ... promotionOptions: { promotionActions: [ ...defaultPromotionActions, orderPercentageDiscount, ], } }; ``` ## Free gift promotions Vendure v1.8 introduced a new **side effect API** to PromotionActions, which allow you to define some additional action to be performed when a Promotion becomes active or inactive. A primary use-case of this API is to add a free gift to the Order. Here's an example of a plugin which implements a "free gift" action: ```ts title="src/plugins/free-gift/free-gift.plugin.ts" import { ID, idsAreEqual, isGraphQlErrorResult, LanguageCode, Logger, OrderLine, OrderService, PromotionItemAction, VendurePlugin, } from "@vendure/core"; import { createHash } from "crypto"; let orderService: OrderService; export const freeGiftAction = new PromotionItemAction({ code: "free_gift", description: [{ languageCode: LanguageCode.en, value: "Add free gifts to the order" }], args: { productVariantIds: { type: "ID", list: true, ui: { component: "product-selector-form-input" }, label: [{ languageCode: LanguageCode.en, value: "Gift product variants" }], }, }, init(injector) { orderService = injector.get(OrderService); }, execute(ctx, orderLine, args) { // This part is responsible for ensuring the variants marked as // "free gifts" have their price reduced to zero if (lineContainsIds(args.productVariantIds, orderLine)) { const unitPrice = orderLine.productVariant.listPriceIncludesTax ? orderLine.unitPriceWithTax : orderLine.unitPrice; return -unitPrice; } return 0; }, // The onActivate function is part of the side effect API, and // allows us to perform some action whenever a Promotion becomes active // due to it's conditions & constraints being satisfied. async onActivate(ctx, order, args, promotion) { for (const id of args.productVariantIds) { if ( !order.lines.find( (line) => idsAreEqual(line.productVariant.id, id) && line.customFields.freeGiftPromotionId == null ) ) { // The order does not yet contain this free gift, so add it const result = await orderService.addItemToOrder(ctx, order.id, id, 1, { freeGiftPromotionId: promotion.id.toString(), }); if (isGraphQlErrorResult(result)) { Logger.error(`Free gift action error for variantId "${id}": ${result.message}`); } } } }, // The onDeactivate function is the other part of the side effect API and is called // when an active Promotion becomes no longer active. It should reverse any // side effect performed by the onActivate function. async onDeactivate(ctx, order, args, promotion) { const linesWithFreeGift = order.lines.filter( (line) => line.customFields.freeGiftPromotionId === promotion.id.toString() ); for (const line of linesWithFreeGift) { await orderService.removeItemFromOrder(ctx, order.id, line.id); } }, }); function lineContainsIds(ids: ID[], line: OrderLine): boolean { return !!ids.find((id) => idsAreEqual(id, line.productVariant.id)); } @VendurePlugin({ configuration: (config) => { config.customFields.OrderLine.push({ name: "freeGiftPromotionId", type: "string", public: true, readonly: true, nullable: true, }); config.customFields.OrderLine.push({ name: "freeGiftDescription", type: "string", public: true, readonly: true, nullable: true, }); config.promotionOptions.promotionActions.push(freeGiftAction); return config; }, }) export class FreeGiftPromotionPlugin {} ``` ## Dependency relationships It is possible to establish dependency relationships between a PromotionAction and one or more PromotionConditions. For example, if we want to set up a "buy 1, get 1 free" offer, we need to: 1. Establish whether the Order contains the particular ProductVariant under offer (done in the PromotionCondition) 2. Apply a discount to the qualifying OrderLine (done in the PromotionAction) In this scenario, we would have to repeat the logic for checking the Order contents in _both_ the PromotionCondition _and_ the PromotionAction. Not only is this duplicated work for the server, it also means that setting up the promotion relies on the same parameters being input into the PromotionCondition and the PromotionAction. Note the use of `PromotionItemAction` to get a reference to the `OrderLine` as opposed to the `Order`. Instead, we can say that the PromotionAction _depends_ on the PromotionCondition: ```ts export const buy1Get1FreeAction = new PromotionItemAction({ code: 'buy_1_get_1_free', description: [{ languageCode: LanguageCode.en, value: 'Buy 1, get 1 free', }], args: {}, // highlight-next-line conditions: [buyXGetYFreeCondition], execute(ctx, orderLine, args, state) { // highlight-next-line const freeItemIds = state.buy_x_get_y_free.freeItemIds; if (idsContainsItem(freeItemIds, orderLine)) { const unitPrice = ctx.channel.pricesIncludeTax ? orderLine.unitPriceWithTax : orderLine.unitPrice; return -unitPrice; } return 0; }, }); ``` In the above code, we are stating that this PromotionAction _depends_ on the `buyXGetYFreeCondition` PromotionCondition. Attempting to create a Promotion using the `buy1Get1FreeAction` without also using the `buyXGetYFreeCondition` will result in an error. In turn, the `buyXGetYFreeCondition` can return a _state object_ with the type `{ [key: string]: any; }` instead of just a `true` boolean value. This state object is then passed to the PromotionConditions which depend on it, as part of the last argument (`state`). ```ts export const buyXGetYFreeCondition = new PromotionCondition({ code: 'buy_x_get_y_free', description: [{ languageCode: LanguageCode.en, value: 'Buy { amountX } of { variantIdsX } products, get { amountY } of { variantIdsY } products free', }], args: { // omitted for brevity }, async check(ctx, order, args) { // logic omitted for brevity if (freeItemIds.length === 0) { return false; } // highlight-next-line return {freeItemIds}; }, }); ``` ## Injecting providers If your PromotionCondition or PromotionAction needs access to the database or other providers, they can be injected by defining an `init()` function in your PromotionAction or PromotionCondition. See the [configurable operation guide](/guides/developer-guide/strategies-configurable-operations/#injecting-dependencies) for details. --- --- title: "Shipping & Fulfillment" --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; Shipping in Vendure is handled by [ShippingMethods](/reference/typescript-api/entities/shipping-method/). A ShippingMethod is composed of a **checker** and a **calculator**. * The [`ShippingEligibilityChecker`](/reference/typescript-api/shipping/shipping-eligibility-checker/) determines whether the order is eligible for the ShippingMethod. It can contain custom logic such as checking the total weight of the order, or whether the order is being shipped to a particular country. * The [`ShippingCalculator`](/reference/typescript-api/shipping/shipping-calculator/) calculates the cost of shipping the order. The calculation can be performed directly by the calculator itself, or it could call out to a third-party API to determine the cost. ![Shipping method](./shipping-method.webp) Multiple shipping methods can be set up and then your storefront can query [`eligibleShippingMethods`](/reference/graphql-api/shop/queries/#eligibleshippingmethods) to find out which ones can be applied to the active order. When querying `eligibleShippingMethods`, each of the defined ShippingMethods' checker functions is executed to find out whether the order is eligible for that method, and if so, the calculator is executed to determine what the cost of shipping will be for that method. ```graphql title="Shop API" query GetEligibleShippingMethods { eligibleShippingMethods { id name price priceWithTax } } ``` ```json { "data": { "eligibleShippingMethods": [ { "id": "1", "name": "Standard Shipping", "price": 500, "priceWithTax": 500 }, { "id": "2", "name": "Express Shipping", "price": 1000, "priceWithTax": 1000 } ] } } ``` ## Creating a custom checker Custom checkers can be created by defining a [`ShippingEligibilityChecker` object](/reference/typescript-api/shipping/shipping-eligibility-checker/). For example, you could create a checker which works with a custom "weight" field to only apply to orders below a certain weight: ```ts title="src/shipping-methods/max-weight-checker.ts" import { LanguageCode, ShippingEligibilityChecker } from '@vendure/core'; export const maxWeightChecker = new ShippingEligibilityChecker({ code: 'max-weight-checker', description: [ {languageCode: LanguageCode.en, value: 'Max Weight Checker'} ], args: { maxWeight: { type: 'int', ui: {component: 'number-form-input', suffix: 'grams'}, label: [{languageCode: LanguageCode.en, value: 'Maximum order weight'}], description: [ { languageCode: LanguageCode.en, value: 'Order is eligible only if its total weight is less than the specified value', }, ], }, }, /** * Must resolve to a boolean value, where `true` means that the order is * eligible for this ShippingMethod. * * (This example assumes a custom field "weight" is defined on the * ProductVariant entity) */ check: (ctx, order, args) => { const totalWeight = order.lines .map(l => l.productVariant.customFields.weight ?? 0 * l.quantity) .reduce((total, lineWeight) => total + lineWeight, 0); return totalWeight <= args.maxWeight; }, }); ``` Custom checkers are then passed into the VendureConfig [ShippingOptions](/reference/typescript-api/shipping/shipping-options/#shippingeligibilitycheckers) to make them available when setting up new ShippingMethods: ```ts title="src/vendure-config.ts" import { defaultShippingEligibilityChecker, VendureConfig } from '@vendure/core'; import { maxWeightChecker } from './shipping-methods/max-weight-checker'; export const config: VendureConfig = { // ... shippingOptions: { shippingEligibilityCheckers: [ defaultShippingEligibilityChecker, maxWeightChecker, ], } } ``` ## Creating a custom calculator Custom calculators can be created by defining a [`ShippingCalculator` object](/reference/typescript-api/shipping/shipping-calculator/). For example, you could create a calculator which consults an external data source (e.g. a spreadsheet, database or 3rd-party API) to find out the cost and estimated delivery time for the order. ```ts title="src/shipping-methods/external-shipping-calculator.ts" import { LanguageCode, ShippingCalculator } from '@vendure/core'; import { shippingDataSource } from './shipping-data-source'; export const externalShippingCalculator = new ShippingCalculator({ code: 'external-shipping-calculator', description: [{languageCode: LanguageCode.en, value: 'Calculates cost from external source'}], args: { taxRate: { type: 'int', ui: {component: 'number-form-input', suffix: '%'}, label: [{languageCode: LanguageCode.en, value: 'Tax rate'}], }, }, calculate: async (ctx, order, args) => { // `shippingDataSource` is assumed to fetch the data from some // external data source. const { rate, deliveryDate, courier } = await shippingDataSource.getRate({ destination: order.shippingAddress, contents: order.lines, }); return { price: rate, priceIncludesTax: ctx.channel.pricesIncludeTax, taxRate: args.taxRate, // metadata is optional but can be used to pass arbitrary // data about the shipping estimate to the storefront. metadata: { courier, deliveryDate }, }; }, }); ``` Custom calculators are then passed into the VendureConfig [ShippingOptions](/reference/typescript-api/shipping/shipping-options/#shippingcalculators) to make them available when setting up new ShippingMethods: ```ts import { defaultShippingCalculator, VendureConfig } from '@vendure/core'; import { externalShippingCalculator } from './external-shipping-calculator'; export const config: VendureConfig = { // ... shippingOptions: { shippingCalculators: [ defaultShippingCalculator, externalShippingCalculator, ], } } ``` :::info If your ShippingEligibilityChecker or ShippingCalculator needs access to the database or other providers, see the [configurable operation dependency injection guide](/guides/developer-guide/strategies-configurable-operations/#injecting-dependencies). ::: ## Fulfillments Fulfillments represent the actual shipping status of items in an order. When an order is placed and payment has been settled, the order items are then delivered to the customer in one or more Fulfillments. * **Physical goods:** A fulfillment would represent the actual boxes or packages which are shipped to the customer. When the package leaves the warehouse, the fulfillment is marked as `Shipped`. When the package arrives with the customer, the fulfillment is marked as `Delivered`. * **Digital goods:** A fulfillment would represent the means of delivering the digital goods to the customer, e.g. a download link or a license key. For example, when the link is sent to the customer, the fulfillment can be marked as `Shipped` and then `Delivered`. ### FulfillmentHandlers It is often required to integrate your fulfillment process, e.g. with an external shipping API which provides shipping labels or tracking codes. This is done by defining [FulfillmentHandlers](/reference/typescript-api/fulfillment/fulfillment-handler/) (click the link for full documentation) and passing them in to the `shippingOptions.fulfillmentHandlers` array in your config. By default, Vendure uses a manual fulfillment handler, which requires the Administrator to manually enter the method and tracking code of the Fulfillment. ### Fulfillment state machine Like Orders, Fulfillments are governed by a [finite state machine](/reference/typescript-api/state-machine/fsm/) and by default, a Fulfillment can be in one of the [following states](/reference/typescript-api/fulfillment/fulfillment-state#fulfillmentstate): * `Pending` The Fulfillment has been created * `Shipped` The Fulfillment has been shipped * `Delivered` The Fulfillment has arrived with the customer * `Cancelled` The Fulfillment has been cancelled These states cover the typical workflow for fulfilling orders. However, it is possible to customize the fulfillment workflow by defining a [FulfillmentProcess](/reference/typescript-api/fulfillment/fulfillment-process) and passing it to your VendureConfig: ```ts title="src/vendure-config.ts" import { FulfillmentProcess, VendureConfig } from '@vendure/core'; import { myCustomFulfillmentProcess } from './my-custom-fulfillment-process'; export const config: VendureConfig = { // ... shippingOptions: { process: [myCustomFulfillmentProcess], }, }; ``` :::info For a more detailed look at how custom processes are used, see the [custom order processes guide](/guides/core-concepts/orders/#custom-order-processes). ::: --- --- title: "Stock Control" --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; Vendure includes features to help manage your stock levels, stock allocations and back orders. The basic purpose is to help you keep track of how many of a given ProductVariant you have available to sell. Stock control is enabled globally via the Global Settings: ![./global-stock-control.webp](./global-stock-control.webp) It can be disabled if, for example, you manage your stock with a separate inventory management system and synchronize stock levels into Vendure automatically. The setting can also be overridden at the individual ProductVariant level. ## Stock Locations Vendure uses the concept of [`StockLocations`](/reference/typescript-api/entities/stock-location/) to represent the physical locations where stock is stored. This could be a warehouse, a retail store, or any other location. If you do not have multiple stock locations, then you can simply use the default location which is created automatically. ![Stock levels](./stock-levels.webp) ### Selecting a stock location When you have multiple stock locations set up, you need a way to determine which location to use when querying stock levels and when allocating stock to orders. This is handled by the [`StockLocationStrategy`](/reference/typescript-api/products-stock/stock-location-strategy/). This strategy exposes a number of methods which are used to determine which location (or locations) to use when: - querying stock levels (`getAvailableStock`) - allocating stock to orders (`forAllocation`) - releasing stock from orders (`forRelease`) - creating sales upon fulfillment (`forSale`) - returning items to stock upon cancellation (`forCancellation`) The default strategy is the [`DefaultStockLocationStrategy`](/reference/typescript-api/products-stock/default-stock-location-strategy), which simply uses the default location for all of the above methods. This is suitable for all cases where there is just a single stock location. If you have multiple stock locations, you'll need to implement a custom strategy which uses custom logic to determine which stock location to use. For instance, you could: - Use the location with the most stock available - Use the location closest to the customer - Use the location which has the cheapest shipping cost ### Displaying stock levels in the storefront The [`StockDisplayStrategy`](/reference/typescript-api/products-stock/stock-display-strategy/) is used to determine how stock levels are displayed in the storefront. The default strategy is the [`DefaultStockDisplayStrategy`](/reference/typescript-api/products-stock/default-stock-display-strategy), which will only display one of three states: `'IN_STOCK'`, `'OUT_OF_STOCK'` or `'LOW_STOCK'`. This is to avoid exposing your exact stock levels to the public, which can sometimes be undesirable. You can implement a custom strategy to display stock levels in a different way. Here's how you would implement a custom strategy to display exact stock levels: ```ts title="src/exact-stock-display-strategy.ts" import { RequestContext, StockDisplayStrategy, ProductVariant } from '@vendure/core'; export class ExactStockDisplayStrategy implements StockDisplayStrategy { getStockLevel(ctx: RequestContext, productVariant: ProductVariant, saleableStockLevel: number): string { return saleableStockLevel.toString(); } } ``` This strategy is then used in your config: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { ExactStockDisplayStrategy } from './exact-stock-display-strategy'; export const config: VendureConfig = { // ... catalogOptions: { stockDisplayStrategy: new ExactStockDisplayStrategy(), }, }; ``` ## Stock Control Concepts * **Stock on hand:** This refers to the number of physical units of a particular variant which you have in stock right now. This can be zero or more, but not negative. * **Allocated:** This refers to the number of units which have been assigned to Orders, but which have not yet been fulfilled. * **Out-of-stock threshold:** This value determines the stock level at which the variant is considered "out of stock". This value is set globally, but can be overridden for specific variants. It defaults to `0`. * **Saleable:** This means the number of units that can be sold right now. The formula is: `saleable = stockOnHand - allocated - outOfStockThreshold` Here's a table to better illustrate the relationship between these concepts: Stock on hand | Allocated | Out-of-stock threshold | Saleable --------------|-----------|------------------------|---------- 10 | 0 | 0 | 10 10 | 0 | 3 | 7 10 | 5 | 0 | 5 10 | 5 | 3 | 2 10 | 10 | 0 | 0 10 | 10 | -5 | 5 The saleable value is what determines whether the customer is able to add a variant to an order. If there is 0 saleable stock, then any attempt to add to the order will result in an [`InsufficientStockError`](/reference/graphql-api/admin/object-types/#insufficientstockerror). ```graphql title="Shop API" query AddItemToOrder { addItemToOrder(productVariantId: 123, quantity: 150) { ...on Order { id code totalQuantity } ...on ErrorResult { errorCode message } ...on InsufficientStockError { errorCode message quantityAvailable order { id totalQuantity } } } } ``` ```json { "data": { "addItemToOrder": { "errorCode": "INSUFFICIENT_STOCK_ERROR", "message": "Only 105 items were added to the order due to insufficient stock", "quantityAvailable": 105, "order": { "id": "2", "totalQuantity": 106 } } } } ``` ### Stock allocation Allocation mean we are setting stock aside because it has been purchased but not yet shipped. It prevents us from selling more of a particular item than we are able to deliver. By default, stock gets allocated to an order once the order transitions to the `PaymentAuthorized` or `PaymentSettled` state. This is defined by the [`DefaultStockAllocationStrategy`](/reference/typescript-api/orders/default-stock-allocation-strategy). Using a custom [`StockAllocationStrategy`](/reference/typescript-api/orders/stock-allocation-strategy/) you can define your own rules for when stock is allocated. With the [`defaultFulfillmentProcess`](/reference/typescript-api/fulfillment/fulfillment-process/#defaultfulfillmentprocess), allocated stock will be converted to **sales** and minused from the `stockOnHand` value when a Fulfillment is created. ### Back orders You may have noticed that the `outOfStockThreshold` value can be set to a negative number. This allows you to sell variants even when you don't physically have them in stock. This is known as a "back order". Back orders can be really useful to allow orders to keep flowing even when stockOnHand temporarily drops to zero. For many businesses with predictable re-supply schedules they make a lot of sense. Once a customer completes checkout, those variants in the order are marked as `allocated`. When a Fulfillment is created, those allocations are converted to Sales and the `stockOnHand` of each variant is adjusted. Fulfillments may only be created if there is sufficient stock on hand. ### Stock movements There is a [`StockMovement`](/reference/typescript-api/entities/stock-movement/) entity which records the history of stock changes. `StockMovement` is actually an abstract class, with the following concrete implementations: - [`Allocation`](/reference/typescript-api/entities/stock-movement/#allocation): When stock is allocated to an order, before the order is fulfilled. Adds stock to `allocated`, which reduces the saleable stock. - [`Sale`](/reference/typescript-api/entities/stock-movement/#sale): When allocated stock gets fulfilled. Removes stock from `allocated` as well as `stockOnHand`. - [`Cancellation`](/reference/typescript-api/entities/stock-movement/#cancellation): When items from a fulfilled order are cancelled, the stock is returned to `stockOnHand`. Adds stock to `stockOnHand`. - [`Release`](/reference/typescript-api/entities/stock-movement/#release): When items which have been allocated (but not yet converted to sales via the creation of a Fulfillment) are cancelled. Removes stock from `allocated`. - [`StockAdjustment`](/reference/typescript-api/entities/stock-movement/#stockadjustment): A general-purpose stock adjustment. Adds or removes stock from `stockOnHand`. Used when manually setting stock levels via the Admin UI, for example. Stock movements can be queried via the `ProductVariant.stockMovements`. Here's an example where we query the stock levels and stock movements of a particular variant: ```graphql title="Admin API" query GetStockMovements { productVariant(id: 1) { id name stockLevels { stockLocation { name } stockOnHand stockAllocated } stockMovements { items { ...on StockMovement { createdAt type quantity } } } } } ``` ```json { "data": { "productVariant": { "id": "1", "name": "Laptop 13 inch 8GB", "stockLevels": [ { "stockLocation": { "name": "Default Stock Location" }, "stockOnHand": 100, "stockAllocated": 0 } ], "stockMovements": { "items": [ { "createdAt": "2023-07-13T13:21:10.000Z", "type": "ADJUSTMENT", "quantity": 100 } ] } } } } ``` --- --- title: "Taxes" showtoc: true --- E-commerce applications need to correctly handle taxes such as sales tax or value added tax (VAT). In Vendure, tax handling consists of: * **Tax categories** Each ProductVariant is assigned to a specific TaxCategory. In some tax systems, the tax rate differs depending on the type of good. For example, VAT in the UK has 3 rates, "standard" (most goods), "reduced" (e.g. child car seats) and "zero" (e.g. books). * **Tax rates** This is the tax rate applied to a specific tax category for a specific [Zone](/reference/typescript-api/entities/zone/). E.g., the tax rate for "standard" goods in the UK Zone is 20%. * **Channel tax settings** Each Channel can specify whether the prices of product variants are inclusive of tax or not, and also specify the default Zone to use for tax calculations. * **TaxZoneStrategy** Determines the active tax Zone used when calculating what TaxRate to apply. By default, it uses the default tax Zone from the Channel settings. * **TaxLineCalculationStrategy** This determines the taxes applied when adding an item to an Order. If you want to integrate a 3rd-party tax API or other async lookup, this is where it would be done. ## API conventions In the GraphQL API, any type which has a taxable price will split that price into two fields: `price` and `priceWithTax`. This pattern also holds for other price fields, e.g. ```graphql query { activeOrder { ...on Order { lines { linePrice linePriceWithTax } subTotal subTotalWithTax shipping shippingWithTax total totalWithTax } } } ``` In your storefront, you can therefore choose whether to display the prices with or without tax, according to the laws and conventions of the area in which your business operates. ## Calculating tax on order lines When a customer adds an item to the Order, the following logic takes place: 1. The price of the item, and whether that price is inclusive of tax, is determined according to the configured [OrderItemPriceCalculationStrategy](/reference/typescript-api/orders/order-item-price-calculation-strategy/). 2. The active tax Zone is determined based on the configured [TaxZoneStrategy](/reference/typescript-api/tax/tax-zone-strategy/). By default, Vendure will use the default tax Zone from the Channel settings. However, you often want to use the customer's address as the basis for determining the tax Zone. In this case, you should use the [AddressBasedTaxZoneStrategy](/reference/typescript-api/tax/address-based-tax-zone-strategy). 3. The applicable TaxRate is fetched based on the ProductVariant's TaxCategory and the active tax Zone determined in step 1. 4. The `TaxLineCalculationStrategy.calculate()` of the configured [TaxLineCalculationStrategy](/reference/typescript-api/tax/tax-line-calculation-strategy/) is called, which will return one or more [TaxLines](/reference/graphql-api/admin/object-types/#taxline). 5. The final `priceWithTax` of the order line is calculated based on all the above. ## Calculating tax on shipping The taxes on shipping is calculated by the [ShippingCalculator](/reference/typescript-api/shipping/shipping-calculator/) of the Order's selected [ShippingMethod](/reference/typescript-api/entities/shipping-method/). ## Configuration This example shows the default configuration for taxes (you don't need to specify this in your own config, as these are the defaults): ```ts title="src/vendure-config.ts" import { DefaultTaxLineCalculationStrategy, DefaultTaxZoneStrategy, DefaultOrderItemPriceCalculationStrategy, VendureConfig } from '@vendure/core'; export const config: VendureConfig = { taxOptions: { taxZoneStrategy: new DefaultTaxZoneStrategy(), taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(), }, orderOptions: { orderItemPriceCalculationStrategy: new DefaultOrderItemPriceCalculationStrategy() } } ``` --- --- title: "Deploying to Digital Ocean" --- ![Deploy to Digital Ocean App Platform](./deploy-to-do-app-platform.webp) [App Platform](https://www.digitalocean.com/products/app-platform) is a fully managed platform which allows you to deploy and scale your Vendure server and infrastructure with ease. :::note The configuration in this guide will cost around $22 per month to run. ::: ## Prerequisites First of all you'll need to [create a new Digital Ocean account](https://cloud.digitalocean.com/registrations/new) if you don't already have one. For this guide you'll need to have your Vendure project in a git repo on either GitHub or GitLab. App Platform also supports deploying from docker registries, but that is out of the scope of this guide. :::info If you'd like to quickly get started with a ready-made Vendure project which includes sample data, you can clone our [Vendure one-click-deploy repo](https://github.com/vendure-ecommerce/one-click-deploy). ::: ## Configuration ### Database connection :::info The following is already pre-configured if you are using the one-click-deploy repo. ::: Make sure your DB connection options uses the following environment variables: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... dbConnectionOptions: { // ... type: 'postgres', database: process.env.DB_NAME, host: process.env.DB_HOST, port: +process.env.DB_PORT, username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, ssl: process.env.DB_CA_CERT ? { ca: process.env.DB_CA_CERT, } : undefined, }, }; ``` ### Asset storage :::info The following is already pre-configured if you are using the one-click-deploy repo. ::: Since App Platform services do not include any persistent storage, we need to configure Vendure to use Digital Ocean's Spaces service, which is an S3-compatible object storage service. This means you'll need to make sure to have the following packages installed: ``` npm install @aws-sdk/client-s3 @aws-sdk/lib-storage ``` and set up your AssetServerPlugin like this: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { AssetServerPlugin, configureS3AssetStorage } from '@vendure/asset-server-plugin'; export const config: VendureConfig = { // ... plugins: [ AssetServerPlugin.init({ route: 'assets', assetUploadDir: process.env.ASSET_UPLOAD_DIR || path.join(__dirname, '../static/assets'), // highlight-start // If the MINIO_ENDPOINT environment variable is set, we'll use // Minio as the asset storage provider. Otherwise, we'll use the // default local provider. storageStrategyFactory: process.env.MINIO_ENDPOINT ? configureS3AssetStorage({ bucket: 'vendure-assets', credentials: { accessKeyId: process.env.MINIO_ACCESS_KEY, secretAccessKey: process.env.MINIO_SECRET_KEY, }, nativeS3Configuration: { endpoint: process.env.MINIO_ENDPOINT, forcePathStyle: true, signatureVersion: 'v4', // The `region` is required by the AWS SDK even when using MinIO, // so we just use a dummy value here. region: 'eu-west-1', }, }) : undefined, // highlight-end }), ], // ... }; ``` ## Create Spaces Object Storage First we'll create a Spaces bucket to store our assets. Click the "Spaces Object Storage" nav item and create a new space and call it "vendure-assets". ![Create Spaces Object Storage](./01-create-space.webp) Next we need to create an access key and secret. Click the "API" nav item and generate a new key. ![Create API key](./02-space-access-keys.webp) Name the key something meaningful like "vendure-assets-key" and then **make sure to copy the secret as it will only be shown once**. Store the access key and secret key in a safe place for later - we'll be using it when we set up our app's environment variables. :::caution If you forget to copy the secret key, you'll need to delete the key and create a new one. ::: ## Create the server resource Now we're ready to create our app infrastructure! Click the "Create" button in the top bar and select "Apps". ![Create App](./03-create-app.webp) Now connect to your git repo, and select the repo of your Vendure project. Depending on your repo, App Platform may suggest more than one app: in this screenshot we are using the one-click-deploy repo which contains a Dockerfile, so App Platform is suggesting two different ways to deploy the app. We'll select the Dockerfile option, but either option should work fine. Delete the unused resource. We need to edit the details of the server app. Click the "Edit" button and set the following: * **Resource Name**: "vendure-server" * **Resource Type**: Web Service * **Run Command**: `node ./dist/index.js` * **HTTP Port**: 3000 At this point you can also click the "Edit Plan" button to select the resource allocation for the server, which will determine performance and price. For testing purposes the smallest Basic server (512MB, 1vCPU) is fine. This can also be changed later. ### Add a database Next click "Add Resource", select **Database** and click "Add", and then accept the default Postgres database. Click the "Create and Attach" to create the database and attach it to the server app. Your config should now look like this: ![App setup](./04-configure-server.webp) ## Set up environment variables Next we need to set up the environment variables. Since these will be shared by both the server and worker apps, we'll create them at the Global level. You can use the "bulk editor" to paste in the following variables (making sure to replace the values in `` with your own values): ```sh DB_NAME=${db.DATABASE} DB_USERNAME=${db.USERNAME} DB_PASSWORD=${db.PASSWORD} DB_HOST=${db.HOSTNAME} DB_PORT=${db.PORT} DB_CA_CERT=${db.CA_CERT} // highlight-next-line COOKIE_SECRET= SUPERADMIN_USERNAME=superadmin // highlight-next-line SUPERADMIN_PASSWORD= // highlight-start MINIO_ACCESS_KEY= MINIO_SECRET_KEY= MINIO_ENDPOINT= // highlight-end ``` :::note The values starting with `${db...}` are automatically populated by App Platform and should not be changed, unless you chose to name your database something other than `db`. ::: :::note When using the App Platform with a Dev Database, DigitalOcean only provides a self-signed SSL certificate. This may prevent the Vendure app from starting. As a workaround, you can add the environment variable `NODE_TLS_REJECT_UNAUTHORIZED` and set it to `0`. ::: After saving your environment variables you can click through the confirmation screens to create the app. ## Create the worker resource Now we need to set up the [Vendure worker](/guides/developer-guide/worker-job-queue/) which will handle background tasks. From the dashboard of your newly-created app, click the "Create" button and then select "Create Resources From Source Code". You will be prompted to select the repo again, and then you'll need to set up a new single resource with the following settings: * **Resource Name**: "vendure-worker" * **Resource Type**: Worker * **Run Command**: `node ./dist/index-worker.js` Again you can edit the plan, and the smallest Basic plan is fine for testing purposes. No new environment variables are needed since we'll be sharing the Global variables with the worker. ## Enable access to the required routes To be able to access the UI and other routes, we need to declare them first. 1. In the app dashboard, click on the **Settings** tab. 2. Click on the **vendure-server** component. ![Open server settings](./05-open-server-settings.webp) 3. Scroll down to **HTTP Request Routes**. 4. Click on **Edit**. 5. Click on **+ Add new route** and fill in the form like this: ![Admin app route](./06-admin-app-route.webp) 6. Make sure to check the **Preserve Path Prefix** option. You need to do this for the following routes: - `/admin` - `/assets` - `/health` - `/admin-api` - `/shop-api` 7. Click on **Save**. ## Test your Vendure server Once everything has finished deploying, you can click the app URL to open your Vendure server in a new tab. ![Open app](./07-open-app.webp) :::info Append `/admin` to the URL to access the admin UI, and log in with the superadmin credentials you set in the environment variables. ::: --- --- title: "Deploying to Google Cloud Run" images: - "/docs/deployment/deploy-to-google-cloud-run/deploy-to-gcr.webp" --- ![./deploy-to-gcr.webp](./deploy-to-gcr.webp) [Google Cloud Run](https://cloud.google.com/run) is a fully managed platform which allows you to run containerized apps and only pay while your app code is actually running. This guide was written by Martijn from [Pinelab](https://pinelab.studio/), who have been successfully running multiple Vendure projects on Google Cloud Run. The step by step commands can be found here on GitHub: - https://github.com/Pinelab-studio/vendure-google-cloud-run-starter/blob/main/README.md. ## Prerequisites This guide assumes you have: * Google cloud's `gcloud` cli installed locally * Created a Google Cloud project and enabled the API's we need: https://github.com/Pinelab-studio/vendure-google-cloud-run-starter/blob/main/README.md#create-a-google-cloud-project ## Setting up a MySQL database with Google Cloud SQL Google Cloud SQL is a fully-managed relational database service that makes it easy to set up, maintain, and manage databases in the cloud. Vendure requires an SQL database to store its data, and Google Cloud SQL is a great option for this because it provides a reliable, scalable, and secure way to host our database. You can find the `gcloud` commands to create a MySQL database here: https://github.com/Pinelab-studio/vendure-google-cloud-run-starter/blob/main/README.md#create-a-mysql-database ## Google Cloud Storage for assets Vendure stores assets such as product images on file system by default. However, Google Cloud Run does not have internal file storage, so we need to use an external storage service. Google Cloud Storage is a great option for this because it provides a scalable and reliable way to store our assets in the cloud. Use these `gcloud` [commands](https://github.com/Pinelab-studio/vendure-google-cloud-run-starter/blob/main/README.md#asset-storage) to create a storage bucket for our assets. ## Google Cloud Tasks for Vendure's worker Vendure uses a worker process to perform asynchronous tasks such as sending emails. To communicate between the main application and the worker process, we need a message queue. Google Cloud Tasks is a great option for this because it provides a fully-managed, scalable, and reliable way to send and receive messages between applications. You don't need to do anything to enable Cloud Tasks: [this plugin](https://github.com/Pinelab-studio/vendure-google-cloud-run-starter/blob/8fd342c15fa7b38e3662311f176901a5d38cde3d/src/vendure-config.ts#L88) automatically creates task queues for you. ## Running locally Let's test out our application locally before deploying to Cloud Run. Copy [this](https://github.com/Pinelab-studio/vendure-google-cloud-run-starter/blob/main/.env.example) `.env.example` to `.env` and fill in your variables. You can skip the `WORKER_HOST` variable, because we don't have it yet. ## Dockerize Vendure Google Cloud Run allows us to deploy containerized applications without worrying about the underlying infrastructure. To deploy Vendure to Google Cloud Run, we need to Dockerize it. Dockerizing Vendure means packaging the application and its dependencies into a container that can be easily deployed to Google Cloud Run. The setup for containerizing Vendure is already done: [This file](https://github.com/Pinelab-studio/vendure-google-cloud-run-starter/blob/main/Dockerfile) and [this file](https://github.com/Pinelab-studio/vendure-google-cloud-run-starter/blob/main/build-docker.sh) will build your container. ## Deployment The example repository contains GitHub action definitions to automatically deploy your app to Cloud Run when you push to the `main` branch. Follow [these steps](https://github.com/Pinelab-studio/vendure-google-cloud-run-starter/blob/main/build-docker.sh) to create a service account and set your variables as repository secret in GitHub. ## Keep alive As final improvement, you can use Google Cloud Scheduler to poll your Cloud Run instance to prevent cold starts with [this command](https://github.com/Pinelab-studio/vendure-google-cloud-run-starter/blob/main/build-docker.sh). That's it! Feel free to reach out for any questions, or create a [Pull Request in the repository](https://github.com/Pinelab-studio/vendure-google-cloud-run-starter/pulls) if you have any improvements. --- --- title: "Deploying to Northflank" showtoc: true images: - "/docs/deployment/deploy-to-northflank/deploy-to-northflank.webp" --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; ![./deploy-to-northflank.webp](./deploy-to-northflank.webp) [Northflank](https://northflank.com) is a comprehensive developer platform to build and scale your apps. It has an outstanding developer experience and has a free tier for small projects, and is well-suited for deploying and scaling Vendure applications. This guide will walk you through the steps to deploy [a sample Vendure application](https://github.com/vendure-ecommerce/one-click-deploy) to Northflank. ## Set up a Northflank account Go to the Northflank [sign up page](https://app.northflank.com/signup) to create a new account. As part of the sign-up you'll be asked for credit card details, but you won't be charged unless you upgrade to a paid plan. ## Create a custom template A template defines the infrastructure that is needed to run your Vendure server. Namely, a **server**, a **worker**, **MinIO object storage** for assets and a **Postgres database**. Click the templates menu item in the navbar and click the "Create template" button. ![./01-create-template-screen.webp](./01-create-template-screen.webp) Now paste the following configuration into the editor in the "code" tab: ![./02-paste-config.webp](./02-paste-config.webp) :::note This template configures a production-like setup for Vendure, with the server and worker running in separate processes and a separate MinIO instance for asset storage. The resources configured here will cost around $20 per month. If you want to use the free plan, use the "Lite Template". :::
```json { "apiVersion": "v1.2", "spec": { "kind": "Workflow", "spec": { "type": "sequential", "steps": [ { "kind": "Project", "ref": "project", "spec": { "name": "Vendure", "region": "europe-west", "description": "Vendure is a modern, open-source composable commerce platform", "color": "#17b9ff" } }, { "kind": "Workflow", "spec": { "type": "parallel", "context": { "projectId": "${refs.project.id}" }, "steps": [ { "kind": "Addon", "ref": "database", "spec": { "name": "database", "type": "postgres", "version": "14-latest", "billing": { "deploymentPlan": "nf-compute-20", "storageClass": "ssd", "storage": 4096, "replicas": 1 }, "tlsEnabled": false, "externalAccessEnabled": false, "ipPolicies": [], "pitrEnabled": false } }, { "kind": "Addon", "ref": "storage", "spec": { "name": "minio", "type": "minio", "version": "latest", "billing": { "deploymentPlan": "nf-compute-20", "storageClass": "ssd", "storage": 4096, "replicas": 1 }, "tlsEnabled": true, "externalAccessEnabled": false, "ipPolicies": [], "pitrEnabled": false, "typeSpecificSettings": {}, "backupSchedules": [] } } ] } }, { "kind": "SecretGroup", "spec": { "projectId": "${refs.project.id}", "secretType": "environment-arguments", "priority": 10, "name": "secrets", "secrets": { "variables": { "APP_ENV": "production", "COOKIE_SECRET": "${fn.randomSecret(32)}", "SUPERADMIN_USERNAME": "superadmin", "SUPERADMIN_PASSWORD": "${fn.randomSecret(16)}", "DB_SCHEMA": "public" }, "files": {} }, "addonDependencies": [ { "addonId": "${refs.database.id}", "keys": [ { "keyName": "HOST", "aliases": [ "DB_HOST" ] }, { "keyName": "PORT", "aliases": [ "DB_PORT" ] }, { "keyName": "DATABASE", "aliases": [ "DB_NAME" ] }, { "keyName": "USERNAME", "aliases": [ "DB_USERNAME" ] }, { "keyName": "PASSWORD", "aliases": [ "DB_PASSWORD" ] } ] }, { "addonId": "${refs.storage.id}", "keys": [ { "keyName": "MINIO_ENDPOINT", "aliases": [ "MINIO_ENDPOINT" ] }, { "keyName": "ACCESS_KEY", "aliases": [ "MINIO_ACCESS_KEY" ] }, { "keyName": "SECRET_KEY", "aliases": [ "MINIO_SECRET_KEY" ] } ] } ], "restrictions": { "restricted": false, "nfObjects": [], "tags": [] } } }, { "kind": "BuildService", "ref": "builder", "spec": { "name": "builder", "projectId": "${refs.project.id}", "billing": { "deploymentPlan": "nf-compute-20" }, "vcsData": { "projectUrl": "https://github.com/vendure-ecommerce/one-click-deploy", "projectType": "github" }, "buildSettings": { "dockerfile": { "buildEngine": "kaniko", "dockerFilePath": "/Dockerfile", "dockerWorkDir": "/", "useCache": false } }, "disabledCI": false, "buildArguments": {} } }, { "kind": "Build", "spec": { "id": "${refs.builder.id}", "projectId": "${refs.project.id}", "type": "service", "branch": "master", "buildOverrides": { "buildArguments": {} }, "reuseExistingBuilds": true }, "condition": "success", "ref": "build" }, { "kind": "Workflow", "spec": { "type": "parallel", "context": { "projectId": "${refs.project.id}" }, "steps": [ { "kind": "DeploymentService", "spec": { "deployment": { "instances": 1, "storage": { "ephemeralStorage": { "storageSize": 1024 }, "shmSize": 64 }, "docker": { "configType": "customCommand", "customCommand": "node ./dist/index.js" }, "internal": { "id": "${refs.builder.id}", "branch": "master", "buildSHA": "latest" } }, "name": "server", "billing": { "deploymentPlan": "nf-compute-20" }, "ports": [ { "name": "app", "internalPort": 3000, "public": true, "protocol": "HTTP", "security": { "credentials": [], "policies": [] }, "domains": [], "disableNfDomain": false } ], "runtimeEnvironment": {}, "runtimeFiles": {} }, "ref": "server" }, { "kind": "DeploymentService", "spec": { "deployment": { "instances": 1, "storage": { "ephemeralStorage": { "storageSize": 1024 }, "shmSize": 64 }, "docker": { "configType": "customCommand", "customCommand": "node ./dist/index-worker.js" }, "internal": { "id": "${refs.builder.id}", "branch": "master", "buildSHA": "latest" } }, "name": "worker", "billing": { "deploymentPlan": "nf-compute-10" }, "ports": [], "runtimeEnvironment": {}, "runtimeFiles": {} }, "ref": "worker" } ] } } ] } } } ```
:::note This template runs the Vendure server & worker in a single process, and as such will fit within the resource limits of the Northflank free plan. Local disk storage is used for assets, which means that horizontal scaling is not possible. This setup is suitable for testing purposes, but is not recommended for production use. :::
```json { "apiVersion": "v1.2", "spec": { "kind": "Workflow", "spec": { "type": "sequential", "steps": [ { "kind": "Project", "ref": "project", "spec": { "name": "Vendure Lite", "region": "europe-west", "description": "Vendure is a modern, open-source composable commerce platform", "color": "#17b9ff" } }, { "kind": "Addon", "spec": { "name": "database", "type": "postgres", "version": "14-latest", "billing": { "deploymentPlan": "nf-compute-20", "storageClass": "ssd", "storage": 4096, "replicas": 1 }, "tlsEnabled": false, "externalAccessEnabled": false, "ipPolicies": [], "pitrEnabled": false } }, { "kind": "SecretGroup", "spec": { "projectId": "${refs.project.id}", "name": "secrets", "secretType": "environment-arguments", "priority": 10, "secrets": { "variables": { "APP_ENV": "production", "COOKIE_SECRET": "${fn.randomSecret(32)}", "SUPERADMIN_USERNAME": "superadmin", "SUPERADMIN_PASSWORD": "${fn.randomSecret(16)}", "DB_SCHEMA": "public", "ASSET_UPLOAD_DIR": "/data", "RUN_JOB_QUEUE_FROM_SERVER": "true" } }, "addonDependencies": [ { "addonId": "database", "keys": [ { "keyName": "HOST", "aliases": [ "DB_HOST" ] }, { "keyName": "PORT", "aliases": [ "DB_PORT" ] }, { "keyName": "DATABASE", "aliases": [ "DB_NAME" ] }, { "keyName": "USERNAME", "aliases": [ "DB_USERNAME" ] }, { "keyName": "PASSWORD", "aliases": [ "DB_PASSWORD" ] } ] } ], "restrictions": { "restricted": false, "nfObjects": [], "tags": [] } } }, { "kind": "BuildService", "spec": { "name": "builder", "projectId": "${refs.project.id}", "billing": { "deploymentPlan": "nf-compute-10" }, "vcsData": { "projectUrl": "https://github.com/vendure-ecommerce/one-click-deploy", "projectType": "github" }, "buildSettings": { "dockerfile": { "buildEngine": "kaniko", "dockerFilePath": "/Dockerfile", "dockerWorkDir": "/", "useCache": false } }, "disabledCI": false, "buildArguments": {} } }, { "kind": "Build", "ref": "build", "spec": { "id": "${refs.builder.id}", "projectId": "${refs.project.id}", "type": "service", "branch": "master", "reuseExistingBuilds": true }, "condition": "success" }, { "kind": "DeploymentService", "ref": "server", "spec": { "name": "server", "billing": { "deploymentPlan": "nf-compute-20" }, "deployment": { "instances": 1, "storage": { "ephemeralStorage": { "storageSize": 1024 }, "shmSize": 64 }, "docker": { "configType": "customCommand", "customCommand": "yarn start:server" }, "internal": { "id": "${refs.builder.id}", "branch": "master", "buildSHA": "latest" } }, "ports": [ { "name": "app", "internalPort": 3000, "public": true, "protocol": "HTTP", "security": { "credentials": [], "policies": [] }, "domains": [] } ], "runtimeEnvironment": {} } }, { "kind": "Volume", "spec": { "spec": { "storageSize": 5120, "accessMode": "ReadWriteOnce", "storageClassName": "ssd" }, "name": "storage", "mounts": [ { "containerMountPath": "/data", "volumeMountPath": "" } ], "attachedObjects": [ { "id": "${refs.server.id}", "type": "service" } ] } } ] } } } ```
Then click the "Create template" button. ## Run the template Next, click the "run template" button to start the deployment process. ![./03-run-template.webp](./03-run-template.webp) Once the template run has completed, you should be able to see the newly-created project in the project selector. ![./04-find-project.webp](./04-find-project.webp) ## Find the public URL Click the "Services" menu item in the left sidebar and then click the "Server" service. ![./05-server-service.webp](./05-server-service.webp) In the top right corner you'll see the public URL of your new Vendure server! Note that it may take a few minutes for the server to start up and populate all the test data because the free tier has limited CPU and memory resources. Once it is ready, you can navigate to the public URL and append `/admin` to the end of the URL to access the admin panel. ![./06-find-url.webp](./06-find-url.webp) :::note The superadmin password was generated for you by the template, and can be found in the "Secrets" section from the project nav bar as `SUPERADMIN_PASSWORD`. ::: Congratulations on deploying your Vendure server! ## Next steps Now that you have a basic Vendure server up and running, you can explore some of the other features offered by Northflank that you might need for a full production setup: - Configure [health checks](https://northflank.com/docs/v1/application/observe/configure-health-checks) to ensure any container crashes are rapidly detected and restarted. Also see the [Vendure health check docs](/guides/deployment/using-docker#healthreadiness-checks). - [Set up a Redis instance](https://northflank.com/docs/v1/application/databases-and-persistence/deploy-databases-on-northflank/deploy-redis-on-northflank) so that you can take advantage of our highly performant [BullMQJobQueuePlugin](/reference/core-plugins/job-queue-plugin/bull-mqjob-queue-plugin) and set up [Redis-based session caching](/reference/typescript-api/auth/session-cache-strategy/) to handle multi-instance deployments. - With the above in place, you can safely start to [scale your server instances](https://northflank.com/docs/v1/application/scale/scaling-replicas) to handle more traffic. - [Add a custom domain](https://northflank.com/docs/v1/application/domains/add-a-domain-to-your-account) using Northflank's powerful DNS management system. - Set up [infrastructure alerts](https://northflank.com/docs/v1/application/observe/set-infrastructure-alerts) to be notified when any of your containers crash or experience load spikes. --- --- title: "Deploying to Railway" --- ![Deploy to Railway](./deploy-to-railway.webp) [Railway](https://railway.app/) is a managed hosting platform which allows you to deploy and scale your Vendure server and infrastructure with ease. :::note This guide should be runnable on the Railway free trial plan, which means you can deploy it for free and thereafter pay only for the resources you use, which should be around $5 per month. ::: ## Prerequisites First of all you'll need to create a new Railway account (click "login" on the website and enter your email address) if you don't already have one. You'll also need a GitHub account and you'll need to have your Vendure project hosted there. In order to use the Railway trial plan, you'll need to connect your GitHub account to Railway via the Railway dashboard. :::info If you'd like to quickly get started with a ready-made Vendure project which includes sample data, you can clone our [Vendure one-click-deploy repo](https://github.com/vendure-ecommerce/one-click-deploy). ::: ## Configuration ### Port Railway defines the port via the `PORT` environment variable, so make sure your Vendure Config uses this variable: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { apiOptions: { // highlight-next-line port: +(process.env.PORT || 3000), // ... }, // ... }; ``` ### Database connection :::info The following is already pre-configured if you are using the one-click-deploy repo. ::: Make sure your DB connection options uses the following environment variables: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... dbConnectionOptions: { // ... database: process.env.DB_NAME, host: process.env.DB_HOST, port: +process.env.DB_PORT, username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, }, }; ``` ### Asset storage :::info The following is already pre-configured if you are using the one-click-deploy repo. ::: In this guide we will use the AssetServerPlugin's default local disk storage strategy. Make sure you use the `ASSET_UPLOAD_DIR` environment variable to set the path to the directory where the uploaded assets will be stored. ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { AssetServerPlugin } from '@vendure/asset-server-plugin'; export const config: VendureConfig = { // ... plugins: [ AssetServerPlugin.init({ route: 'assets', // highlight-next-line assetUploadDir: process.env.ASSET_UPLOAD_DIR || path.join(__dirname, '../static/assets'), }), ], // ... }; ``` ## Create a new Railway project From the Railway dashboard, click "New Project" and select "Empty Project". You'll be taken to a screen where you can add the first service to your project. ## Create the database Click the "Add a Service" button and select "database". Choose a database that matches the one you are using in your Vendure project. If you are following along using the one-click-deploy repo, then choose "Postgres". ## Create the Vendure server Click the "new" button to create a new service, and select "GitHub repo". Select the repository which contains your Vendure project. You may need to configure access to this repo if you haven't already done so. ![Create new service](./01-new-service.webp) ### Configure the server service You should then see a card representing this service in the main area of the dashboard. Click the card and go to the "settings" tab. * Scroll to the "Service" section and rename the service to "vendure-server". * Check the "Build" section and make sure the build settings make sense for your repo. If you are using the one-click-deploy repo, then it should detect the Dockerfile. * In the "Deploy" section, set the "Custom start command" to `node ./dist/index.js`. * Finally, scroll up to the "Networking" section and click "Generate domain" to set up a temporary domain for your Vendure server. ### Create a Volume In order to persist the uploaded product images, we need to create a volume. Click the "new" button and select "Volume". Attach it to the "vendure-server" service and set the mount path to `/vendure-assets`. ### Configure server env vars Click on the "vendure-server" service and go to the "Variables" tab. This is where we will set up the environment variables which are used in our Vendure Config. You can use the raw editor to add the following variables, making sure to replace the highlighted values with your own: ```sh DB_NAME=${{Postgres.PGDATABASE}} DB_USERNAME=${{Postgres.PGUSER}} DB_PASSWORD=${{Postgres.PGPASSWORD}} DB_HOST=${{Postgres.PGHOST}} DB_PORT=${{Postgres.PGPORT}} ASSET_UPLOAD_DIR=/vendure-assets // highlight-next-line COOKIE_SECRET= SUPERADMIN_USERNAME=superadmin // highlight-next-line SUPERADMIN_PASSWORD= ``` ![Setting env vars](./02-env-vars.webp) :::note The variables starting with `${{Postgres...}}` assume that your database service is called "Postgres". If you have named it differently, then you'll need to change these variables accordingly. ::: ## Create the Vendure worker Finally, we need to define the worker process which will run the background tasks. Click the "new" button and select "GitHub repo". Select again the repository which contains your Vendure project. ### Configure the worker service You should then see a card representing this service in the main area of the dashboard. Click the card and go to the "settings" tab. * Scroll to the "Service" section and rename the service to "vendure-worker". * Check the "Build" section and make sure the build settings make sense for your repo. If you are using the one-click-deploy repo, then it should detect the Dockerfile. * In the "Deploy" section, set the "Custom start command" to `node ./dist/index-worker.js`. ### Configure worker env vars The worker will need to know how to connect to the database, so add the following variables to the "Variables" tab: ```sh DB_NAME=${{Postgres.PGDATABASE}} DB_USERNAME=${{Postgres.PGUSER}} DB_PASSWORD=${{Postgres.PGPASSWORD}} DB_HOST=${{Postgres.PGHOST}} DB_PORT=${{Postgres.PGPORT}} ``` ## Test your Vendure server To test that everything is working, click the "vendure-server" card and then the link to the temporary domain. ![Test server](./03-test-server.webp) ## Next Steps This setup gives you a basic Vendure server to get started with. When moving to a more production-ready setup, you'll want to consider the following: - Use MinIO for asset storage. This is a more robust and scalable solution than the local disk storage used here. - [MinIO template for Railway](https://railway.app/template/SMKOEA), - [Configuring the AssetServerPlugin for MinIO](/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy/#usage-with-minio) - Use Redis to power the job queue and session cache. This is not only more performant, but will enable horizontal scaling of your server and worker instances. - [Railway Redis docs](https://docs.railway.app/guides/redis) - [Vendure horizontal scaling docs](/guides/deployment/horizontal-scaling) --- --- title: "Deploying to Render" --- ![Deploy to Render](./deploy-to-render.webp) [Render](https://render.com/) is a managed hosting platform which allows you to deploy and scale your Vendure server and infrastructure with ease. :::note The configuration in this guide will cost from around $12 per month to run. ::: ## Prerequisites First of all you'll need to [create a new Render account](https://dashboard.render.com/register) if you don't already have one. For this guide you'll need to have your Vendure project in a git repo on either GitHub or GitLab. :::info If you'd like to quickly get started with a ready-made Vendure project which includes sample data, you can use our [Vendure one-click-deploy repo](https://github.com/vendure-ecommerce/one-click-deploy), which means you won't have to set up your own git repo. ::: ## Configuration ### Port Render defines the port via the `PORT` environment variable and [defaults to `10000`](https://docs.render.com/web-services#host-and-port-configuration), so make sure your Vendure Config uses this variable: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { apiOptions: { // highlight-next-line port: +(process.env.PORT || 3000), // ... }, // ... }; ``` ### Database connection :::info The following is already pre-configured if you are using the one-click-deploy repo. ::: Make sure your DB connection options uses the following environment variables: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... dbConnectionOptions: { // ... database: process.env.DB_NAME, host: process.env.DB_HOST, port: +process.env.DB_PORT, username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, }, }; ``` ### Asset storage :::info The following is already pre-configured if you are using the one-click-deploy repo. ::: In this guide we will use the AssetServerPlugin's default local disk storage strategy. Make sure you use the `ASSET_UPLOAD_DIR` environment variable to set the path to the directory where the uploaded assets will be stored. ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { AssetServerPlugin } from '@vendure/asset-server-plugin'; export const config: VendureConfig = { // ... plugins: [ AssetServerPlugin.init({ route: 'assets', // highlight-next-line assetUploadDir: process.env.ASSET_UPLOAD_DIR || path.join(__dirname, '../static/assets'), }), ], // ... }; ``` ## Create a database From the Render dashboard, click the "New" button and select "PostgreSQL" from the list of services: ![Create a new PostgreSQL database](./01-create-db.webp) Give the database a name (e.g. "postgres"), select a region close to you, select an appropriate plan and click "Create Database". ## Create the Vendure server Click the "New" button again and select "Web Service" from the list of services. Choose the "Build and deploy from a Git repository" option. In the next step you will be prompted to connect to either GitHub or GitLab. Select the appropriate option and follow the instructions to connect your account and grant access to the repository containing your Vendure project. :::info If you are using the one-click-deploy repo, you should instead use the "Public Git repository" option and enter the URL of the repo: ``` https://github.com/vendure-ecommerce/one-click-deploy ``` ::: ### Configure the server service In the next step you will configure the server: - **Name**: "vendure-server" - **Region**: Select a region close to you - **Branch**: Select the branch you want to deploy, usually "main" or "master" - **Runtime**: If you have a Dockerfile then it should be auto-detected. If not you should select "Node" and enter the appropriate build and start commands. For a typical Vendure project these would be: - **Build Command**: `yarn; yarn build` or `npm install; npm run build` - **Start Command**: `node ./dist/index.js` - **Instance Type**: Select the appropriate instance type. Since we want to use a persistent volume to store our assets, we need to use at least the "Starter" instance type or higher. Click the "Advanced" button to expand the advanced options: - Click "Add Disk" to set up a persistent volume for the assets and use the following settings: - **Name**: "vendure-assets" - **Mount Path**: `/vendure-assets` - **Size**: As appropriate. For testing purposes you can use the smallest size (1GB) - **Health Check Path**: `/health` - **Docker Command**: `node ./dist/index.js` (if you are _not_ using a Dockerfile this option will not be available) Click "Create Web Service" to create the service. :::note If you have not already set up payment, you will be prompted to enter credit card details at this point. ::: ## Configure environment variables Next we need to set up the environment variables which will be used by both the server and worker. Click the "Env Groups" tab and then click the "New Environment Group" button. Name the group "vendure configuration" and add the following variables. The database variables can be found by navigating to the database service, clicking the "Info" tab and scrolling to the "Connections" section: ![Database connection settings](./03-db-connection.webp) ```shell DB_NAME= DB_USERNAME= DB_PASSWORD= DB_HOST= DB_PORT= ASSET_UPLOAD_DIR=/vendure-assets // highlight-next-line COOKIE_SECRET= SUPERADMIN_USERNAME=superadmin // highlight-next-line SUPERADMIN_PASSWORD= ``` Once the correct values have been entered, click "Create Environment Group". Next, click the "vendure-server" service and go to the "Environment" tab to link the environment group to the service: ![Link environment group](./04-link-env-group.webp) ## Create the Vendure worker Finally, we need to define the worker process which will run the background tasks. Click the "New" button and select "Background Worker". Select the same git repo as before, and in the next step configure the worker: - **Name**: "vendure-worker" - **Region**: Same as the server - **Branch**: Select the branch you want to deploy, usually "main" or "master" - **Runtime**: If you have a Dockerfile then it should be auto-detected. If not you should select "Node" and enter the appropriate build and start commands. For a typical Vendure project these would be: - **Build Command**: `yarn; yarn build` or `npm install; npm run build` - **Start Command**: `node ./dist/index-worker.js` - **Instance Type**: Select the appropriate instance type. The Starter size is fine to get started. Click the "Advanced" button to expand the advanced options: - **Docker Command**: `node ./dist/index-worker.js` (if you are _not_ using a Dockerfile this option will not be available) Click "Create Background Worker" to create the worker. Finally, click the "Environment" tab and link the "vendure configuration" environment group to the worker. ## Test your Vendure server Navigate back to the dashboard, click the "vendure-server" service, and you should see a link to the temporary domain: ![Test server](./05-server-url.webp) Click the link and append `/admin` to the URL to open the Admin UI. Log in with the username and password you set in the environment variables. ## Next Steps This setup gives you a basic Vendure server to get started with. When moving to a more production-ready setup, you'll want to consider the following: - Use MinIO for asset storage. This is a more robust and scalable solution than the local disk storage used here. - [Deploying MinIO to Render](https://docs.render.com/deploy-minio), - [Configuring the AssetServerPlugin for MinIO](/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy/#usage-with-minio) - Use Redis to power the job queue and session cache. This is not only more performant, but will enable horizontal scaling of your server and worker instances. - [Render Redis docs](https://docs.render.com/redis#creating-a-redis-instance) - [Vendure horizontal scaling docs](/guides/deployment/horizontal-scaling) --- --- title: "Deploying the Admin UI" showtoc: true --- ## Compiling the Admin UI If you have customized the Admin UI with extensions, you should compile your custom Admin UI app ahead of time before deploying it. This will bundle the app into a set of static files which are then served by the AdminUiPlugin. - [Guide: Compiling the Admin UI as a deployment step](/guides/extending-the-admin-ui/getting-started/#compiling-as-a-deployment-step). :::warning It is not recommended to compile the Admin UI on the server at runtime, as this can be slow and resource-intensive. Instead, compile the Admin UI ahead of time and deploy the compiled assets, as covered in the guide linked above. ::: ## Setting the API host & port When running in development mode, the Admin UI app will "guess" the API host and port based on the current URL in the browser. Typically, this will be `http://localhost:3000`. For production deployments where the Admin UI app is served from a different host or port than the Vendure server, you'll need to configure the Admin UI app to point to the correct API host and port. ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ port: 3001, route: 'admin', adminUiConfig: { apiHost: 'https://api.example.com', apiPort: 443, }, }), ], }; ``` ## Deploying a stand-alone Admin UI Usually, the Admin UI is served from the Vendure server via the AdminUiPlugin. However, you may wish to deploy the Admin UI app elsewhere. Since it is just a static Angular app, it can be deployed to any static hosting service such as Vercel or Netlify. #### Metrics The AdminUiPlugin not only serves the Admin UI app, but also provides a `metricSummary` query which is used to display the order metrics on the dashboard. If you wish to deploy the Admin UI app stand-alone (not served by the AdminUiPlugin), but still want to display the metrics on the dashboard, you'll need to include the AdminUiPlugin in your server's plugins array, but do not call `init()`: ```ts title="src/vendure-config.ts" import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; const config: VendureConfig = { plugins: [ AdminUiPlugin, // <== include the plugin, but don't call init() ], // ... }; ``` #### Example Script Here's an example script that can be run as part of your host's `build` command, which will generate a stand-alone app bundle and configure it to point to your remote server API. This example is for Vercel, and assumes: * A `BASE_HREF` environment variable to be set to `/` * A public (output) directory set to `build/dist` * A build command set to `npm run build` or `yarn build` * A package.json like this: ```json title="package.json" { "name": "standalone-admin-ui", "version": "0.1.0", "private": true, "scripts": { "build": "ts-node compile.ts" }, "devDependencies": { "@vendure/ui-devkit": "^1.4.5", "ts-node": "^10.2.1", "typescript": "~4.3.5" } } ``` ```ts title="compile.ts" import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; import { DEFAULT_BASE_HREF } from '@vendure/ui-devkit/compiler/constants'; import path from 'path'; import { promises as fs } from 'fs'; /** * Compiles the Admin UI. If the BASE_HREF is defined, use that. * Otherwise, go back to the default admin route. */ compileUiExtensions({ outputPath: path.join(__dirname, 'build'), baseHref: process.env.BASE_HREF ?? DEFAULT_BASE_HREF, extensions: [ /* any UI extensions would go here, or leave empty */ ], }) .compile?.() .then(() => { // If building for Vercel deployment, replace the config to make // api calls to api.example.com instead of localhost. if (process.env.VERCEL) { console.log('Overwriting the vendure-ui-config.json for Vercel deployment.'); return fs.writeFile( path.join(__dirname, 'build', 'dist', 'vendure-ui-config.json'), JSON.stringify({ apiHost: 'https://api.example.com', apiPort: '443', adminApiPath: 'admin-api', tokenMethod: 'cookie', defaultLanguage: 'en', availableLanguages: ['en', 'de'], hideVendureBranding: false, hideVersion: false, }), ); } }) .then(() => { process.exit(0); }); ``` --- --- title: "Getting data into production" showtoc: true weight: 4 --- # Getting data into production Once you have set up your production deployment, you'll need some way to get your products and other data into the system. The main tasks will be: 1. Creation of the database schema 2. Importing initial data like roles, tax rates, countries etc. 3. Importing catalog data like products, variants, options, facets 4. Importing other data used by your application ## Creating the database schema The first item - creation of the schema - can be automatically handled by TypeORM's `synchronize` feature. Switching it on for the initial run will automatically create the schema. This can be done by using an environment variable: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... dbConnectionOptions: { type: 'postgres', // highlight-next-line synchronize: process.env.DB_SYNCHRONIZE, host: process.env.DB_HOST, port: process.env.DB_PORT, username: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, }, // ... }; ``` Set the `DB_SYNCHRONIZE` variable to `true` on first start, and then after the schema is created, set it to `false`. ## Importing initial & catalog data Importing initial and catalog data can be handled by Vendure `populate()` helper function - see the [Importing Product Data guide](/guides/developer-guide/importing-data/). ## Importing other data Any kinds of data not covered by the `populate()` function can be imported using a custom script, which can use any Vendure service or service defined by your custom plugins to populate data in any way you like. See the [Stand-alone scripts guide](/guides/developer-guide/stand-alone-scripts/). --- --- title: "Horizontal scaling" showtoc: true weight: 2 --- # Horizontal scaling "Horizontal scaling" refers to increasing the performance capacity of your application by running multiple instances. This type of scaling has two main advantages: 1. It can enable increased throughput (requests/second) by distributing the incoming requests between multiple instances. 2. It can increase resilience because if a single instance fails, the other instances will still be able to service requests. As discussed in the [Server resource requirements guide](/guides/deployment/server-resource-requirements), horizontal scaling can be the most cost-effective way of deploying your Vendure server due to the single-threaded nature of Node.js. In Vendure, both the server and the worker can be scaled horizontally. Scaling the server will increase the throughput of the GraphQL APIs, whereas scaling the worker can increase the speed with which the job queue is processed by allowing more jobs to be run in parallel. ## Multi-instance configuration In order to run Vendure in a multi-instance configuration, there are some important configuration changes you'll need to make. The key consideration in configuring Vendure for this scenario is to ensure that any persistent state is managed externally from the Node process, and is shared by all instances. Namely: * The JobQueue should be stored externally using the [DefaultJobQueuePlugin](/reference/typescript-api/job-queue/default-job-queue-plugin/) (which stores jobs in the database) or the [BullMQJobQueuePlugin](/reference/core-plugins/job-queue-plugin/bull-mqjob-queue-plugin) (which stores jobs in Redis), or some other custom JobQueueStrategy. **Note:** the BullMQJobQueuePlugin is much more efficient than the DefaultJobQueuePlugin, and is recommended for production applications. * An appropriate [CacheStrategy](/reference/typescript-api/cache/cache-strategy/) must be used which stores the cache externally. Both the [DefaultCachePlugin](/reference/typescript-api/cache/default-cache-plugin/) and the [RedisCachePlugin](/reference/typescript-api/cache/redis-cache-plugin/) are suitable for multi-instance setups. The DefaultCachePlugin uses the database to store the cache data, which is simple and effective, while the RedisCachePlugin uses a Redis server to store the cache data and can have better performance characteristics. * If you are on a version prior to v3.1, a custom [SessionCacheStrategy](/reference/typescript-api/auth/session-cache-strategy/) must be used which stores the session cache externally (such as in the database or Redis), since the default strategy stores the cache in-memory and will cause inconsistencies in multi-instance setups. [Example Redis-based SessionCacheStrategy](/reference/typescript-api/auth/session-cache-strategy/). From v3.1 the session cache is handled by the underlying cache strategy, so you normally don't need to define a custom SessionCacheStrategy. * When using cookies to manage sessions, make sure all instances are using the _same_ cookie secret: ```ts title="src/vendure-config.ts" const config: VendureConfig = { authOptions: { cookieOptions: { secret: 'some-secret' } } } ``` * Channel and Zone data gets cached in-memory as this data is used in virtually every request. The cache time-to-live defaults to 30 seconds, which is probably fine for most cases, but it can be configured in the [EntityOptions](/reference/typescript-api/configuration/entity-options/#channelcachettl). ## Using Docker or Kubernetes One way of implementing horizontal scaling is to use Docker to wrap your Vendure server & worker in a container, which can then be run as multiple instances. Some hosting providers allow you to provide a Docker image and will then run multiple instances of that image. Kubernetes can also be used to manage multiple instances of a Docker image. For a more complete guide, see the [Using Docker guide](/guides/deployment/using-docker). ## Using PM2 [PM2](https://pm2.keymetrics.io/) is a process manager which will spawn multiple instances of your server or worker, as well as re-starting any instances that crash. PM2 can be used on VPS hosts to manage multiple instances of Vendure without needing Docker or Kubernetes. PM2 must be installed on your server: ```sh npm install pm2@latest -g ``` Your processes can then be run in [cluster mode](https://pm2.keymetrics.io/docs/usage/cluster-mode/) with the following command: ```sh pm2 start ./dist/index.js -i 4 ``` The above command will start a cluster of 4 instances. You can also instruct PM2 to use the maximum number of available CPUs with `-i max`. Note that if you are using pm2 inside a Docker container, you should use the `pm2-runtime` command: ```dockerfile # ... your existing Dockerfile config RUN npm install pm2 -g CMD ["pm2-runtime", "app.js", "-i", "max"] ``` --- --- title: 'Production configuration' showtoc: true weight: 0 --- # Production configuration This is a guide to the recommended configuration for a production Vendure application. ## Environment variables Keep sensitive information or context-dependent settings in environment variables. In local development you can store the values in a `.env` file. For production, you should use the mechanism provided by your hosting platform to set the values for production. The default `@vendure/create` project scaffold makes use of environment variables already. For example: ```ts const IS_DEV = process.env.APP_ENV === 'dev'; ``` The `APP_ENV` environment variable can then be set using the admin dashboard of your hosting provider: ![A typical UI for setting env vars](./env-var-ui.webp) If you are using [Docker or Kubernetes](/guides/deployment/using-docker), they include their own methods of setting environment variables. ## Superadmin credentials Ensure you set the superadmin credentials to something other than the default of `superadmin:superadmin`. Use your hosting platform's environment variables to set a **strong** password for the Superadmin account. ```ts import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { authOptions: { tokenMethod: ['bearer', 'cookie'], superadminCredentials: { identifier: process.env.SUPERADMIN_USERNAME, password: process.env.SUPERADMIN_PASSWORD, }, }, // ... }; ``` ## API hardening It is recommended that you install and configure the [HardenPlugin](/reference/core-plugins/harden-plugin/) for all production deployments. This plugin locks down your schema (disabling introspection and field suggestions) and protects your Shop API against malicious queries that could otherwise overwhelm your server. Install the plugin: ```sh npm install @vendure/harden-plugin # or yarn add @vendure/harden-plugin ``` Then add it to your VendureConfig: ```ts import { VendureConfig } from '@vendure/core'; import { HardenPlugin } from '@vendure/harden-plugin'; const IS_DEV = process.env.APP_ENV === 'dev'; export const config: VendureConfig = { // ... plugins: [ HardenPlugin.init({ maxQueryComplexity: 500, apiMode: IS_DEV ? 'dev' : 'prod', }), // ... ] }; ``` :::info For a detailed explanation of how to best configure this plugin, see the [HardenPlugin docs](/reference/core-plugins/harden-plugin/). ::: ## ID Strategy By default, Vendure uses auto-increment integer IDs as entity primary keys. While easier to work with in development, sequential primary keys can leak information such as the number of orders or customers in the system. For this reason you should consider using the UuidIdStrategy for production. ```ts title="src/vendure-config.ts" import { UuidIdStrategy, VendureConfig } from '@vendure/core'; export const config: VendureConfig = { entityOptions: { entityIdStrategy: new UuidIdStrategy(), }, // ... } ``` Another option, if you wish to stick with integer IDs, is to create a custom [EntityIdStrategy](/reference/typescript-api/configuration/entity-id-strategy/) which uses the `encodeId()` and `decodeId()` methods to obfuscate the sequential nature of the ID. ## Database Timezone Vendure internally treats all dates & times as UTC. However, you may sometimes run into issues where dates are offset by some fixed amount of hours. E.g. you place an order at 17:00, but it shows up in the Admin UI as being placed at 19:00. Typically, this is caused by the timezone of your database not being set to UTC. You can check the timezone in **MySQL/MariaDB** by executing: ```SQL SELECT TIMEDIFF(NOW(), UTC_TIMESTAMP); ``` and you should expect to see `00:00:00`. In **Postgres**, you can execute: ```SQL show timezone; ``` and you should expect to see `UTC` or `Etc/UTC`. ## Trust proxy When deploying your Vendure application behind a reverse proxy (usually the case with most hosting services), consider configuring Express's `trust proxy` setting. This allows you to retrieve the original IP address from the `X-Forwarded-For` header, which proxies use to forward the client's IP address. You can set the `trustProxy` option in your `VendureConfig`: ```ts import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { apiOptions: { trustProxy: 1, // Trust the first proxy in front of your app }, }; ``` For more details on configuring `trust proxy`, refer to the [Express documentation](https://expressjs.com/en/guide/behind-proxies.html). ## Security Considerations Please read over the [Security](/guides/developer-guide/security) section of the Developer Guide for more information on how to secure your Vendure application. --- --- title: 'Server resource requirements' showtoc: true weight: 1 --- ## Server resource requirements ### RAM The Vendure server and worker process each use around 200-300MB of RAM when idle. This figure will increase under load. The total RAM required by a single instance of the server depends on your project size (the number of products, variants, customers, orders etc.) as well as expected load (the number of concurrent users you expect). As a rule, 512MB per process would be a practical minimum for a smaller project with low expected load. ### CPU CPU resources are generally measured in "cores" or "vCPUs" (virtual CPUs) depending on the type of hosting. The exact relationship between vCPUs and physical CPU cores is out of the scope of this guide, but for our purposes we will use "CPU" to refer to both physical and virtual CPU resources. Because Node.js is single-threaded, a single instance of the Vendure server or worker will not be able to take advantage of multiple CPUs. For example, if you set up a server instance running with 4 CPUs, the server will only use 1 of those CPUs and the other 3 will be wasted. Therefore, when looking to optimize performance (for example, the number of requests that can be serviced per second), it makes sense to scale horizontally by running multiple instances of the Vendure server. See the [Horizontal Scaling guide](/guides/deployment/horizontal-scaling). ## Load testing It is important to test whether your current server configuration will be able to handle the loads you expect when you go into production. There are numerous tools out there to help you load test your application, such as: - [k6](https://k6.io/) - [Artillery](https://www.artillery.io/) - [jMeter](https://jmeter.apache.org/) --- --- title: "Using docker" showtoc: true weight: 3 --- # Using Docker [Docker](https://docs.docker.com/) is a technology which allows you to run your Vendure application inside a [container](https://docs.docker.com/get-started/#what-is-a-container). The default installation with `@vendure/create` includes a sample Dockerfile: ```dockerfile title="Dockerfile" FROM node:22 WORKDIR /usr/src/app COPY package.json ./ COPY package-lock.json ./ RUN npm install --production COPY . . RUN npm run build ``` This Dockerfile can then be built into an "image" using: ```sh docker build -t vendure . ``` This same image can be used to run both the Vendure server and the worker: ```sh # Run the server docker run -dp 3000:3000 --name vendure-server vendure npm run start:server # Run the worker docker run -dp 3000:3000 --name vendure-worker vendure npm run start:worker ``` Here is a breakdown of the command used above: - `docker run` - run the image we created with `docker build` - `-dp 3000:3000` - the `-d` flag means to run in "detached" mode, so it runs in the background and does not take control of your terminal. `-p 3000:3000` means to expose port 3000 of the container (which is what Vendure listens on by default) as port 3000 on your host machine. - `--name vendure-server` - we give the container a human-readable name. - `vendure` - we are referencing the tag we set up during the build. - `npm run start:server` - this last part is the actual command that should be run inside the container. ## Docker Compose Managing multiple docker containers can be made easier using [Docker Compose](https://docs.docker.com/compose/). In the below example, we use the same Dockerfile defined above, and we also define a Postgres database to connect to: ```yaml version: "3" services: server: build: context: . dockerfile: Dockerfile ports: - 3000:3000 command: ["npm", "run", "start:server"] volumes: - /usr/src/app environment: DB_HOST: database DB_PORT: 5432 DB_NAME: vendure DB_USERNAME: postgres DB_PASSWORD: password worker: build: context: . dockerfile: Dockerfile command: ["npm", "run", "start:worker"] volumes: - /usr/src/app environment: DB_HOST: database DB_PORT: 5432 DB_NAME: vendure DB_USERNAME: postgres DB_PASSWORD: password database: image: postgres volumes: - /var/lib/postgresql/data ports: - 5432:5432 environment: POSTGRES_PASSWORD: password POSTGRES_DB: vendure ``` ## Kubernetes [Kubernetes](https://kubernetes.io/) is used to manage multiple containerized applications. This deployment starts the shop container we created above as both worker and server. ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: vendure-shop spec: selector: matchLabels: app: vendure-shop replicas: 1 template: metadata: labels: app: vendure-shop spec: containers: - name: server image: vendure-shop:latest command: - node args: - "dist/index.js" env: # your env config here ports: - containerPort: 3000 - name: worker image: vendure-shop:latest imagePullPolicy: Always command: - node args: - "dist/index-worker.js" env: # your env config here ports: - containerPort: 3000 ``` ## Health/Readiness Checks If you wish to deploy with Kubernetes or some similar system, you can make use of the health check endpoints. ### Server This is a regular REST route (note: _not_ GraphQL), available at `/health`. ```text REQUEST: GET http://localhost:3000/health ``` ```json { "status": "ok", "info": { "database": { "status": "up" } }, "error": {}, "details": { "database": { "status": "up" } } } ``` Health checks are built on the [Nestjs Terminus module](https://docs.nestjs.com/recipes/terminus). You can also add your own health checks by creating plugins that make use of the [HealthCheckRegistryService](/reference/typescript-api/health-check/health-check-registry-service/). ### Worker Although the worker is not designed as an HTTP server, it contains a minimal HTTP server specifically to support HTTP health checks. To enable this, you need to call the `startHealthCheckServer()` method after bootstrapping the worker: ```ts bootstrapWorker(config) .then(worker => worker.startJobQueue()) .then(worker => worker.startHealthCheckServer({ port: 3020 })) .catch(err => { console.log(err); }); ``` This will make the `/health` endpoint available. When the worker instance is running, it will return the following: ```text REQUEST: GET http://localhost:3020/health ``` ```json { "status": "ok" } ``` --- --- title: "Cache" --- Caching is a technique to improve performance of a system by saving the results of expensive operations and reusing them when the same operation is requested again. Vendure uses caching in a number of places to improve performance, and the same caching mechanism is available for use in custom plugins. ## Setting up the cache In order to take advantage of Vendure distributed caching, you need to enable a cache plugin. :::note If no cache plugin is specified, Vendure uses an in-memory cache which is not shared between instances. This is suitable for development, but not recommended for production use. ::: ### DefaultCachePlugin The [DefaultCachePlugin](/reference/typescript-api/cache/default-cache-plugin) uses the database to store the cache data. This is a simple and effective cache strategy which has the advantage of not requiring any additional infrastructure. ```ts title="vendure-config.ts" import { DefaultCachePlugin, VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... plugins: [ DefaultCachePlugin.init({ // optional maximum number of items to // store in the cache. Defaults to 10,000 cacheSize: 20_000, }), ], }; ``` After enabling the `DefaultCachePlugin`, you will need to [generate a migration](/guides/developer-guide/migrations/) to add the necessary tables to the database. ### RedisCachePlugin Vendure also provides a [RedisCachePlugin](/reference/typescript-api/cache/redis-cache-plugin) which uses a Redis server to store the cache data and can have better performance characteristics. ```ts title="vendure-config.ts" import { RedisCachePlugin, VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... plugins: [ RedisCachePlugin.init({ redisOptions: { host: 'localhost', port: 6379, }, }), ], }; ``` ## CacheService The [`CacheService`](/reference/typescript-api/cache/cache-service) is the general-purpose API for interacting with the cache. It provides methods for setting, getting and deleting cache entries. ![CacheService](./cache-service.webp) Internally, the `CacheService` uses a [CacheStrategy](/reference/typescript-api/cache/cache-strategy) to store the data. The cache strategy is responsible for the actual storage and retrieval of the data. The `CacheService` provides a consistent API which can be used regardless of the underlying cache strategy. :::info From Vendure v3.1, new projects are created with the [DefaultCachePlugin](/reference/typescript-api/cache/default-cache-plugin) enabled by default. This plugin uses the database to store the cache data. This is a simple and effective cache strategy which is suitable for most use-cases. For more advanced use-cases, you can use the [RedisCachePlugin](/reference/typescript-api/cache/redis-cache-plugin) which uses a Redis server to store the cache data and can have better performance characteristics. ::: ### Multi-instance use It is common to run Vendure in a multi-instance setup, where multiple instances of the server and worker are running in parallel. The `CacheService` is designed to work in this environment. Both the [DefaultCachePlugin](/reference/typescript-api/cache/default-cache-plugin) and the [RedisCachePlugin](/reference/typescript-api/cache/redis-cache-plugin) use a single shared cache across all instances. This means that if one instance sets a cache entry, it will be available to all other instances. Likewise, if one instance deletes a cache entry, it will be deleted for all other instances. ### Usage The `CacheService` can be injected into any service, resolver, strategy or configurable operation. ```ts import { Injectable } from '@nestjs/common'; import { CacheService } from '@vendure/core'; @Injectable() export class MyService { constructor(private cacheService: CacheService) {} async myMethod() { const cacheKey = 'MyService.myMethod'; const cachedValue = await this.cacheService.get(cacheKey); if (cachedValue) { return cachedValue; } const newValue = await this.expensiveOperation(); // Cache the result for 1 minute (60 * 1000 milliseconds) await this.cacheService.set(cacheKey, newValue, { ttl: 60 * 1000 }); return newValue; } private async expensiveOperation() { // Do something expensive } } ``` :::info The data stored in the cache must be serializable. This means you cannot store instances of classes, functions, or other non-serializable data types. ::: ### Cache key naming When setting a cache entry, it is important to choose a unique key which will not conflict with other cache entries. The key should be namespaced to avoid conflicts. For example, you can use the name of the class & method as part of the key. If there is an identifier which is unique to the operation, that can be used as well. ```ts getVariantIds(productId: ID): Promise { const cacheKey = `ProductService.getVariantIds:${productId}`; const cachedValue = await this.cacheService.get(cacheKey); if (cachedValue) { return cachedValue; } const newValue = await this.expensiveOperation(productId); await this.cacheService.set(cacheKey, newValue, { ttl: 60 * 1000 }); return newValue; } ``` ### Cache eviction The cache is not infinite, and entries will be evicted after a certain time. The time-to-live (TTL) of a cache entry can be set when calling `set()`. If no TTL is set, the cache entry will remain in the cache indefinitely. Cache entries can also be manually deleted using the `delete()` method: ```ts await this.cacheService.delete(cacheKey); ``` ### Cache tags When setting a cache entry, you can also specify a list of tags. This allows you to invalidate all cache entries which share a tag. For example, if you have a cache entry which is related to a Product, you can tag it with the Product's ID. When the Product is updated, you can invalidate all cache entries which are tagged with that Product ID. ```ts const cacheKey = `ProductService.getVariantIds:${productId}`; await this.cacheService.set(cacheKey, newValue, { tags: [`Product:${productId}`] }); // later await this.cacheService.invalidateTags([`Product:${productId}`]); ``` ### createCache Helper The `createCache` helper function can be used to create a [Cache](/reference/typescript-api/cache) instance which is a convenience wrapper around the `CacheService` APIs: ```ts import { Injectable } from '@nestjs/common'; import { CacheService, ID, EventBus, ProductEvent,RequestContext } from '@vendure/core'; @Injectable() export class FacetValueChecker { // Create a Cache instance with a 1-day TTL private facetValueCache = this.cacheService.createCache({ getKey: (productId: ID) => `FacetValueChecker.${productId}`, options: { ttl: 1000 * 60 * 60 * 24 }, }); constructor(private cacheService: CacheService, private eventBus: EventBus) { this.eventBus.ofType(ProductEvent).subscribe(event => { if (event.type !== 'created') { // Invalidate the cache entry when a Product is updated or deleted this.facetValueCache.delete(event.entity.id); } }); } async getFacetValueIdsForProduct(ctx: RequestContext, productId: ID): Promise { return this.facetValueCache.get(productId, () => // This function will only be called if the cache entry does not exist // or has expired. It will set the result in the cache automatically. this.calculateFacetValueIdsForProduct(ctx, productId)); } async calculateFacetValueIdsForProduct(ctx: RequestContext, productId: ID): Promise { // Do something expensive } } ``` ## RequestContextCache The [RequestContextCacheService](/reference/typescript-api/cache/request-context-cache-service) is a specialized cache service which is scoped to the current request. This is useful when you want to cache data for the duration of a single request, but not across multiple requests. This can be especially useful in resolvers, where you may want to cache the result of a specific resolved field which may be requested multiple times within the same request. For example, in Vendure core, when dealing with product lists, there's a particular very hot code path that is used to calculate the correct prices to return for each product. As part of this calculation, we need to know the active tax zone, which can be expensive to calculate newly for each product. We use the `RequestContextCacheService` to cache the active tax zone for the duration of the request. ```ts const activeTaxZone = await this.requestContextCache.get( ctx, 'activeTaxZone', () => taxZoneStrategy .determineTaxZone(ctx, zones, ctx.channel, order), ); ``` Internally, the `RequestContextCacheService` makes used of the WeakMap data structure which means the cached data will be automatically garbage-collected when the request is finished. It is also able to store any kind of data, not just serializable data. ## Session Cache There is an additional cache which is specifically used to cache session data, since this data is commonly accessed on almost all requests. Since v3.1, the default is to use the [DefaultSessionCacheStrategy](/reference/typescript-api/auth/default-session-cache-strategy) which internally just uses whatever the current `CacheStrategy` is to store the data. This means that in most cases you don't need to worry about the session cache, but if you have specific requirements, you can create a custom session cache strategy and set it via the `authOptions.sessionCacheStrategy` config property. ## SelfRefreshingCache The [SelfRefreshingCache](/reference/typescript-api/cache/self-refreshing-cache) is a specialized in-memory cache which automatically refreshes itself if the value is found to be stale. This is useful to cache a single frequently-accessed value, that don't change often. It is created using the [createSelfRefreshingCache](/reference/typescript-api/cache/self-refreshing-cache#createselfrefreshingcache) function, which takes a configuration object that specifies the name of the cache, the time-to-live (TTL) for the cache entries, and a refresh function that will be called to update the value when it is stale. ```ts title="SelfRefreshingCache Example" import { Channel, createSelfRefreshingCache, EventBus, InitializerEvent, InternalServerError, Logger, RequestContext SelfRefreshingCache, TransactionalConnection, } from '@vendure/core'; @Injectable() export class PublicChannelService { // highlight-start private publicChannel: SelfRefreshingCache; // highlight-end private readonly logCtx = 'PublicChannelService'; constructor( private connection: TransactionalConnection, private eventBus: EventBus, ) { this.eventBus.ofType(InitializerEvent).subscribe(async () => { // highlight-start this.publicChannel = await createSelfRefreshingCache({ name: 'PublicChannelService.publicChannel', ttl: 1000 * 60 * 5, // 5min refresh: { fn: ctx => this.findPublicChannel(ctx), defaultArgs: [RequestContext.empty()] }, }); // highlight-end }); } async getPublicChannel(): Promise { // highlight-start const publicChannel = await this.publicChannel.value(); // highlight-end if (!publicChannel) { throw new InternalServerError(`error.public-channel-not-found`); } return publicChannel; } private async findPublicChannel(ctx: RequestContext): Promise { const publicChannel = await this.connection.getRepository(ctx, Channel).findOne({ where: { code: DEFAULT_PUBLIC_CHANNEL_CODE }, relations: ['defaultShippingZone', 'defaultTaxZone'], }); if (!publicChannel) { Logger.error('Could not find public channel!', this.logCtx); throw new InternalServerError(`error.public-channel-not-found`); } return publicChannel; } } ``` --- --- title: "Implementing ChannelAware" showtoc: true --- ## Defining channel-aware entities Making an entity channel-aware means that it can be associated with a specific [Channel](/reference/typescript-api/entities/channel). This is useful when you want to have different data or features for different channels. First you will have to create an entity ([Define a database entity](/guides/developer-guide/database-entity/)) that implements the `ChannelAware` interface. This interface requires the entity to provide a `channels` property ```ts title="src/plugins/requests/entities/product-request.entity.ts" import { DeepPartial } from '@vendure/common/lib/shared-types'; import { VendureEntity, Product, EntityId, ID, ChannelAware } from '@vendure/core'; import { Column, Entity, ManyToOne } from 'typeorm'; @Entity() class ProductRequest extends VendureEntity implements ChannelAware { constructor(input?: DeepPartial) { super(input); } @ManyToOne(type => Product) product: Product; @EntityId() productId: ID; @Column() text: string; // highlight-start @ManyToMany(() => Channel) @JoinTable() channels: Channel[]; // highlight-end } ``` ## Creating channel-aware entities Creating a channel-aware entity is similar to creating a regular entity. The only difference is that you need to assign the entity to the current channel. This can be done by using the `ChannelService` which provides the `assignToCurrentChannel` helper function. :::info The `assignToCurrentChannel` function will only assign the `channels` property of the entity. You will still need to save the entity to the database. ::: ```ts title="src/plugins/requests/service/product-request.service.ts" import { ChannelService } from '@vendure/core'; export class RequestService { constructor(private channelService: ChannelService) {} async create(ctx: RequestContext, input: CreateRequestInput): Promise { const request = new ProductRequest(input); // Now we need to assign the request to the current channel (+ default channel) // highlight-next-line await this.channelService.assignToCurrentChannel(input, ctx); return await this.connection.getRepository(ProductRequest).save(request); } } ``` For [Translatable entities](/guides/developer-guide/translations/), the best place to assign the channels is inside the `beforeSave` input of the [TranslateableSave](/reference/typescript-api/service-helpers/translatable-saver/) helper class. ## Querying channel-aware entities When querying channel-aware entities, you can use the [ListQueryBuilder](/reference/typescript-api/data-access/list-query-builder/#extendedlistqueryoptions) or the [TransactionalConnection](/reference/typescript-api/data-access/transactional-connection/#findoneinchannel) to automatically filter entities based on the provided channel id. ```ts title="src/plugins/requests/service/product-request.service.ts" import { ChannelService, ListQueryBuilder, TransactionalConnection } from '@vendure/core'; export class RequestService { constructor( private connection: TransactionalConnection, private listQueryBuilder: ListQueryBuilder, private channelService: ChannelService) {} findOne(ctx: RequestContext, requestId: ID, relations?: RelationPaths) { // highlight-start return this.connection.findOneInChannel(ctx, ProductRequest, requestId, ctx.channelId, { relations: unique(effectiveRelations) }); // highlight-end } findAll( ctx: RequestContext, options?: ProductRequestListOptions, relations?: RelationPaths, ): Promise> { return this.listQueryBuilder .build(ProductRequest, options, { ctx, relations, // highlight-next-line channelId: ctx.channelId, }) .getManyAndCount() .then(([items, totalItems]) => { return { items, totalItems, }; }); } } ``` --- --- title: "CLI" --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; The Vendure CLI is a command-line tool for boosting your productivity as a developer by automating common tasks such as creating new plugins, entities, API extensions and more. It is much more than just a scaffolding tool - it is able to analyze your project and intelligently modify your existing codebase to integrate new functionality. ## Installation :::info The Vendure CLI comes installed with a new Vendure project by default from v2.2.0+ ::: To manually install the CLI, run: ```bash npm install -D @vendure/cli ``` ```bash yarn add -D @vendure/cli ``` ## Interactive vs Non-Interactive Mode The Vendure CLI supports both **interactive** and **non-interactive** modes: - **Interactive mode**: Provides guided prompts and menus for easy use during development - **Non-interactive mode**: Allows direct command execution with arguments and options, perfect for automation, CI/CD, and AI agents ## The Add Command The `add` command is used to add new entities, resolvers, services, plugins, and more to your Vendure project. ### Interactive Mode From your project's **root directory**, run: ```bash npx vendure add ``` ```bash yarn vendure add ``` ![Add command](./add-command.webp) The CLI will guide you through the process of adding new functionality to your project. The `add` command is much more than a simple file generator. It is able to analyze your project source code to deeply understand and correctly update your project files. ### Non-Interactive Mode For automation or when you know exactly what you need to add, you can use the non-interactive mode with specific arguments and options: ```bash # Create a new plugin npx vendure add -p MyPlugin # Add an entity to a plugin npx vendure add -e MyEntity --selected-plugin MyPlugin # Add an entity with features npx vendure add -e MyEntity --selected-plugin MyPlugin --custom-fields --translatable # Add a service to a plugin npx vendure add -s MyService --selected-plugin MyPlugin # Add a service with specific type npx vendure add -s MyService --selected-plugin MyPlugin --type entity # Add job queue support to a plugin npx vendure add -j MyPlugin --name my-job --selected-service MyService # Add GraphQL codegen to a plugin npx vendure add -c MyPlugin # Add API extension to a plugin npx vendure add -a MyPlugin --queryName getCustomData --mutationName updateCustomData # Add UI extensions to a plugin npx vendure add -u MyPlugin # Use custom config file npx vendure add -p MyPlugin --config ./custom-vendure.config.ts ``` ```bash # Create a new plugin yarn vendure add -p MyPlugin # Add an entity to a plugin yarn vendure add -e MyEntity --selected-plugin MyPlugin # Add an entity with features yarn vendure add -e MyEntity --selected-plugin MyPlugin --custom-fields --translatable # Add a service to a plugin yarn vendure add -s MyService --selected-plugin MyPlugin # Add a service with specific type yarn vendure add -s MyService --selected-plugin MyPlugin --type entity # Add job queue support to a plugin yarn vendure add -j MyPlugin --name my-job --selected-service MyService # Add GraphQL codegen to a plugin yarn vendure add -c MyPlugin # Add API extension to a plugin yarn vendure add -a MyPlugin --queryName getCustomData --mutationName updateCustomData # Add UI extensions to a plugin yarn vendure add -u MyPlugin # Use custom config file yarn vendure add -p MyPlugin --config ./custom-vendure.config.ts ``` #### Add Command Options | Flag | Long Form | Description | Example | |------|-----------|-------------|---------| | `-p` | `--plugin ` | Create a new plugin | `vendure add -p MyPlugin` | | `-e` | `--entity ` | Add a new entity to a plugin | `vendure add -e MyEntity --selected-plugin MyPlugin` | | `-s` | `--service ` | Add a new service to a plugin | `vendure add -s MyService --selected-plugin MyPlugin` | | `-j` | `--job-queue [plugin]` | Add job queue support | `vendure add -j MyPlugin --name job-name --selected-service ServiceName` | | `-c` | `--codegen [plugin]` | Add GraphQL codegen configuration | `vendure add -c MyPlugin` | | `-a` | `--api-extension [plugin]` | Add API extension scaffold | `vendure add -a MyPlugin --queryName getName --mutationName setName` | | `-u` | `--ui-extensions [plugin]` | Add UI extensions setup | `vendure add -u MyPlugin` | | | `--config ` | Specify custom Vendure config file | `--config ./custom-config.ts` | #### Sub-options for specific commands **Entity (`-e`) additional options:** - `--selected-plugin `: Name of the plugin to add the entity to (required) - `--custom-fields`: Add custom fields support to the entity - `--translatable`: Make the entity translatable **Service (`-s`) additional options:** - `--selected-plugin `: Name of the plugin to add the service to (required) - `--type `: Type of service: basic or entity (default: basic) **Job Queue (`-j`) additional options:** - `--name `: Name for the job queue (required) - `--selected-service `: Service to add the job queue to (required) **API Extension (`-a`) additional options: (requires either)** - `--queryName `: Name for the GraphQL query - `--mutationName `: Name for the GraphQL mutation :::info **Validation**: Entity and service commands validate that the specified plugin exists in your project. If the plugin is not found, the command will list all available plugins in the error message. Both commands require the `--selected-plugin` parameter when running in non-interactive mode. ::: ## The Migrate Command The `migrate` command is used to generate and manage [database migrations](/guides/developer-guide/migrations) for your Vendure project. ### Interactive Mode From your project's **root directory**, run: ```bash npx vendure migrate ``` ```bash yarn vendure migrate ``` ![Migrate command](./migrate-command.webp) ### Non-Interactive Mode For migration operations, use specific arguments and options: ```bash # Generate a new migration npx vendure migrate -g my-migration-name # Run pending migrations npx vendure migrate -r # Revert the last migration npx vendure migrate --revert # Generate migration with custom output directory npx vendure migrate -g my-migration -o ./custom/migrations ``` ```bash # Generate a new migration yarn vendure migrate -g my-migration-name # Run pending migrations yarn vendure migrate -r # Revert the last migration yarn vendure migrate --revert # Generate migration with custom output directory yarn vendure migrate -g my-migration -o ./custom/migrations ``` #### Migrate Command Options | Flag | Long Form | Description | Example | |------|-----------|-------------|---------| | `-g` | `--generate ` | Generate a new migration | `vendure migrate -g add-user-table` | | `-r` | `--run` | Run all pending migrations | `vendure migrate -r` | | | `--revert` | Revert the last migration | `vendure migrate --revert` | | `-o` | `--output-dir ` | Custom output directory for migrations | `vendure migrate -g my-migration -o ./migrations` | ## Getting Help To see all available commands and options: ```bash npx vendure --help npx vendure add --help npx vendure migrate --help ``` --- --- title: "Configuration" sidebar_position: 3 --- # Configuration Every aspect of the Vendure server is configured via a single, central [`VendureConfig`](/reference/typescript-api/configuration/vendure-config/) object. This object is passed into the [`bootstrap`](/reference/typescript-api/common/bootstrap/) and [`bootstrapWorker`](/reference/typescript-api/worker/bootstrap-worker/) functions to start up the Vendure server and worker respectively. The `VendureConfig` object is organised into sections, grouping related settings together. For example, [`VendureConfig.apiOptions`](/reference/typescript-api/configuration/api-options/) contains all the config for the GraphQL APIs, whereas [`VendureConfig.authOptions`](/reference/typescript-api/auth/auth-options/) deals with authentication. ## Important Configuration Settings In this guide, we will take a look at those configuration options needed for getting the server up and running. :::tip A description of every available configuration option can be found in the [`VendureConfig` reference docs](/reference/typescript-api/configuration/vendure-config/). ::: ### Specifying API hostname & port etc The [`VendureConfig.apiOptions`](/reference/typescript-api/configuration/api-options/) object is used to set the hostname, port, as well as other API-related concerns. Express middleware and Apollo Server plugins may also be specified here. Example: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { apiOptions: { hostname: 'localhost', port: 3000, adminApiPath: '/admin', shopApiPath: '/shop', middleware: [{ // add some Express middleware to the Shop API route handler: timeout('5s'), route: 'shop', }] }, // ... } ``` ### Connecting to the database The database connection is configured with the `VendureConfig.dbConnectionOptions` object. This object is actually the [TypeORM DataSourceOptions object](https://typeorm.io/data-source-options) and is passed directly to TypeORM. Example: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { dbConnectionOptions: { type: 'postgres', host: process.env.DB_HOST, port: process.env.DB_PORT, synchronize: false, username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: 'vendure', migrations: [path.join(__dirname, 'migrations/*.ts')], }, // ... } ``` ### Configuring authentication Authentication settings are configured with [`VendureConfig.authOptions`](/reference/typescript-api/auth/auth-options/). The most important setting here is whether the storefront client will use cookies or bearer tokens to manage user sessions. For more detail on this topic, see [the Managing Sessions guide](/guides/storefront/connect-api/#managing-sessions). The username and default password of the superadmin user can also be specified here. In production, it is advisable to use environment variables for these settings (see the following section on usage of environment variables). Example: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { authOptions: { tokenMethod: 'cookie', requireVerification: true, cookieOptions: { secret: process.env.COOKIE_SESSION_SECRET, }, superadminCredentials: { identifier: process.env.SUPERADMIN_USERNAME, password: process.env.SUPERADMIN_PASSWORD, }, }, // ... } ``` ## Working with the VendureConfig object Since the `VendureConfig` is just a JavaScript object, it can be managed and manipulated according to your needs. For example: ### Using environment variables Environment variables can be used when you don't want to hard-code certain values which may change, e.g. depending on whether running locally, in staging or in production: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { apiOptions: { hostname: process.env.HOSTNAME, port: process.env.PORT, } // ... }; ``` They are also useful so that sensitive credentials do not need to be hard-coded and committed to source control: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { dbConnectionOptions: { type: 'postgres', username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: 'vendure', }, // ... } ``` When you create a Vendure project with `@vendure/create`, it comes with the [dotenv](https://www.npmjs.com/package/dotenv) package installed, which allows you to store environment variables in a `.env` file in the root of your project. To define new environment variables, you can add them to the `.env` file. For instance, if you are using a plugin that requires an API key, you can ```txt title=".env" APP_ENV=dev COOKIE_SECRET=toh8soqdlj SUPERADMIN_USERNAME=superadmin SUPERADMIN_PASSWORD=superadmin // highlight-next-line MY_API_KEY=12345 ``` In order to tell TypeScript about the existence of this new variable, you can add it to the `src/environment.d.ts` file: ```ts title="src/environment.d.ts" export {}; // Here we declare the members of the process.env object, so that we // can use them in our application code in a type-safe manner. declare global { namespace NodeJS { interface ProcessEnv { APP_ENV: string; COOKIE_SECRET: string; SUPERADMIN_USERNAME: string; SUPERADMIN_PASSWORD: string; // highlight-next-line MY_API_KEY: string; } } } ```` You can then use the environment variable in your config file: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { plugins: [ MyPlugin.init({ apiKey: process.env.MY_API_KEY, }), ], // ... } ``` :::info In production, the way you manage environment variables will depend on your hosting provider. Read more about this in our [Production Configuration guide](/guides/deployment/production-configuration/). ::: ### Splitting config across files If the config object grows too large, you can split it across several files. For example, the `plugins` array in a real-world project can easily grow quite big: ```ts title="src/vendure-config-plugins.ts" import { AssetServerPlugin, DefaultJobQueuePlugin, VendureConfig } from '@vendure/core'; import { ElasticsearchPlugin } from '@vendure/elasticsearch-plugin'; import { EmailPlugin } from '@vendure/email-plugin'; import { CustomPlugin } from './plugins/custom-plugin'; export const plugins: VendureConfig['plugins'] = [ CustomPlugin, AssetServerPlugin.init({ route: 'assets', assetUploadDir: path.join(__dirname, 'assets'), port: 5002, }), DefaultJobQueuePlugin, ElasticsearchPlugin.init({ host: 'localhost', port: 9200, }), EmailPlugin.init({ // ...lots of lines of config }), ]; ``` ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { plugins } from './vendure-config-plugins'; export const config: VendureConfig = { plugins, // ... } ``` --- --- title: 'Custom Fields' sidebar_position: 3 --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import CustomFieldProperty from '@site/src/components/CustomFieldProperty'; Custom fields allow you to add your own custom data properties to almost every Vendure entity. The entities which may have custom fields defined are listed in the [CustomFields interface documentation](/reference/typescript-api/custom-fields/). Some use-cases for custom fields include: * Storing the weight, dimensions or other product-specific data on the `ProductVariant` entity. * Storing additional product codes on the `ProductVariant` entity such as ISBN or GTIN. * Adding a `downloadable` flag to the `Product` entity to indicate whether the product is a digital download. * Storing an external identifier (e.g. from a payment provider) on the `Customer` entity. * Adding a longitude and latitude to the `StockLocation` for use in selecting the closest location to a customer. :::note Custom fields are not solely restricted to Vendure's native entities though, it's also possible to add support for custom fields to your own custom entities. See: [Supporting custom fields](/guides/developer-guide/database-entity/#supporting-custom-fields) ::: ## Defining custom fields Custom fields are specified in the VendureConfig: ```ts const config = { // ... customFields: { Product: [ { name: 'infoUrl', type: 'string' }, { name: 'downloadable', type: 'boolean' }, { name: 'shortName', type: 'localeString' }, ], User: [ { name: 'socialLoginToken', type: 'string', unique: true }, ], }, }; ``` With the example config above, the following will occur: 1. The database schema will be altered, and a column will be added for each custom field. **Note: changes to custom fields require a database migration**. See the [Migrations guide](/guides/developer-guide/migrations/). 2. The GraphQL APIs will be modified to add the custom fields to the `Product` and `User` types respectively. 3. If you are using the [AdminUiPlugin](/reference/core-plugins/admin-ui-plugin/), the Admin UI detail pages will now contain form inputs to allow the custom field data to be added or edited, and the list view data tables will allow custom field columns to be added, sorted and filtered. ![custom-fields-data-table.webp](custom-fields-data-table.webp) The values of the custom fields can then be set and queried via the GraphQL APIs: ```graphql mutation { updateProduct(input: { id: 1 // highlight-start customFields: { infoUrl: "https://some-url.com", downloadable: true, } // highlight-end translations: [ // highlight-next-line { languageCode: en, customFields: { shortName: "foo" } } ] }) { id name // highlight-start customFields { infoUrl downloadable shortName } // highlight-end } } ``` ```json { "data": { "product": { "id": "1", "name": "Laptop", "customFields": { "infoUrl": "https://some-url.com", "downloadable": true, "shortName": "foo" } } } } ``` The custom fields will also extend the filter and sort options available to the `products` list query: ```graphql query { products(options: { // highlight-start filter: { infoUrl: { contains: "new" }, downloadable: { eq: true } }, sort: { infoUrl: ASC } // highlight-end }) { items { id name // highlight-start customFields { infoUrl downloadable shortName } // highlight-end } } } ``` ## Available custom field types The following types are available for custom fields: | Type | Description | Example | |----------------|------------------------------|----------------------------------------------------------| | `string` | Short string data | url, label | | `localeString` | Localized short strings | localized url | | `text` | Long text data | extended product info, json config object | | `localeText` | Localized long text | localized extended product info | | `int` | Integer | product weight, customer loyalty points, monetary values | | `float` | Floating point number | product review rating | | `boolean` | Boolean | isDownloadable flag on product | | `datetime` | A datetime | date that variant is back in stock | | `struct` | Structured json-like data | Key-value attributes with additional data for products | | `relation` | A relation to another entity | Asset used as a customer avatar, related Products | To see the underlying DB data type and GraphQL type used for each, see the [CustomFieldType doc](/reference/typescript-api/custom-fields/custom-field-type). #### Relations It is possible to set up custom fields that hold references to other entities using the `'relation'` type: ```ts const config = { // ... customFields: { Customer: [ { name: 'avatar', // highlight-start type: 'relation', entity: Asset, // highlight-end }, ], }, }; ``` In this example, we set up a many-to-one relationship from Customer to Asset, allowing us to specify an avatar image for each Customer. Relation custom fields are unique in that the input and output names are not the same - the input will expect an ID and will be named `'Id'` or `'Ids'` for list types. ```graphql mutation { updateCustomer(input: { id: 1 customFields: { avatarId: 42, } }) { id customFields { avatar { id name preview } } } } ``` ## Accessing custom fields in TypeScript As well as exposing custom fields via the GraphQL APIs, you can also access them directly in your TypeScript code. This is useful for plugins which need to access custom field data. Given the following custom field configuration: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; const config: VendureConfig = { // ... customFields: { Customer: [ { name: 'externalId', type: 'string' }, { name: 'avatar', type: 'relation', entity: Asset }, ], }, }; ``` the `externalId` will be available whenever you access a `Customer` entity: ```ts const customer = await this.connection.getRepository(ctx, Customer).findOne({ where: { id: 1 }, }); console.log(customer.externalId); ``` The `avatar` relation will require an explicit join to be performed in order to access the data, since it is not eagerly loaded by default: ```ts const customer = await this.connection.getRepository(ctx, Customer).findOne({ where: { id: 1 }, relations: { customFields: { avatar: true, } } }); console.log(customer.avatar); ``` or if using the QueryBuilder API: ```ts const customer = await this.connection.getRepository(ctx, Customer).createQueryBuilder('customer') .leftJoinAndSelect('customer.customFields.avatar', 'avatar') .where('customer.id = :id', { id: 1 }) .getOne(); console.log(customer.avatar); ``` or using the EntityHydrator: ```ts const customer = await this.customerService.findOne(ctx, 1); await this.entityHydrator.hydrate(ctx, customer, { relations: ['customFields.avatar'] }); console.log(customer.avatar); ``` ## Custom field config properties ### Common properties All custom fields share some common properties: - [`name`](#name) - [`type`](#type) - [`list`](#list) - [`label`](#label) - [`description`](#description) - [`public`](#public) - [`readonly`](#readonly) - [`internal`](#internal) - [`defaultValue`](#defaultvalue) - [`nullable`](#nullable) - [`unique`](#unique) - [`validate`](#validate) - [`requiresPermission`](#requirespermission) - [`deprecated`](#deprecated) #### name The name of the field. This is used as the column name in the database, and as the GraphQL field name. The name should not contain spaces and by convention should be camelCased. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { Product: [ { // highlight-next-line name: 'infoUrl', type: 'string' }, ] } }; ``` #### type The type of data that will be stored in the field. #### list If set to `true`, then the field will be an array of the specified type. Defaults to `false`. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { Product: [ { name: 'infoUrls', type: 'string', // highlight-next-line list: true, }, ] } }; ``` Setting a custom field to be a list has the following effects: * The GraphQL type will be an array of the specified type. * The Admin UI will display a list of inputs for the field. * For lists of primitive types (anything except `relation`), the database type will be set to `simple-json` which serializes the data into a JSON string. For lists of `relation` types, a separate many-to-many table will be created. #### label An array of localized labels for the field. These are used in the Admin UI to label the field. ```ts title="src/vendure-config.ts" import { LanguageCode } from '@vendure/core'; const config = { // ... customFields: { Product: [ { name: 'infoUrl', type: 'string', // highlight-start label: [ { languageCode: LanguageCode.en, value: 'Info URL' }, { languageCode: LanguageCode.de, value: 'Info-URL' }, { languageCode: LanguageCode.es, value: 'URL de información' }, ], // highlight-end }, ] } }; ``` #### description An array of localized descriptions for the field. These are used in the Admin UI to describe the field. ```ts title="src/vendure-config.ts" import { LanguageCode } from '@vendure/core'; const config = { // ... customFields: { Product: [ { name: 'infoUrl', type: 'string', // highlight-start description: [ { languageCode: LanguageCode.en, value: 'A URL to more information about the product' }, { languageCode: LanguageCode.de, value: 'Eine URL zu weiteren Informationen über das Produkt' }, { languageCode: LanguageCode.es, value: 'Una URL con más información sobre el producto' }, ], // highlight-end }, ] } }; ``` #### public Whether the custom field is available via the Shop API. Defaults to `true`. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { Product: [ { name: 'profitMargin', type: 'int', // highlight-next-line public: false, }, ] } }; ``` #### readonly Whether the custom field can be updated via the GraphQL APIs. Defaults to `false`. If set to `true`, then the field can only be updated via direct manipulation via TypeScript code in a plugin. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { Product: [ { name: 'profitMargin', type: 'int', // highlight-next-line readonly: true, }, ] } }; ``` #### internal Whether the custom field is exposed at all via the GraphQL APIs. Defaults to `false`. If set to `true`, then the field will not be available via the GraphQL API, but can still be used in TypeScript code in a plugin. Internal fields are useful for storing data which is not intended to be exposed to the outside world, but which can be used in plugin logic. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { OrderLine: [ { name: 'referralId', type: 'string', // highlight-next-line internal: true, }, ] } }; ``` #### defaultValue The default value when an Entity is created with this field. If not provided, then the default value will be `null`. Note that if you set `nullable: false`, then you should also provide a `defaultValue` to avoid database errors when creating new entities. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { Product: [ { name: 'reviewRating', type: 'float', // highlight-next-line defaultValue: 0, }, ] } }; ``` #### nullable Whether the field is nullable in the database. If set to `false`, then a `defaultValue` should be provided. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { Product: [ { name: 'reviewRating', type: 'float', // highlight-start nullable: false, defaultValue: 0, // highlight-end }, ] } }; ``` #### unique Whether the value of the field should be unique. When set to `true`, a UNIQUE constraint is added to the column. Defaults to `false`. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { Customer: [ { name: 'externalId', type: 'string', // highlight-next-line unique: true, }, ] } }; ``` #### validate A custom validation function. If the value is valid, then the function should not return a value. If a string or LocalizedString array is returned, this is interpreted as an error message. Note that string, number and date fields also have some built-in validation options such as `min`, `max`, `pattern` which you can read about in the following sections. ```ts title="src/vendure-config.ts" import { LanguageCode } from '@vendure/core'; const config = { // ... customFields: { Product: [ { name: 'infoUrl', type: 'string', // highlight-start validate: (value: any) => { if (!value.startsWith('http')) { // If a localized error message is not required, a simple string can be returned. // return 'The URL must start with "http"'; // If a localized error message is required, return an array of LocalizedString objects. return [ { languageCode: LanguageCode.en, value: 'The URL must start with "http"' }, { languageCode: LanguageCode.de, value: 'Die URL muss mit "http" beginnen' }, { languageCode: LanguageCode.es, value: 'La URL debe comenzar con "http"' }, ]; } }, // highlight-end }, ] } }; ``` This function can even be asynchronous and may use the [Injector](/reference/typescript-api/common/injector/) to access providers. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { ProductVariant: [ { name: 'partCode', type: 'string', // highlight-start validate: async (value, injector, ctx) => { const partCodeService = injector.get(PartCodeService); const isValid = await partCodeService.validateCode(value); if (!isValid) { return `Part code ${value} is not valid`; } }, // highlight-end }, ] } }; ``` #### requiresPermission Since v2.2.0, you can restrict access to custom field data by specifying a permission or permissions which are required to read and update the field. For instance, you might want to add a particular custom field to the `Product` entity, but you do not want all administrators to be able to view or update the field. In the Admin UI, the custom field will not be displayed if the current administrator lacks the required permission. In the GraphQL API, if the current user does not have the required permission, then the field will always return `null`. Attempting to set the value of a field for which the user does not have the required permission will cause the mutation to fail with an error. ```ts title="src/vendure-config.ts" import { Permission } from '@vendure/core'; const config = { // ... customFields: { Product: [ { name: 'internalNotes', type: 'text', // highlight-start requiresPermission: Permission.SuperAdmin, // highlight-end }, { name: 'shippingType', type: 'string', // highlight-start // You can also use an array of permissions, // and the user must have at least one of the permissions // to access the field. requiresPermission: [ Permission.SuperAdmin, Permission.ReadShippingMethod, ], // highlight-end }, ] } }; ``` :::note The `requiresPermission` property only affects the _Admin API_. Access to a custom field via the _Shop API_ is controlled by the `public` property. If you need special logic to control access to a custom field in the Shop API, you can set `public: false` and then implement a custom [field resolver](/guides/developer-guide/extend-graphql-api/#add-fields-to-existing-types) which contains the necessary logic, and returns the entity's custom field value if the current customer meets the requirements. ::: #### deprecated Marks the custom field as deprecated in the GraphQL schema. When set to `true`, the field will be marked with the `@deprecated` directive. When set to a string, that string will be used as the deprecation reason. This is useful for API evolution - you can mark fields as deprecated to signal to API consumers that they should migrate to newer alternatives, while still maintaining backward compatibility. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { Product: [ { name: 'oldField', type: 'string', // highlight-next-line deprecated: true, }, { name: 'legacyUrl', type: 'string', // highlight-next-line deprecated: 'Use the new infoUrl field instead', }, ] } }; ``` When querying the GraphQL schema, deprecated fields will be marked accordingly: ```graphql type ProductCustomFields { oldField: String @deprecated legacyUrl: String @deprecated(reason: "Use the new infoUrl field instead") infoUrl: String } ``` ### Properties for `string` fields In addition to the common properties, the `string` custom fields have some type-specific properties: - [`pattern`](#pattern) - [`options`](#options) - [`length`](#length) #### pattern A regex pattern which the field value must match. If the value does not match the pattern, then the validation will fail. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { ProductVariant: [ { name: 'gtin', type: 'string', // highlight-next-line pattern: '^\d{8}(?:\d{4,6})?$', }, ] } }; ``` #### options An array of pre-defined options for the field. This is useful for fields which should only have a limited set of values. The `value` property is the value which will be stored in the database, and the `label` property is an optional array of localized strings which will be displayed in the admin UI. ```ts title="src/vendure-config.ts" import { LanguageCode } from '@vendure/core'; const config = { // ... customFields: { ProductVariant: [ { name: 'condition', type: 'string', // highlight-start options: [ { value: 'new', label: [{ languageCode: LanguageCode.en, value: 'New' }] }, { value: 'used', label: [{ languageCode: LanguageCode.en, value: 'Used' }] }, ], // highlight-end }, ] } }; ``` Attempting to set the value of the field to a value which is not in the `options` array will cause the validation to fail. #### length The max length of the varchar created in the database. Defaults to 255. Maximum is 65,535. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { ProductVariant: [ { name: 'partCode', type: 'string', // highlight-next-line length: 20, }, ] } }; ``` ### Properties for `localeString` fields In addition to the common properties, the `localeString` custom fields have some type-specific properties: - [`pattern`](#pattern-1) - [`length`](#length-1) #### pattern Same as the `pattern` property for `string` fields. #### length Same as the `length` property for `string` fields. ### Properties for `int` & `float` fields In addition to the common properties, the `int` & `float` custom fields have some type-specific properties: - [`min`](#min) - [`max`](#max) - [`step`](#step) #### min The minimum permitted value. If the value is less than this, then the validation will fail. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { ProductVariant: [ { name: 'reviewRating', type: 'int', // highlight-next-line min: 0, }, ] } }; ``` #### max The maximum permitted value. If the value is greater than this, then the validation will fail. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { ProductVariant: [ { name: 'reviewRating', type: 'int', // highlight-next-line max: 5, }, ] } }; ``` #### step The step value. This is used in the Admin UI to determine the increment/decrement value of the input field. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { ProductVariant: [ { name: 'reviewRating', type: 'int', // highlight-next-line step: 0.5, }, ] } }; ``` ### Properties for `datetime` fields In addition to the common properties, the `datetime` custom fields have some type-specific properties. The min, max & step properties for datetime fields are intended to be used as described in [the MDN datetime-local docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes) - [`min`](#min-1) - [`max`](#max-1) - [`step`](#step-1) #### min The earliest permitted date. If the value is earlier than this, then the validation will fail. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { ProductVariant: [ { name: 'releaseDate', type: 'datetime', // highlight-next-line min: '2019-01-01T00:00:00.000Z', }, ] } }; ``` #### max The latest permitted date. If the value is later than this, then the validation will fail. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { ProductVariant: [ { name: 'releaseDate', type: 'datetime', // highlight-next-line max: '2019-12-31T23:59:59.999Z', }, ] } }; ``` #### step The step value. See [the MDN datetime-local docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#step) to understand how this is used. ### Properties for `struct` fields :::info The `struct` custom field type is available from Vendure v3.1.0. ::: In addition to the common properties, the `struct` custom fields have some type-specific properties: - [`fields`](#fields) #### fields A `struct` is a data structure comprising a set of named fields, each with its own type. The `fields` property is an array of `StructFieldConfig` objects, each of which defines a field within the struct. ```ts title="src/vendure-config.ts" const config = { // ... customFields: { Product: [ { name: 'dimensions', type: 'struct', // highlight-start fields: [ { name: 'length', type: 'int' }, { name: 'width', type: 'int' }, { name: 'height', type: 'int' }, ], // highlight-end }, ] } }; ``` When querying the `Product` entity, the `dimensions` field will be an object with the fields `length`, `width` and `height`: ```graphql query { product(id: 1) { customFields { dimensions { length width height } } } } ``` Struct fields support many of the same properties as other custom fields, such as `list`, `label`, `description`, `validate`, `ui` and type-specific properties such as `options` and `pattern` for string types. :::note The following properties are **not** supported for `struct` fields: `public`, `readonly`, `internal`, `defaultValue`, `nullable`, `unique`, `requiresPermission`. ::: ```ts title="src/vendure-config.ts" import { LanguageCode } from '@vendure/core'; const config = { // ... customFields: { OrderLine: [ { name: 'customizationOptions', type: 'struct', fields: [ { name: 'color', type: 'string', // highlight-start options: [ { value: 'red', label: [{ languageCode: LanguageCode.en, value: 'Red' }] }, { value: 'blue', label: [{ languageCode: LanguageCode.en, value: 'Blue' }] }, ], // highlight-end }, { name: 'engraving', type: 'string', // highlight-start validate: (value: any) => { if (value.length > 20) { return 'Engraving text must be 20 characters or fewer'; } }, }, { name: 'notifyEmailAddresses', type: 'string', // highlight-start list: true, // highlight-end } ], }, ] } }; ``` ### Properties for `relation` fields In addition to the common properties, the `relation` custom fields have some type-specific properties: - [`entity`](#entity) - [`eager`](#eager) - [`graphQLType`](#graphqltype) - [`inverseSide`](#inverseside) #### entity The entity which this custom field is referencing. This can be one of the built-in entities, or a custom entity. If the entity is a custom entity, it must extend the `VendureEntity` class. ```ts title="src/vendure-config.ts" import { Product } from '\@vendure/core'; const config = { // ... customFields: { Product: [ { name: 'relatedProducts', list: true, // highlight-next-line type: 'relation', // highlight-next-line entity: Product, }, ] } }; ``` #### eager Whether to [eagerly load](https://typeorm.io/#/eager-and-lazy-relations) the relation. Defaults to false. Note that eager loading has performance implications, so should only be used when necessary. ```ts title="src/vendure-config.ts" import { Product } from '\@vendure/core'; const config = { // ... customFields: { Product: [ { name: 'relatedProducts', list: true, type: 'relation', entity: Product, // highlight-next-line eager: true, }, ] } }; ``` #### graphQLType The name of the GraphQL type that corresponds to the entity. Can be omitted if the GraphQL type name is the same as the entity name, which is the case for all of the built-in entities. ```ts title="src/vendure-config.ts" import { CmsArticle } from './entities/cms-article.entity'; const config = { // ... customFields: { Product: [ { name: 'blogPosts', list: true, type: 'relation', entity: CmsArticle, // highlight-next-line graphQLType: 'BlogPost', }, ] } }; ``` In the above example, the `CmsArticle` entity is being used as a related entity. However, the GraphQL type name is `BlogPost`, so we must specify this in the `graphQLType` property, otherwise Vendure will try to extend the GraphQL schema with reference to a non-existent "CmsArticle" type. #### inverseSide Allows you to specify the [inverse side of the relation](https://typeorm.io/#inverse-side-of-the-relationship). Let's say you are adding a relation from `Product` to a custom entity which refers back to the product. You can specify this inverse relation like so: ```ts title="src/vendure-config.ts" import { Product } from '\@vendure/core'; import { ProductReview } from './entities/product-review.entity'; const config = { // ... customFields: { Product: [ { name: 'reviews', list: true, type: 'relation', entity: ProductReview, // highlight-start inverseSide: (review: ProductReview) => review.product, // highlight-end }, ] } }; ``` This then allows you to query the `ProductReview` entity and include the `product` relation: ```ts const { productReviews } = await this.connection.getRepository(ProductReview).findOne({ where: { id: 1 }, relations: ['product'], }); ``` ## Custom Field UI In the Admin UI, an appropriate default form input component is used for each custom field type. The Admin UI comes with a set of ready-made form input components, but it is also possible to create custom form input components. The ready-made components are: - `text-form-input`: A single-line text input - `password-form-input`: A single-line password input - `select-form-input`: A select input - `textarea-form-input`: A multi-line textarea input - `rich-text-form-input`: A rich text editor input that saves the content as HTML - `json-editor-form-input`: A simple JSON editor input - `html-editor-form-input`: A simple HTML text editor input - `number-form-input`: A number input - `currency-form-input`: A number input with currency formatting - `boolean-form-input`: A checkbox input - `date-form-input`: A date input - `relation-form-input`: A generic entity relation input which allows an ID to be manually specified - `customer-group-form-input`: A select input for selecting a CustomerGroup - `facet-value-form-input`: A select input for selecting a FacetValue - `product-selector-form-input`: A select input for selecting a Product from an autocomplete list - `product-multi-form-input`: A modal dialog for selecting multiple Products or ProductVariants #### Default form inputs This table shows the default form input component used for each custom field type: | Type | Form input component | |--------------------------|----------------------------------------------------------------------------------------------| | `string`, `localeString` | `text-form-input` or, if options are defined, `select-form-input` | | `text`, `localeText` | `textarea-form-input` | | `int`, `float` | `number-form-input` | | `boolean` | `boolean-form-input` | | `datetime` | `date-form-input` | | `relation` | Depends on related entity, defaults to `relation-form-input` if no specific component exists | :::info **UI for relation type** The Admin UI app has built-in selection components for "relation" custom fields that reference certain common entity types, such as Asset, Product, ProductVariant and Customer. If you are relating to an entity not covered by the built-in selection components, you will see a generic relation component which allows you to manually enter the ID of the entity you wish to select. If the generic selector is not suitable, or is you wish to replace one of the built-in selector components, you can create a UI extension that defines a custom field control for that custom field. You can read more about this in the [custom form input guide](/guides/extending-the-admin-ui/custom-form-inputs/) ::: ### Specifying the input component The defaults listed above can be overridden by using the `ui` property of the custom field config object. For example, if we want a number to be displayed as a currency input: ```ts title="src/vendure-config.ts" const config = { // ... customFields: { ProductVariant: [ { name: 'rrp', type: 'int', // highlight-next-line ui: { component: 'currency-form-input' }, }, ] } } ``` Here's an example config demonstrating several ways to customize the UI controls for custom fields: ```ts import { LanguageCode, VendureConfig } from '@vendure/core'; const config: VendureConfig = { // ... customFields: { Product: [ // Rich text editor { name: 'additionalInfo', type: 'text', ui: { component: 'rich-text-form-input' } }, // JSON editor { name: 'specs', type: 'text', ui: { component: 'json-editor-form-input' } }, // Numeric with suffix { name: 'weight', type: 'int', ui: { component: 'number-form-input', suffix: 'g' }, }, // Currency input { name: 'RRP', type: 'int', ui: { component: 'currency-form-input' }, }, // Select with options { name: 'pageType', type: 'string', ui: { component: 'select-form-input', options: [ { value: 'static', label: [{ languageCode: LanguageCode.en, value: 'Static' }] }, { value: 'dynamic', label: [{ languageCode: LanguageCode.en, value: 'Dynamic' }] }, ], }, }, // Text with prefix { name: 'link', type: 'string', ui: { component: 'text-form-input', prefix: 'https://', }, }, ], }, }; ``` and the resulting UI: ![custom-fields-ui.webp](custom-fields-ui.webp) :::info The various configuration options for each of the built-in form input (e.g. `suffix`) is documented in the [`DefaultFormConfigHash` object](/reference/typescript-api/configurable-operation-def/default-form-config-hash/). ::: ### Custom form input components If none of the built-in form input components are suitable, you can create your own. This is a more advanced topic which is covered in detail in the [Custom Form Input Components](/guides/extending-the-admin-ui/custom-form-inputs/) guide. ## Tabbed custom fields With a large, complex project, it's common for lots of custom fields to be required. This can get visually noisy in the UI, so Vendure supports tabbed custom fields. Just specify the tab name in the `ui` object, and those fields with the same tab name will be grouped in the UI! The tab name can also be a translation token if you need to support multiple languages. :::note Tabs will only be displayed if there is more than one tab name used in the custom fields. A lack of a `tab` property is counted as a tab (the "general" tab). ::: ```ts title="src/vendure-config.ts" const config = { // ... customFields: { Product: [ { name: 'additionalInfo', type: 'text', ui: { component: 'rich-text-form-input' } }, { name: 'specs', type: 'text', ui: { component: 'json-editor-form-input' } }, { name: 'width', type: 'int', ui: { tab: 'Shipping' } }, { name: 'height', type: 'int', ui: { tab: 'Shipping' } }, { name: 'depth', type: 'int', ui: { tab: 'Shipping' } }, { name: 'weight', type: 'int', ui: { tab: 'Shipping' } }, ], }, } ``` ## TypeScript Typings Because custom fields are generated at run-time, TypeScript has no way of knowing about them based on your VendureConfig. Consider the example above - let's say we have a [plugin](/guides/developer-guide/plugins/) which needs to access the custom field values on a Product entity. Attempting to access the custom field will result in a TS compiler error: ```ts {hl_lines=[12,13]} import { RequestContext, TransactionalConnection, ID, Product } from '@vendure/core'; export class MyService { constructor(private connection: TransactionalConnection) { } async getInfoUrl(ctx: RequestContext, productId: ID) { const product = await this.connection .getRepository(ctx, Product) .findOne(productId); return product.customFields.infoUrl; } // ^ TS2339: Property 'infoUrl' } // does not exist on type 'CustomProductFields'. ``` The "easy" way to solve this is to assert the `customFields` object as `any`: ```ts return (product.customFields as any).infoUrl; ``` However, this sacrifices type safety. To make our custom fields type-safe we can take advantage of a couple of more advanced TypeScript features - [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#merging-interfaces) and [ambient modules](https://www.typescriptlang.org/docs/handbook/modules.html#ambient-modules). This allows us to extend the built-in `CustomProductFields` interface to add our custom fields to it: ```ts // types.ts // Note: we are using a deep import here, rather than importing from `@vendure/core` due to // a possible bug in TypeScript (https://github.com/microsoft/TypeScript/issues/46617) which // causes issues when multiple plugins extend the same custom fields interface. import { CustomProductFields } from '@vendure/core/dist/entity/custom-entity-fields'; declare module '@vendure/core/dist/entity/custom-entity-fields' { interface CustomProductFields { infoUrl: string; downloadable: boolean; shortName: string; } } ``` When this file is then imported into our service file (either directly or indirectly), TypeScript will know about our custom fields, and we do not need to do any type assertions. ```ts return product.customFields.infoUrl; // no error, plus TS autocomplete works. ``` :::caution Note that for the typings to work correctly, **order of imports matters**. One way to ensure that your custom field typings always get imported first is to include them as the first item in the tsconfig "include" array. ::: :::tip For a working example of this setup, see the [real-world-vendure repo](https://github.com/vendure-ecommerce/real-world-vendure/blob/master/src/plugins/reviews/types.ts) ::: ## Special cases Beyond adding custom fields to the corresponding GraphQL types, and updating paginated list sort & filter options, there are a few special cases where adding custom fields to certain entities will result in further API changes. ### OrderLine custom fields When you define custom fields on the `OrderLine` entity, the following API changes are also automatically provided by Vendure: - Shop API: [addItemToOrder](/reference/graphql-api/shop/mutations#additemtoorder) will have a 3rd input argument, `customFields`, which allows custom field values to be set when adding an item to the order. - Shop API: [adjustOrderLine](/reference/graphql-api/shop/mutations#additemtoorder) will have a 3rd input argument, `customFields`, which allows custom field values to be updated. - Admin API: the equivalent mutations for manipulating draft orders and for modifying and order will also have inputs to allow custom field values to be set. :::info To see an example of this in practice, see the [Configurable Product guide](/guides/how-to/configurable-products/) ::: ### Order custom fields When you define custom fields on the `Order` entity, the following API changes are also automatically provided by Vendure: - Admin API: [modifyOrder](/reference/graphql-api/admin/mutations#modifyorder) will have a `customFields` field on the input object. ### ShippingMethod custom fields When you define custom fields on the `ShippingMethod` entity, the following API changes are also automatically provided by Vendure: - Shop API: [eligibleShippingMethods](/reference/graphql-api/shop/queries#eligibleshippingmethods) will have public custom fields available on the result. ### PaymentMethod custom fields When you define custom fields on the `PaymentMethod` entity, the following API changes are also automatically provided by Vendure: - Shop API: [eligiblePaymentMethods](/reference/graphql-api/shop/queries#eligiblepaymentmethods) will have public custom fields available on the result. ### Customer custom fields When you define custom fields on the `Customer` entity, the following API changes are also automatically provided by Vendure: - Shop API: [registerCustomerAccount](/reference/graphql-api/shop/mutations#registercustomeraccount) will have have a `customFields` field on the input object. --- --- title: "Define custom permissions" showtoc: true --- Vendure uses a fine-grained access control system based on roles & permissions. This is described in detail in the [Auth guide](/guides/core-concepts/auth/). The built-in [`Permission` enum](/reference/typescript-api/common/permission/) includes a range of permissions to control create, read, update, and delete access to the built-in entities. When building plugins, you may need to define new permissions to control access to new functionality. This guide explains how to do so. ## Defining a single permission For example, let's imagine you are creating a plugin which exposes a new mutation that can be used by remote services to sync your inventory. First of all we will define the new permission using the [`PermissionDefinition`](/reference/typescript-api/auth/permission-definition/) class: ```ts title="src/plugins/inventory-sync/constants.ts" import { PermissionDefinition } from '@vendure/core'; export const sync = new PermissionDefinition({ name: 'SyncInventory', description: 'Allows syncing stock levels via Admin API' }); ``` This permission can then be used in conjuction with the [@Allow() decorator](/reference/typescript-api/request/allow-decorator/) to limit access to the mutation: ```ts title="src/plugins/inventory-sync/api/inventory-sync.resolver.ts" import { Mutation, Resolver } from '@nestjs/graphql'; import { Allow } from '@vendure/core'; import { sync } from '../constants'; @Resolver() export class InventorySyncResolver { // highlight-next-line @Allow(sync.Permission) @Mutation() syncInventory(/* ... */) { // ... } } ``` Finally, the `sync` PermissionDefinition must be passed into the VendureConfig so that Vendure knows about this new custom permission: ```ts title="src/plugins/inventory-sync/inventory-sync.plugin.ts" import gql from 'graphql-tag'; import { VendurePlugin } from '@vendure/core'; import { InventorySyncResolver } from './api/inventory-sync.resolver' import { sync } from './constants'; @VendurePlugin({ adminApiExtensions: { schema: gql` input InventoryDataInput { # omitted for brevity } extend type Mutation { syncInventory(input: InventoryDataInput!): Boolean! } `, resolvers: [InventorySyncResolver] }, configuration: config => { // highlight-next-line config.authOptions.customPermissions.push(sync); return config; }, }) export class InventorySyncPlugin {} ``` On starting the Vendure server, this custom permission will now be visible in the Role detail view of the Admin UI, and can be assigned to Roles. ## Custom CRUD permissions Quite often your plugin will define a new entity on which you must perform create, read, update and delete (CRUD) operations. In this case, you can use the [CrudPermissionDefinition](/reference/typescript-api/auth/permission-definition/#crudpermissiondefinition) which simplifies the creation of the set of 4 CRUD permissions. For example, let's imagine we are creating a plugin which adds a new entity called `ProductReview`. We can define the CRUD permissions like so: ```ts title="src/plugins/product-review/constants.ts" import { CrudPermissionDefinition } from '@vendure/core'; export const productReviewPermission = new CrudPermissionDefinition('ProductReview'); ``` These permissions can then be used in our resolver: ```ts title="src/plugins/product-review/api/product-review.resolver.ts" import { Mutation, Resolver } from '@nestjs/graphql'; import { Allow, Transaction } from '@vendure/core'; import { productReviewPermission } from '../constants'; @Resolver() export class ProductReviewResolver { // highlight-next-line @Allow(productReviewPermission.Read) @Query() productReviews(/* ... */) { // ... } // highlight-next-line @Allow(productReviewPermission.Create) @Mutation() @Transaction() createProductReview(/* ... */) { // ... } // highlight-next-line @Allow(productReviewPermission.Update) @Mutation() @Transaction() updateProductReview(/* ... */) { // ... } // highlight-next-line @Allow(productReviewPermission.Delete) @Mutation() @Transaction() deleteProductReview(/* ... */) { // ... } } ``` Finally, the `productReview` CrudPermissionDefinition must be passed into the VendureConfig so that Vendure knows about this new custom permission: ```ts title="src/plugins/product-review/product-review.plugin.ts" import gql from 'graphql-tag'; import { VendurePlugin } from '@vendure/core'; import { ProductReviewResolver } from './api/product-review.resolver' import { productReviewPermission } from './constants'; @VendurePlugin({ adminApiExtensions: { schema: gql` # omitted for brevity `, resolvers: [ProductReviewResolver] }, configuration: config => { // highlight-next-line config.authOptions.customPermissions.push(productReviewPermission); return config; }, }) export class ProductReviewPlugin {} ``` ## Custom permissions for custom fields Since Vendure v2.2.0, it is possible to define custom permissions for custom fields. This is useful when you want to control access to specific custom fields on an entity. For example, imagine a "product reviews" plugin which adds a `rating` custom field to the `Product` entity. You may want to restrict access to this custom field to only those roles which have permissions on the product review plugin. ```ts title="src/plugins/product-review.plugin.ts" import { VendurePlugin } from '@vendure/core'; import { productReviewPermission } from './constants'; @VendurePlugin({ configuration: config => { config.authOptions.customPermissions.push(productReviewPermission); config.customFields.Product.push({ name: 'rating', type: 'int', // highlight-start requiresPermission: [ productReviewPermission.Read, productReviewPermission.Update, ], // highlight-end }); return config; }, }) export class ProductReviewPlugin {} ``` --- --- title: 'Custom Strategies in Plugins' --- When building Vendure plugins, you often need to provide extensible, pluggable implementations for specific features. The **strategy pattern** is the perfect tool for this, allowing plugin users to customize behavior by providing their own implementations. This guide shows you how to implement custom strategies in your plugins, following Vendure's established patterns and best practices. ## Overview A strategy in Vendure is a way to provide a pluggable implementation of a particular feature. Custom strategies in plugins allow users to: - Override default behavior with their own implementations - Inject dependencies and services through the `init()` lifecycle method - Clean up resources using the `destroy()` lifecycle method - Configure the strategy through the plugin's init options ## Creating a Strategy Interface First, define the interface that your strategy must implement. All strategy interfaces should extend `InjectableStrategy` to support dependency injection and lifecycle methods. ```ts title="src/strategies/my-custom-strategy.ts" import { InjectableStrategy, RequestContext } from '@vendure/core'; export interface MyCustomStrategy extends InjectableStrategy { /** * Process some data and return a result */ processData(ctx: RequestContext, data: any): Promise; /** * Validate the input data */ validateInput(data: any): boolean; } ``` ## Implementing a Default Strategy Create a default implementation that users can extend or replace: ```ts title="src/strategies/default-my-custom-strategy.ts" import { Injector, RequestContext, Logger } from '@vendure/core'; import { MyCustomStrategy } from './my-custom-strategy'; import { SomeOtherService } from '../services/some-other.service'; import { loggerCtx } from '../constants'; export class DefaultMyCustomStrategy implements MyCustomStrategy { private someOtherService: SomeOtherService; async init(injector: Injector): Promise { // Inject dependencies during the init phase this.someOtherService = injector.get(SomeOtherService); // Perform any setup logic Logger.info('DefaultMyCustomStrategy initialized', loggerCtx); } async destroy(): Promise { // Clean up resources if needed Logger.info('DefaultMyCustomStrategy destroyed', loggerCtx); } async processData(ctx: RequestContext, data: any): Promise { // Validate input first if (!this.validateInput(data)) { throw new Error('Invalid input data'); } // Use injected service to process data const result = await this.someOtherService.doSomething(ctx, data); // ... do something with the result return result; } validateInput(data: any): boolean { return data != null && typeof data === 'object'; } } ``` ## Adding Strategy to Plugin Options Define your plugin's initialization options to include the strategy: ```ts title="src/types.ts" import { MyCustomStrategy } from './strategies/my-custom-strategy'; export interface MyPluginInitOptions { /** * Custom strategy for processing data * @default DefaultMyCustomStrategy */ processingStrategy?: MyCustomStrategy; /** * Other plugin options */ someOtherOption?: string; } ``` ## Configuring the Plugin In your plugin definition, provide the default strategy and allow users to override it: ```ts title="src/my-plugin.ts" import { PluginCommonModule, VendurePlugin, Injector } from '@vendure/core'; import { OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { MY_PLUGIN_OPTIONS } from './constants'; import { MyPluginInitOptions } from './types'; import { DefaultMyCustomStrategy } from './strategies/default-my-custom-strategy'; import { MyPluginService } from './services/my-plugin.service'; import { SomeOtherService } from './services/some-other.service'; @VendurePlugin({ imports: [PluginCommonModule], providers: [ MyPluginService, SomeOtherService, { provide: MY_PLUGIN_OPTIONS, useFactory: () => MyPlugin.options, }, ], configuration: config => { // You can also configure core Vendure strategies here if needed return config; }, compatibility: '^3.0.0', }) export class MyPlugin implements OnApplicationBootstrap, OnApplicationShutdown { static options: MyPluginInitOptions; constructor(private moduleRef: ModuleRef) {} static init(options: MyPluginInitOptions) { this.options = { // Provide default strategy if none specified processingStrategy: new DefaultMyCustomStrategy(), ...options, }; return MyPlugin; } async onApplicationBootstrap() { await this.initStrategy(); } async onApplicationShutdown() { await this.destroyStrategy(); } private async initStrategy() { const strategy = MyPlugin.options.processingStrategy; if (strategy && typeof strategy.init === 'function') { const injector = new Injector(this.moduleRef); await strategy.init(injector); } } private async destroyStrategy() { const strategy = MyPlugin.options.processingStrategy; if (strategy && typeof strategy.destroy === 'function') { await strategy.destroy(); } } } ``` ## Using the Strategy in Services Access the strategy through dependency injection in your services: ```ts title="src/services/my-plugin.service.ts" import { Injectable, Inject } from '@nestjs/common'; import { RequestContext } from '@vendure/core'; import { MY_PLUGIN_OPTIONS } from '../constants'; import { MyPluginInitOptions } from '../types'; @Injectable() export class MyPluginService { constructor(@Inject(MY_PLUGIN_OPTIONS) private options: MyPluginInitOptions) {} async processUserData(ctx: RequestContext, userData: any): Promise { // Delegate to the configured strategy return this.options.processingStrategy.processData(ctx, userData); } validateUserInput(data: any): boolean { return this.options.processingStrategy.validateInput(data); } } ``` ## User Implementation Example Plugin users can now provide their own strategy implementations: ```ts title="src/my-custom-implementation.ts" import { Injector, RequestContext, Logger } from '@vendure/core'; import { MyCustomStrategy } from '@my-org/my-plugin'; import { ExternalApiService } from './external-api.service'; import { loggerCtx } from '../constants'; export class CustomProcessingStrategy implements MyCustomStrategy { private externalApi: ExternalApiService; async init(injector: Injector): Promise { this.externalApi = injector.get(ExternalApiService); // Initialize external API connection await this.externalApi.connect(); Logger.info('Custom processing strategy initialized', loggerCtx); } async destroy(): Promise { // Clean up external connections if (this.externalApi) { await this.externalApi.disconnect(); } Logger.info('Custom processing strategy destroyed', loggerCtx); } async processData(ctx: RequestContext, data: any): Promise { if (!this.validateInput(data)) { throw new Error('Invalid data format'); } // Use external API for processing const result = await this.externalApi.processData(data); return `Processed: ${result}`; } validateInput(data: any): boolean { // Custom validation logic return data && data.type === 'custom' && data.value; } } ``` ## Plugin Configuration by Users Users configure the plugin with their custom strategy: ```ts title="vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { MyPlugin } from '@my-org/my-plugin'; import { CustomProcessingStrategy } from './my-custom-implementation'; export const config: VendureConfig = { plugins: [ MyPlugin.init({ processingStrategy: new CustomProcessingStrategy(), someOtherOption: 'custom-value', }), ], // ... other config }; ``` ## Strategy with Options You can also create strategies that accept configuration options: ```ts title="src/strategies/configurable-strategy.ts" import { Injector, RequestContext } from '@vendure/core'; import { MyCustomStrategy } from './my-custom-strategy'; export interface ConfigurableStrategyOptions { timeout: number; retries: number; apiKey: string; } export class ConfigurableStrategy implements MyCustomStrategy { constructor(private options: ConfigurableStrategyOptions) {} async init(injector: Injector): Promise { // Use options during initialization console.log(`Strategy configured with timeout: ${this.options.timeout}ms`); } async destroy(): Promise { // Cleanup logic } async processData(ctx: RequestContext, data: any): Promise { // Use configuration options const timeout = this.options.timeout; const retries = this.options.retries; // Implementation using these options... return 'processed with options'; } validateInput(data: any): boolean { return true; } } ``` Usage: ```ts title="vendure-config.ts" import { ConfigurableStrategy } from './strategies/configurable-strategy'; // In plugin configuration MyPlugin.init({ processingStrategy: new ConfigurableStrategy({ timeout: 5000, retries: 3, apiKey: process.env.API_KEY, }), }); ``` ## Multiple Strategies in One Plugin For complex plugins, you might need multiple strategies: ```ts title="src/types.ts" export interface ComplexPluginOptions { dataProcessingStrategy?: DataProcessingStrategy; validationStrategy?: ValidationStrategy; cacheStrategy?: CacheStrategy; } ``` ```ts title="src/complex-plugin.ts" @VendurePlugin({ // ... plugin config }) export class ComplexPlugin implements OnApplicationBootstrap, OnApplicationShutdown { static options: ComplexPluginOptions; static init(options: ComplexPluginOptions) { this.options = { dataProcessingStrategy: new DefaultDataProcessingStrategy(), validationStrategy: new DefaultValidationStrategy(), cacheStrategy: new DefaultCacheStrategy(), ...options, }; return ComplexPlugin; } async onApplicationBootstrap() { await this.initAllStrategies(); } async onApplicationShutdown() { await this.destroyAllStrategies(); } private async initAllStrategies() { const injector = new Injector(this.moduleRef); const strategies = [ ComplexPlugin.options.dataProcessingStrategy, ComplexPlugin.options.validationStrategy, ComplexPlugin.options.cacheStrategy, ]; for (const strategy of strategies) { if (strategy && typeof strategy.init === 'function') { await strategy.init(injector); } } } private async destroyAllStrategies() { const strategies = [ ComplexPlugin.options.dataProcessingStrategy, ComplexPlugin.options.validationStrategy, ComplexPlugin.options.cacheStrategy, ]; for (const strategy of strategies) { if (strategy && typeof strategy.destroy === 'function') { await strategy.destroy(); } } } } ``` ## Best Practices ### 1. Always Extend InjectableStrategy ```ts export interface MyStrategy extends InjectableStrategy { // ... strategy methods } ``` ### 2. Provide Sensible Defaults Always provide a default implementation so users can use your plugin out-of-the-box: ```ts static init(options: MyPluginOptions) { this.options = { myStrategy: new DefaultMyStrategy(), ...options, }; return MyPlugin; } ``` ### 3. Handle Lifecycle Properly Always implement proper init/destroy handling in your plugin: ```ts async onApplicationBootstrap() { await this.initStrategies(); } async onApplicationShutdown() { await this.destroyStrategies(); } ``` ### 4. Use TypeScript for Better DX Provide strong typing for better developer experience: ```ts export interface MyStrategy extends InjectableStrategy { processData(ctx: RequestContext, data: T): Promise>; } ``` ### 5. Document Your Strategy Interface Provide comprehensive JSDoc comments: ```ts export interface MyStrategy extends InjectableStrategy { /** * @description * Processes the input data and returns a transformed result. * This method is called for each data processing request. * * @param ctx - The current request context * @param data - The input data to process * @returns Promise resolving to the processed result */ processData(ctx: RequestContext, data: any): Promise; } ``` ## Summary Custom strategies in plugins provide a powerful way to make your plugins extensible and configurable. By following the patterns outlined in this guide, you can: - Define clear strategy interfaces that extend `InjectableStrategy` - Provide default implementations that work out-of-the-box - Allow users to inject dependencies through the `init()` method - Properly manage strategy lifecycle with `init()` and `destroy()` methods - Enable users to provide their own implementations - Support configuration options for strategies This approach ensures your plugins are flexible, maintainable, and follow Vendure's established conventions. --- --- title: "Define a database entity" showtoc: true --- :::cli Use `npx vendure add` to easily add a new entity to a plugin. ::: Your plugin can define new database entities to model the data it needs to store. For instance, a product review plugin would need a way to store reviews. This would be done by defining a new database entity. This example shows how new [TypeORM database entities](https://typeorm.io/entities) can be defined by plugins. ## Create the entity class ```ts title="src/plugins/reviews/entities/product-review.entity.ts" import { DeepPartial } from '@vendure/common/lib/shared-types'; import { VendureEntity, Product, EntityId, ID } from '@vendure/core'; import { Column, Entity, ManyToOne } from 'typeorm'; @Entity() class ProductReview extends VendureEntity { constructor(input?: DeepPartial) { super(input); } @ManyToOne(type => Product) product: Product; @EntityId() productId: ID; @Column() text: string; @Column() rating: number; } ``` :::note Any custom entities *must* extend the [`VendureEntity`](/reference/typescript-api/entities/vendure-entity/) class. ::: In this example, we are making use of the following TypeORM decorators: * [`@Entity()`](https://typeorm.io/decorator-reference#entity) - defines the entity as a TypeORM entity. This is **required** for all entities. It tells TypeORM to create a new table in the database for this entity. * [`@Column()`](https://typeorm.io/decorator-reference#column) - defines a column in the database table. The data type of the column is inferred from the TypeScript type of the property, but can be overridden by passing an options object to the decorator. The `@Column()` also supports many other options for defining the column, such as `nullable`, `default`, `unique`, `primary`, `enum` etc. * [`@ManyToOne()`](https://typeorm.io/decorator-reference#manytoone) - defines a many-to-one relationship between this entity and another entity. In this case, many `ProductReview` entities can be associated with a given `Product`. There are other types of relations that can be defined - see the [TypeORM relations docs](https://typeorm.io/relations). There is an additional Vendure-specific decorator: * [`@EntityId()`](/reference/typescript-api/configuration/entity-id-decorator) marks a property as the ID of another entity. In this case, the `productId` property is the ID of the `Product` entity. The reason that we have a special decorator for this is that Vendure supports both numeric and string IDs, and the `@EntityId()` decorator will automatically set the database column to be the correct type. This `productId` is not _necessary_, but it is a useful convention to allow access to the ID of the associated entity without having to perform a database join. ## Register the entity The new entity is then passed to the `entities` array of the VendurePlugin metadata: ```ts title="src/plugins/reviews/reviews-plugin.ts" import { VendurePlugin } from '@vendure/core'; import { ProductReview } from './entities/product-review.entity'; @VendurePlugin({ // highlight-next-line entities: [ProductReview], }) export class ReviewsPlugin {} ``` :::note Once you have added a new entity to your plugin, and the plugin has been added to your VendureConfig plugins array, you must create a [database migration](/guides/developer-guide/migrations/) to create the new table in the database. ::: ## Using the entity The new entity can now be used in your plugin code. For example, you might want to create a new product review when a customer submits a review via the storefront: ```ts title="src/plugins/reviews/services/review.service.ts" import { Injectable } from '@nestjs/common'; import { RequestContext, Product, TransactionalConnection } from '@vendure/core'; import { ProductReview } from '../entities/product-review.entity'; @Injectable() export class ReviewService { constructor(private connection: TransactionalConnection) {} async createReview(ctx: RequestContext, productId: string, rating: number, text: string) { const product = await this.connection.getEntityOrThrow(ctx, Product, productId); // highlight-start const review = new ProductReview({ product, rating, text, }); return this.connection.getRepository(ctx, ProductReview).save(review); // highlight-end } } ``` ## Available entity decorators In addition to the decorators described above, there are many other decorators provided by TypeORM. Some commonly used ones are: - [`@OneToOne()`](https://typeorm.io/decorator-reference#onetoone) - [`@OneToMany()`](https://typeorm.io/decorator-reference#onetomany) - [`@ManyToMany()`](https://typeorm.io/decorator-reference#manytomany) - [`@Index()`](https://typeorm.io/decorator-reference#index) - [`@Unique()`](https://typeorm.io/decorator-reference#unique) There is also another Vendure-specific decorator for representing monetary values specifically: - [`@Money()`](/reference/typescript-api/money/money-decorator): This works together with the [`MoneyStrategy`](/reference/typescript-api/money/money-strategy) to allow configurable control over how monetary values are stored in the database. For more information see the [Money & Currency guide](/guides/core-concepts/money/#the-money-decorator). :::info The full list of TypeORM decorators can be found in the [TypeORM decorator reference](https://typeorm.io/decorator-reference) ::: ## Corresponding GraphQL type Once you have defined a new DB entity, it is likely that you want to expose it in your GraphQL API. Here's how to [define a new type in your GraphQL API](/guides/developer-guide/extend-graphql-api/#defining-a-new-type). ## Supporting custom fields From Vendure v2.2, it is possible to add support for [custom fields](/guides/developer-guide/custom-fields/) to your custom entities. This is useful when you are defining a custom entity as part of a plugin which is intended to be used by other developers. For example, a plugin which defines a new entity for storing product reviews might want to allow the developer to add custom fields to the review entity. First you need to update your entity class to implement the `HasCustomFields` interface, and provide an empty class which will be used to store the custom field values: ```ts title="src/plugins/reviews/entities/product-review.entity.ts" import { DeepPartial, HasCustomFields, Product, VendureEntity, } from '@vendure/core'; import { Column, Entity, ManyToOne } from 'typeorm'; // highlight-next-line export class CustomProductReviewFields {} @Entity() // highlight-next-line export class ProductReview extends VendureEntity implements HasCustomFields { constructor(input?: DeepPartial) { super(input); } // highlight-start @Column(type => CustomProductReviewFields) customFields: CustomProductReviewFields; // highlight-end @ManyToOne(type => Product) product: Product; @EntityId() productId: ID; @Column() text: string; @Column() rating: number; } ``` Now you'll be able to add custom fields to the `ProductReview` entity via the VendureConfig: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... customFields: { ProductReview: [ { name: 'reviewerName', type: 'string' }, { name: 'reviewerLocation', type: 'string' }, ], }, }; ``` --- --- title: "GraphQL Dataloaders" showtoc: true --- [Dataloaders](https://github.com/graphql/dataloader) are used in GraphQL to solve the so called N+1 problem. This is an advanced performance optimization technique you may want to use in your application if you find certain custom queries are slow or inefficient. ## N+1 problem Imagine a cart with 20 items. Your implementation requires you to perform an `async` calculation `isSubscription` for each cart item which executes one or more queries each time it is called, and it takes pretty long on each execution. It works fine for a cart with 1 or 2 items. But with more than 15 items, suddenly the cart takes a **lot** longer to load. Especially when the site is busy. The reason: the N+1 problem. Your cart is firing of 20 or more queries almost at the same time, adding **significantly** to the GraphQL request. It's like going to the McDonald's drive-in to get 10 hamburgers and getting in line 10 times to get 1 hamburger at a time. It's not efficient. ## The solution: dataloaders Dataloaders allow you to say: instead of loading each field in the `grapqhl` tree one at a time, aggregate all the `ids` you want to execute the `async` calculation for, and then execute this for all the `ids` in one efficient `request`. Dataloaders are generally used on `fieldResolver`s. Often, you will need a specific dataloader for each field resolver. A Dataloader can return anything: `boolean`, `ProductVariant`, `string`, etc. ## Performance implications Dataloaders can have a huge impact on performance. If your `fieldResolver` executes queries, and you log these queries, you should see a cascade of queries before the implementation of the dataloader, change to a single query using multiple `ids` after you implement it. ## Do I need this for `CustomField` relations? No, not normally. `CustomField` relations are automatically added to the root query for the `entity` that they are part of. So, they are loaded as part of the query that loads that entity. ## Example We will provide a complete example here for you to use as a starting point. The skeleton created can handle multiple dataloaders across multiple channels. We will implement a `fieldResolver` called `isSubscription` for an `OrderLine` that will return a `true/false` for each incoming `orderLine`, to indicate whether the `orderLine` represents a subscription. ```ts title="src/plugins/my-plugin/api/api-extensions.ts" import gql from 'graphql-tag'; export const shopApiExtensions = gql` extend type OrderLine { isSubscription: Boolean! } ` ``` This next part import the `dataloader` package, which you can install with ```sh npm install dataloader ``` **Dataloader skeleton** ```ts title="src/plugins/my-plugin/api/dataloader.ts" import DataLoader from 'dataloader' const LoggerCtx = 'SubscriptionDataloaderService' @Injectable({ scope: Scope.REQUEST }) // Important! Dataloaders live at the request level export class DataloaderService { /** * first level is channel identifier, second level is dataloader key */ private loaders = new Map>>() constructor(private service: SubscriptionExtensionService) {} getLoader(ctx: RequestContext, dataloaderKey: string) { const token = ctx.channel?.code ?? `${ctx.channelId}` Logger.debug(`Dataloader retrieval: ${token}, ${dataloaderKey}`, LoggerCtx) if (!this.loaders.has(token)) { this.loaders.set(token, new Map>()) } const channelLoaders = this.loaders.get(token)! if (!channelLoaders.get(dataloaderKey)) { let loader: DataLoader switch (dataloaderKey) { case 'is-subscription': loader = new DataLoader((ids) => this.batchLoadIsSubscription(ctx, ids as ID[]), ) break // Implement cases for your other dataloaders here default: throw new Error(`Unknown dataloader key ${dataloaderKey}`) } channelLoaders.set(dataloaderKey, loader) } return channelLoaders.get(dataloaderKey)! } private async batchLoadIsSubscription( ctx: RequestContext, ids: ID[], ): Promise { // Returns an array of ids that represent those input ids that are subscriptions // Remember: this array can be smaller than the input array const subscriptionIds = await this.service.whichSubscriptions(ctx, ids) Logger.debug(`Dataloader is-subscription: ${ids}: ${subscriptionIds}`, LoggerCtx) return ids.map((id) => subscriptionIds.includes(id)) // Important! preserve order and size of input ids array } } ``` ```ts title="src/plugins/my-plugin/api/entity-resolver.ts" @Resolver(() => OrderLine) export class MyPluginOrderLineEntityResolver { constructor( private dataloaderService: DataloaderService, ) {} @ResolveField() isSubscription(@Ctx() ctx: RequestContext, @Parent() parent: OrderLine) { const loader = this.dataloaderService.getLoader(ctx, 'is-subscription') return loader.load(parent.id) } } ``` To make it all work, ensure that the `DataLoaderService` is loaded in your `plugin` as a provider. :::tip Dataloaders map the result in the same order as the `ids` you send to the dataloader. Dataloaders expect the same order and array size in the return result. In other words: ensure that the order of your returned result is the same as the incoming `ids` and don't omit values! ::: --- --- title: "Database subscribers" --- # Defining database subscribers TypeORM allows us to define [subscribers](https://typeorm.io/listeners-and-subscribers#what-is-a-subscriber). With a subscriber, we can listen to specific entity events and take actions based on inserts, updates, deletions and more. If you need lower-level access to database changes that you get with the [Vendure EventBus system](/reference/typescript-api/events/event-bus/), TypeORM subscribers can be useful. ## Simple subscribers The simplest way to register a subscriber is to pass it to the `dbConnectionOptions.subscribers` array: ```ts title="src/plugins/my-plugin/product-subscriber.ts" import { Product, VendureConfig } from '@vendure/core'; import { EntitySubscriberInterface, EventSubscriber, UpdateEvent } from 'typeorm'; @EventSubscriber() export class ProductSubscriber implements EntitySubscriberInterface { listenTo() { return Product; } beforeUpdate(event: UpdateEvent) { console.log(`BEFORE PRODUCT UPDATED: `, event.entity); } } ``` ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { ProductSubscriber } from './plugins/my-plugin/product-subscriber'; // ... export const config: VendureConfig = { dbConnectionOptions: { // ... subscribers: [ProductSubscriber], } } ``` The limitation of this method is that the `ProductSubscriber` class cannot make use of dependency injection, since it is not known to the underlying NestJS application and is instead instantiated by TypeORM directly. If you need to make use of providers in your subscriber class, you'll need to use the following pattern. ## Injectable subscribers 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. ```ts title="src/plugins/my-plugin/product-subscriber.ts" import { PluginCommonModule, Product, TransactionalConnection, VendureConfig, VendurePlugin, } from '@vendure/core'; import { Injectable } from '@nestjs/common'; import { EntitySubscriberInterface, EventSubscriber, UpdateEvent } from 'typeorm'; import { MyService } from './services/my.service'; @Injectable() @EventSubscriber() export class ProductSubscriber implements EntitySubscriberInterface { constructor(private connection: TransactionalConnection, private myService: MyService) { // This is how we can dynamically register the subscriber // with TypeORM connection.rawConnection.subscribers.push(this); } listenTo() { return Product; } async beforeUpdate(event: UpdateEvent) { console.log(`BEFORE PRODUCT UPDATED: `, event.entity); // Now we can make use of our injected provider await this.myService.handleProductUpdate(event); } } ``` ```ts title="src/plugins/my-plugin/my.plugin.ts" @VendurePlugin({ imports: [PluginCommonModule], providers: [ProductSubscriber, MyService], }) class MyPlugin { } ``` ```ts title="src/vendure-config.ts" // ... export const config: VendureConfig = { dbConnectionOptions: { // We no longer need to pass the subscriber here // subscribers: [ProductSubscriber], }, plugins: [ MyPlugin, ], } ``` ## Troubleshooting subscribers An important factor when working with TypeORM subscribers is that they are very low-level and require some understanding of the Vendure schema. For example consider the `ProductSubscriber` above. If an admin changes a product's name in the Admin UI, this subscriber **will not fire**. The reason is that the `name` property is actually stored on the `ProductTranslation` entity, rather than on the `Product` entity. So if your subscribers do not seem to work as expected, check your database schema and make sure you are really targeting the correct entity which has the property that you are interested in. --- --- title: "Error Handling" showtoc: true --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import Stackblitz from '@site/src/components/Stackblitz'; Errors in Vendure can be divided into two categories: * Unexpected errors * Expected errors These two types have different meanings and are handled differently from one another. ## Unexpected Errors This type of error occurs when something goes unexpectedly wrong during the processing of a request. Examples include internal server errors, database connectivity issues, lacking permissions for a resource, etc. In short, these are errors that are *not supposed to happen*. Internally, these situations are handled by throwing an Error: ```ts const customer = await this.findOneByUserId(ctx, user.id); // in this case, the customer *should always* be found, and if // not then something unknown has gone wrong... if (!customer) { throw new InternalServerError('error.cannot-locate-customer-for-user'); } ``` In the GraphQL APIs, these errors are returned in the standard `errors` array: ```json { "errors": [ { "message": "You are not currently authorized to perform this action", "locations": [ { "line": 2, "column": 2 } ], "path": [ "me" ], "extensions": { "code": "FORBIDDEN" } } ], "data": { "me": null } } ``` So your client applications need a generic way of detecting and handling this kind of error. For example, many http client libraries support "response interceptors" which can be used to intercept all API responses and check the `errors` array. :::note GraphQL will return a `200` status even if there are errors in the `errors` array. This is because in GraphQL it is still possible to return good data _alongside_ any errors. ::: Here's how it might look in a simple Fetch-based client: ```ts title="src/client.ts" export function query(document: string, variables: Record = {}) { return fetch(endpoint, { method: 'POST', headers, credentials: 'include', body: JSON.stringify({ query: document, variables, }), }) .then(async (res) => { if (!res.ok) { const body = await res.json(); throw new Error(body); } const newAuthToken = res.headers.get('vendure-auth-token'); if (newAuthToken) { localStorage.setItem(AUTH_TOKEN_KEY, newAuthToken); } return res.json(); }) .catch((err) => { // This catches non-200 responses, such as malformed queries or // network errors. Handle this with your own error handling logic. // For this demo we just show an alert. window.alert(err.message); }) .then((result) => { // highlight-start // We check for any GraphQL errors which would be in the // `errors` array of the response body: if (Array.isArray(result.errors)) { // It looks like we have an unexpected error. // At this point you could take actions like: // - logging the error to a remote service // - displaying an error popup to the user // - inspecting the `error.extensions.code` to determine the // type of error and take appropriate action. E.g. a // in response to a FORBIDDEN_ERROR you can redirect the // user to a login page. // In this example we just display an alert: const errorMessage = result.errors.map((e) => e.message).join('\n'); window.alert(`Unexpected error caught:\n\n${errorMessage}`); } // highlight-end return result; }); } ``` ## Expected errors (ErrorResults) This type of error represents a well-defined result of (typically) a GraphQL mutation which is not considered "successful". For example, when using the `applyCouponCode` mutation, the code may be invalid, or it may have expired. These are examples of "expected" errors and are named in Vendure "ErrorResults". These ErrorResults are encoded into the GraphQL schema itself. ErrorResults all implement the `ErrorResult` interface: ```graphql interface ErrorResult { errorCode: ErrorCode! message: String! } ``` Some ErrorResults add other relevant fields to the type: ```graphql "Returned if there is an error in transitioning the Order state" type OrderStateTransitionError implements ErrorResult { errorCode: ErrorCode! message: String! transitionError: String! fromState: String! toState: String! } ``` Operations that may return ErrorResults use a GraphQL `union` as their return type: ```graphql type Mutation { "Applies the given coupon code to the active Order" applyCouponCode(couponCode: String!): ApplyCouponCodeResult! } union ApplyCouponCodeResult = Order | CouponCodeExpiredError | CouponCodeInvalidError | CouponCodeLimitError ``` ### Querying an ErrorResult union When performing an operation of a query or mutation which returns a union, you will need to use the [GraphQL conditional fragment](https://graphql.org/learn/schema/#union-types) to select the desired fields: ```graphql mutation ApplyCoupon($code: String!) { applyCouponCode(couponCode: $code) { __typename ...on Order { id couponCodes totalWithTax } # querying the ErrorResult fields # "catches" all possible errors ...on ErrorResult { errorCode message } # you can also specify particular fields # if your client app needs that specific data # as part of handling the error. ...on CouponCodeLimitError { limit } } } ``` :::note The `__typename` field is added by GraphQL to _all_ object types, so we can include it no matter whether the result will end up being an `Order` object or an `ErrorResult` object. We can then use the `__typename` value to determine what kind of object we have received. Some clients such as Apollo Client will automatically add the `__typename` field to all queries and mutations. If you are using a client which does not do this, you will need to add it manually. ::: Here's how a response would look in both the success and error result cases: ```json { "data": { "applyCouponCode": { // highlight-next-line "__typename": "Order", "id": "123", "couponCodes": ["VALID-CODE"], "totalWithTax": 12599, } } } ``` ```json { "data": { "applyCouponCode": { // highlight-next-line "__typename": "CouponCodeLimitError", "errorCode": "COUPON_CODE_LIMIT_ERROR", "message": "Coupon code cannot be used more than once per customer", // highlight-next-line "limit": 1 } } } ``` ### Handling ErrorResults in plugin code If you are writing a plugin which deals with internal Vendure service methods that may return ErrorResults, then you can use the `isGraphQlErrorResult()` function to check whether the result is an ErrorResult: ```ts import { Injectable} from '@nestjs/common'; import { isGraphQlErrorResult, Order, OrderService, OrderState, RequestContext } from '@vendure/core'; @Injectable() export class MyService { constructor(private orderService: OrderService) {} async myMethod(ctx: RequestContext, order: Order, newState: OrderState) { const transitionResult = await this.orderService.transitionToState(ctx, order.id, newState); if (isGraphQlErrorResult(transitionResult)) { // The transition failed with an ErrorResult throw transitionResult; } else { // TypeScript will correctly infer the type of `transitionResult` to be `Order` return transitionResult; } } } ``` ### Handling ErrorResults in client code Because we know all possible ErrorResult that may occur for a given mutation, we can handle them in an exhaustive manner. In other words, we can ensure our storefront has some sensible response to all possible errors. Typically this will be done with a `switch` statement: ```ts const result = await query(APPLY_COUPON_CODE, { code: 'INVALID-CODE' }); switch (result.applyCouponCode.__typename) { case 'Order': // handle success break; case 'CouponCodeExpiredError': // handle expired code break; case 'CouponCodeInvalidError': // handle invalid code break; case 'CouponCodeLimitError': // handle limit error break; default: // any other ErrorResult can be handled with a generic error message } ``` If we combine this approach with [GraphQL code generation](/guides/storefront/codegen/), then TypeScript will even be able to help us ensure that we have handled all possible ErrorResults: ```ts // Here we are assuming that the APPLY_COUPON_CODE query has been generated // by the codegen tool, and therefore has the // type `TypedDocumentNode`. const result = await query(APPLY_COUPON_CODE, { code: 'INVALID-CODE' }); switch (result.applyCouponCode.__typename) { case 'Order': // handle success break; case 'CouponCodeExpiredError': // handle expired code break; case 'CouponCodeInvalidError': // handle invalid code break; case 'CouponCodeLimitError': // handle limit error break; default: // highlight-start // this line will cause a TypeScript error if there are any // ErrorResults which we have not handled in the switch cases // above. const _exhaustiveCheck: never = result.applyCouponCode; // highlight-end } ``` ## Live example Here is a live example which the handling of unexpected errors as well as ErrorResults: --- --- title: 'Events' --- Vendure emits events which can be subscribed to by plugins. These events are published by the [EventBus](/reference/typescript-api/events/event-bus/) and likewise the `EventBus` is used to subscribe to events. An event exists for virtually all significant actions which occur in the system, such as: - When entities (e.g. `Product`, `Order`, `Customer`) are created, updated or deleted - When a user registers an account - When a user logs in or out - When the state of an `Order`, `Payment`, `Fulfillment` or `Refund` changes A full list of the available events follows. ## Event types
- [`AccountRegistrationEvent`](/reference/typescript-api/events/event-types#accountregistrationevent) - [`AccountVerifiedEvent`](/reference/typescript-api/events/event-types#accountverifiedevent) - [`AdministratorEvent`](/reference/typescript-api/events/event-types#administratorevent) - [`AssetChannelEvent`](/reference/typescript-api/events/event-types#assetchannelevent) - [`AssetEvent`](/reference/typescript-api/events/event-types#assetevent) - [`AttemptedLoginEvent`](/reference/typescript-api/events/event-types#attemptedloginevent) - [`ChangeChannelEvent`](/reference/typescript-api/events/event-types#changechannelevent) - [`ChannelEvent`](/reference/typescript-api/events/event-types#channelevent) - [`CollectionEvent`](/reference/typescript-api/events/event-types#collectionevent) - [`CollectionModificationEvent`](/reference/typescript-api/events/event-types#collectionmodificationevent) - [`CountryEvent`](/reference/typescript-api/events/event-types#countryevent) - [`CouponCodeEvent`](/reference/typescript-api/events/event-types#couponcodeevent) - [`CustomerAddressEvent`](/reference/typescript-api/events/event-types#customeraddressevent) - [`CustomerEvent`](/reference/typescript-api/events/event-types#customerevent) - [`CustomerGroupChangeEvent`](/reference/typescript-api/events/event-types#customergroupchangeevent) - [`CustomerGroupEvent`](/reference/typescript-api/events/event-types#customergroupevent) - [`FacetEvent`](/reference/typescript-api/events/event-types#facetevent) - [`FacetValueEvent`](/reference/typescript-api/events/event-types#facetvalueevent) - [`FulfillmentEvent`](/reference/typescript-api/events/event-types#fulfillmentevent) - [`FulfillmentStateTransitionEvent`](/reference/typescript-api/events/event-types#fulfillmentstatetransitionevent) - [`GlobalSettingsEvent`](/reference/typescript-api/events/event-types#globalsettingsevent) - [`HistoryEntryEvent`](/reference/typescript-api/events/event-types#historyentryevent) - [`IdentifierChangeEvent`](/reference/typescript-api/events/event-types#identifierchangeevent) - [`IdentifierChangeRequestEvent`](/reference/typescript-api/events/event-types#identifierchangerequestevent) - [`InitializerEvent`](/reference/typescript-api/events/event-types#initializerevent) - [`LoginEvent`](/reference/typescript-api/events/event-types#loginevent) - [`LogoutEvent`](/reference/typescript-api/events/event-types#logoutevent) - [`OrderEvent`](/reference/typescript-api/events/event-types#orderevent)
- [`OrderLineEvent`](/reference/typescript-api/events/event-types#orderlineevent) - [`OrderPlacedEvent`](/reference/typescript-api/events/event-types#orderplacedevent) - [`OrderStateTransitionEvent`](/reference/typescript-api/events/event-types#orderstatetransitionevent) - [`PasswordResetEvent`](/reference/typescript-api/events/event-types#passwordresetevent) - [`PasswordResetVerifiedEvent`](/reference/typescript-api/events/event-types#passwordresetverifiedevent) - [`PaymentMethodEvent`](/reference/typescript-api/events/event-types#paymentmethodevent) - [`PaymentStateTransitionEvent`](/reference/typescript-api/events/event-types#paymentstatetransitionevent) - [`ProductChannelEvent`](/reference/typescript-api/events/event-types#productchannelevent) - [`ProductEvent`](/reference/typescript-api/events/event-types#productevent) - [`ProductOptionEvent`](/reference/typescript-api/events/event-types#productoptionevent) - [`ProductOptionGroupChangeEvent`](/reference/typescript-api/events/event-types#productoptiongroupchangeevent) - [`ProductOptionGroupEvent`](/reference/typescript-api/events/event-types#productoptiongroupevent) - [`ProductVariantChannelEvent`](/reference/typescript-api/events/event-types#productvariantchannelevent) - [`ProductVariantEvent`](/reference/typescript-api/events/event-types#productvariantevent) - [`PromotionEvent`](/reference/typescript-api/events/event-types#promotionevent) - [`ProvinceEvent`](/reference/typescript-api/events/event-types#provinceevent) - [`RefundStateTransitionEvent`](/reference/typescript-api/events/event-types#refundstatetransitionevent) - [`RoleChangeEvent`](/reference/typescript-api/events/event-types#rolechangeevent) - [`RoleEvent`](/reference/typescript-api/events/event-types#roleevent) - [`SearchEvent`](/reference/typescript-api/events/event-types#searchevent) - [`SellerEvent`](/reference/typescript-api/events/event-types#sellerevent) - [`ShippingMethodEvent`](/reference/typescript-api/events/event-types#shippingmethodevent) - [`StockMovementEvent`](/reference/typescript-api/events/event-types#stockmovementevent) - [`TaxCategoryEvent`](/reference/typescript-api/events/event-types#taxcategoryevent) - [`TaxRateEvent`](/reference/typescript-api/events/event-types#taxrateevent) - [`TaxRateModificationEvent`](/reference/typescript-api/events/event-types#taxratemodificationevent) - [`ZoneEvent`](/reference/typescript-api/events/event-types#zoneevent) - [`ZoneMembersEvent`](/reference/typescript-api/events/event-types#zonemembersevent)
## Subscribing to events To subscribe to an event, use the `EventBus`'s `.ofType()` method. It is typical to set up subscriptions in the `onModuleInit()` or `onApplicationBootstrap()` lifecycle hooks of a plugin or service (see [NestJS Lifecycle events](https://docs.nestjs.com/fundamentals/lifecycle-events)). Here's an example where we subscribe to the `ProductEvent` and use it to trigger a rebuild of a static storefront: ```ts title="src/plugins/storefront-build/storefront-build.plugin.ts" import { OnModuleInit } from '@nestjs/common'; import { EventBus, ProductEvent, PluginCommonModule, VendurePlugin } from '@vendure/core'; import { StorefrontBuildService } from './services/storefront-build.service'; @VendurePlugin({ imports: [PluginCommonModule], }) export class StorefrontBuildPlugin implements OnModuleInit { constructor( // highlight-next-line private eventBus: EventBus, private storefrontBuildService: StorefrontBuildService, ) {} onModuleInit() { // highlight-start this.eventBus.ofType(ProductEvent).subscribe(event => { this.storefrontBuildService.triggerBuild(); }); // highlight-end } } ``` :::info The `EventBus.ofType()` and related `EventBus.filter()` methods return an RxJS `Observable`. This means that you can use any of the [RxJS operators](https://rxjs-dev.firebaseapp.com/guide/operators) to transform the stream of events. For example, to debounce the stream of events, you could do this: ```ts // highlight-next-line import { debounceTime } from 'rxjs/operators'; // ... this.eventBus .ofType(ProductEvent) // highlight-next-line .pipe(debounceTime(1000)) .subscribe(event => { this.storefrontBuildService.triggerBuild(); }); ``` ::: ### Subscribing to multiple event types Using the `.ofType()` method allows us to subscribe to a single event type. If we want to subscribe to multiple event types, we can use the `.filter()` method instead: ```ts title="src/plugins/my-plugin/my-plugin.plugin.ts" import { Injectable, OnModuleInit } from '@nestjs/common'; import { EventBus, PluginCommonModule, VendurePlugin, ProductEvent, ProductVariantEvent, } from '@vendure/core'; @VendurePlugin({ imports: [PluginCommonModule], }) export class MyPluginPlugin implements OnModuleInit { constructor(private eventBus: EventBus) {} onModuleInit() { this.eventBus // highlight-start .filter(event => event instanceof ProductEvent || event instanceof ProductVariantEvent) // highlight-end .subscribe(event => { // the event will be a ProductEvent or ProductVariantEvent }); } } ``` ## Publishing events You can publish events using the `EventBus.publish()` method. This is useful if you want to trigger an event from within a plugin or service. For example, to publish a `ProductEvent`: ```ts title="src/plugins/my-plugin/services/my-plugin.service.ts" import { Injectable } from '@nestjs/common'; import { EventBus, ProductEvent, RequestContext, Product } from '@vendure/core'; @Injectable() export class MyPluginService { constructor(private eventBus: EventBus) {} async doSomethingWithProduct(ctx: RequestContext, product: Product) { // ... do something // highlight-next-line await this.eventBus.publish(new ProductEvent(ctx, product, 'updated')); } } ``` ## Creating custom events You can create your own custom events by extending the [`VendureEvent`](/reference/typescript-api/events/vendure-event) class. For example, to create a custom event which is triggered when a customer submits a review, you could do this: ```ts title="src/plugins/reviews/events/review-submitted.event.ts" import { ID, RequestContext, VendureEvent } from '@vendure/core'; import { ProductReviewInput } from '../types'; /** * @description * This event is fired whenever a ProductReview is submitted. */ export class ReviewSubmittedEvent extends VendureEvent { constructor( public ctx: RequestContext, public input: ProductReviewInput, ) { super(); } } ``` The event would then be published from your plugin's `ProductReviewService`: ```ts title="src/plugins/reviews/services/product-review.service.ts" import { Injectable } from '@nestjs/common'; import { EventBus, ProductReviewService, RequestContext } from '@vendure/core'; import { ReviewSubmittedEvent } from '../events/review-submitted.event'; import { ProductReviewInput } from '../types'; @Injectable() export class ProductReviewService { constructor( private eventBus: EventBus, private productReviewService: ProductReviewService, ) {} async submitReview(ctx: RequestContext, input: ProductReviewInput) { // highlight-next-line this.eventBus.publish(new ReviewSubmittedEvent(ctx, input)); // handle creation of the new review // ... } } ``` ### Entity events There is a special event class [`VendureEntityEvent`](/reference/typescript-api/events/vendure-entity-event) for events relating to the creation, update or deletion of entities. Let's say you have a custom entity (see [defining a database entity](/guides/developer-guide/database-entity)) `BlogPost` and you want to trigger an event whenever a new `BlogPost` is created, updated or deleted: ```ts title="src/plugins/blog/events/blog-post-event.ts" import { ID, RequestContext, VendureEntityEvent } from '@vendure/core'; import { BlogPost } from '../entities/blog-post.entity'; import { CreateBlogPostInput, UpdateBlogPostInput } from '../types'; type BlogPostInputTypes = CreateBlogPostInput | UpdateBlogPostInput | ID | ID[]; /** * This event is fired whenever a BlogPost is added, updated * or deleted. */ export class BlogPostEvent extends VendureEntityEvent { constructor( ctx: RequestContext, entity: BlogPost, type: 'created' | 'updated' | 'deleted', input?: BlogPostInputTypes, ) { super(entity, type, ctx, input); } } ``` Using this event, you can subscribe to all `BlogPost` events, and for instance filter for only the `created` events: ```ts title="src/plugins/blog/blog-plugin.ts" import { Injectable, OnModuleInit } from '@nestjs/common'; import { EventBus, PluginCommonModule, VendurePlugin } from '@vendure/core'; import { filter } from 'rxjs/operators'; import { BlogPostEvent } from './events/blog-post-event'; @VendurePlugin({ imports: [PluginCommonModule], // ... }) export class BlogPlugin implements OnModuleInit { constructor(private eventBus: EventBus) {} onModuleInit() { this.eventBus // highlight-start .ofType(BlogPostEvent) .pipe(filter(event => event.type === 'created')) .subscribe(event => { const blogPost = event.entity; // do something with the newly created BlogPost }); // highlight-end } } ``` ## Blocking event handlers :::note The following section is an advanced topic. The API described in this section was added in Vendure v2.2.0. ::: When using the `.ofType().subscribe()` pattern, the event handler is non-blocking. This means that the code that publishes the event (the "publishing code") will have no knowledge of any subscribers, and in fact any subscribers will be executed after the code that published the event has completed (technically, any ongoing database transactions are completed before the event gets emitted to the subscribers). This follows the typical [Observer pattern](https://en.wikipedia.org/wiki/Observer_pattern) and is a good fit for most use-cases. However, there may be certain situations in which you want the event handler to cause the publishing code to block until the event handler has completed. This is done by using a "blocking event handler", which does _not_ follow the Observer pattern, but rather it behaves more like a synchronous function call occurring within the publishing code. You may want to use a blocking event handler in the following situations: - The event handler is so critical that you need to ensure that it has completed before the publishing code continues. For example, if the event handler must manipulate some financial records. - Errors in the event handler code should cause the publishing code to fail (and any database transaction to be rolled back). - You want to guard against the edge case that a server instance gets shut down (due to e.g. a fatal error or an auto-scaling event) before event subscribers have been invoked. In these cases, you can use the `EventBus.registerBlockingEventHandler()` method: ```ts title="src/plugins/my-plugin/my-plugin.plugin.ts" import { Injectable, OnModuleInit } from '@nestjs/common'; import { EventBus, PluginCommonModule, VendurePlugin, CustomerEvent } from '@vendure/core'; import { CustomerSyncService } from './services/customer-sync.service'; @VendurePlugin({ imports: [PluginCommonModule], }) export class MyPluginPlugin implements OnModuleInit { constructor( private eventBus: EventBus, private customerSyncService: CustomerSyncService, ) {} onModuleInit() { // highlight-start this.eventBus.registerBlockingEventHandler({ event: CustomerEvent, id: 'sync-customer-details-handler', handler: async event => { // This hypothetical service method would do nothing // more than adding a new job to the job queue. This gives us // the guarantee that the job is added before the publishing // code is able to continue, while minimizing the time spent // in the event handler. await this.customerSyncService.triggerCustomerSyncJob(event); }, }); // highlight-end } } ``` Key differences between event subscribers and blocking event handlers: Aspect | Event subscribers | Blocking event handlers | |---|---|---| | **Execution** | Executed _after_ publishing code completes | Execute _during_ the publishing code | | **Error handling** | Errors do not affect publishing code | Errors propagated to publishing code | | **Transactions** | Guaranteed to execute only after the publishing code transaction has completed | Executed within the transaction of the publishing code | | **Performance** | Non-blocking: subscriber function performance has no effect on publishing code | Blocking: handler function will block execution of publishing code. Handler must be fast. | ### Performance considerations Since blocking event handlers execute within the same transaction as the publishing code, it is important to ensure that they are fast. If a single handler takes longer than 100ms to execute, a warning will be logged. Ideally they should be much faster than that - you can set your Logger's `logLevel` to `LogLevel.DEBUG` to see the execution time of each handler. If multiple handlers are registered for a single event, they will be executed sequentially, so the publishing code will be blocked until all handlers have completed. ### Order of execution If you register multiple handlers for the same event, they will be executed in the order in which they were registered. If you need more control over this order, i.e. to _guarantee_ that a particular handler will execute before another, you can use the `before` or `after` options: ```ts // In one part of your code base this.eventBus.registerBlockingEventHandler({ type: CustomerEvent, id: 'sync-customer-details-handler', handler: async event => { // ... }, }); // In another part of your code base this.eventBus.registerBlockingEventHandler({ type: CustomerEvent, id: 'check-customer-details-handler', handler: async event => { // ... }, // highlight-next-line before: 'sync-customer-details-handler', }); ``` --- --- title: "Extend the GraphQL API" showtoc: true --- Extension to the GraphQL API consists of two parts: 1. **Schema extensions**. These define new types, fields, queries and mutations. 2. **Resolvers**. These provide the logic that backs up the schema extensions. The Shop API and Admin APIs can be extended independently: ```ts title="src/plugins/top-products/top-products.plugin.ts" import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import gql from 'graphql-tag'; import { TopSellersResolver } from './api/top-products.resolver'; const schemaExtension = gql` extend type Query { topProducts: [Product!]! } ` @VendurePlugin({ imports: [PluginCommonModule], // highlight-start // We pass our schema extension and any related resolvers // to our plugin metadata shopApiExtensions: { schema: schemaExtension, resolvers: [TopProductsResolver], }, // Likewise, if you want to extend the Admin API, // you would use `adminApiExtensions` in exactly the // same way. // adminApiExtensions: { // schema: someSchemaExtension // resolvers: [SomeResolver], // }, // highlight-end }) export class TopProductsPlugin { } ``` There are a number of ways the GraphQL APIs can be modified by a plugin. ## Adding a new Query Let's take a simple example where we want to be able to display a banner in our storefront. First let's define a new query in the schema: ```ts title="src/plugins/banner/api/api-extensions.ts" import gql from 'graphql-tag'; export const shopApiExtensions = gql` extend type Query { // highlight-next-line activeBanner(locationId: String!): String } `; ``` This defines a new query called `activeBanner` which takes a `locationId` string argument and returns a string. :::tip `!` = non-nullable In GraphQL, the `!` in `locationId: String!` indicates that the argument is required, and the lack of a `!` on the return type indicates that the return value can be `null`. ::: We can now define the resolver for this query: ```ts title="src/plugins/banner/api/banner-shop.resolver.ts" import { Args, Query, Resolver } from '@nestjs/graphql'; import { Ctx, RequestContext } from '@vendure/core'; import { BannerService } from '../services/banner.service.ts'; @Resolver() class BannerShopResolver { constructor(private bannerService: BannerService) {} // highlight-start @Query() activeBanner(@Ctx() ctx: RequestContext, @Args() args: { locationId: string; }) { return this.bannerService.getBanner(ctx, args.locationId); } // highlight-end } ``` The `BannerService` would implement the actual logic for fetching the banner text from the database. Finally, we need to add the resolver to the plugin metadata: ```ts title="src/plugins/banner/banner.plugin.ts" import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { BannerService } from './services/banner.service'; import { BannerShopResolver } from './api/banner-shop.resolver'; import { shopApiExtensions } from './api/api-extensions'; @VendurePlugin({ imports: [PluginCommonModule], // highlight-start shopApiExtensions: { schema: shopApiExtensions, resolvers: [BannerShopResolver], }, // highlight-end providers: [BannerService], }) export class BannerPlugin {} ``` ## Adding a new Mutation Let's continue the `BannerPlugin` example and now add a mutation which allows the administrator to set the banner text. First we define the mutation in the schema: ```ts title="src/plugins/banner/api/api-extensions.ts" import gql from 'graphql-tag'; export const adminApiExtensions = gql` extend type Mutation { // highlight-next-line setBannerText(locationId: String!, text: String!): String! } `; ``` Here we are defining a new mutation called `setBannerText` which takes two arguments, `locationId` and `text`, both of which are required strings. The return type is a non-nullable string. Now let's define a resolver to handle that mutation: ```ts title="src/plugins/banner/api/banner-admin.resolver.ts" import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { Allow, Ctx, RequestContext, Permission, Transaction } from '@vendure/core'; import { BannerService } from '../services/banner.service.ts'; @Resolver() class BannerAdminResolver { constructor(private bannerService: BannerService) {} // highlight-start @Allow(Permission.UpdateSettings) @Transaction() @Mutation() setBannerText(@Ctx() ctx: RequestContext, @Args() args: { locationId: string; text: string; }) { return this.bannerService.setBannerText(ctx, args.locationId, args.text); } // highlight-end } ``` Note that we have used the `@Allow()` decorator to ensure that only users with the `UpdateSettings` permission can call this mutation. We have also wrapped the resolver in a transaction using `@Transaction()`, which is a good idea for any mutation which modifies the database. :::info For more information on the available decorators, see the [API Layer "decorators" guide](/guides/developer-guide/the-api-layer/#api-decorators). ::: Finally, we add the resolver to the plugin metadata: ```ts title="src/plugins/banner/banner.plugin.ts" import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { BannerService } from './services/banner.service'; import { BannerShopResolver } from './api/banner-shop.resolver'; import { BannerAdminResolver } from './api/banner-admin.resolver'; import { shopApiExtensions, adminApiExtensions } from './api/api-extensions'; @VendurePlugin({ imports: [PluginCommonModule], shopApiExtensions: { schema: shopApiExtensions, resolvers: [BannerShopResolver], }, // highlight-start adminApiExtensions: { schema: adminApiExtensions, resolvers: [BannerAdminResolver], }, // highlight-end providers: [BannerService], }) export class BannerPlugin {} ``` ## Defining a new type If you have defined a new database entity, it is likely that you'll want to expose this entity in your GraphQL API. To do so, you'll need to define a corresponding GraphQL type. Using the `ProductReview` entity from the [Define a database entity guide](/guides/developer-guide/database-entity), let's see how we can expose it as a new type in the API. As a reminder, here is the `ProductReview` entity: ```ts title="src/plugins/reviews/product-review.entity.ts" import { DeepPartial } from '@vendure/common/lib/shared-types'; import { VendureEntity, Product, EntityId, ID } from '@vendure/core'; import { Column, Entity, ManyToOne } from 'typeorm'; @Entity() class ProductReview extends VendureEntity { constructor(input?: DeepPartial) { super(input); } @ManyToOne(type => Product) product: Product; @EntityId() productId: ID; @Column() text: string; @Column() rating: number; } ``` Let's define a new GraphQL type which corresponds to this entity: ```ts title="src/plugins/reviews/api/api-extensions.ts" import gql from 'graphql-tag'; export const apiExtensions = gql` // highlight-start type ProductReview implements Node { id: ID! createdAt: DateTime! updatedAt: DateTime! product: Product! productId: ID! text: String! rating: Float! } // highlight-end `; ``` :::info Assuming the entity is a standard `VendureEntity`, it is good practice to always include the `id`, `createdAt` and `updatedAt` fields in the GraphQL type. Additionally, we implement `Node` which is a built-in GraphQL interface. ::: Now we can add this type to both the Admin and Shop APIs: ```ts title="src/plugins/reviews/reviews.plugin.ts" import gql from 'graphql-tag'; import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { ReviewsResolver } from './api/reviews.resolver'; import { apiExtensions } from './api/api-extensions'; import { ProductReview } from './entities/product-review.entity'; @VendurePlugin({ imports: [PluginCommonModule], shopApiExtensions: { // highlight-next-line schema: apiExtensions, }, entities: [ProductReview], }) export class ReviewsPlugin {} ``` ## Add fields to existing types Let's say you want to add a new field to the `ProductVariant` type to allow the storefront to display some indication of how long a particular product variant would take to deliver, based on data from some external service. First we extend the `ProductVariant` GraphQL type: ```ts title="src/plugins/delivery-time/api/api-extensions.ts" import gql from 'graphql-tag'; export const shopApiExtensions = gql` type DeliveryEstimate { from: Int! to: Int! } // highlight-start extend type ProductVariant { delivery: DeliveryEstimate! } // highlight-end }`; ``` This schema extension says that the `delivery` field will be added to the `ProductVariant` type, and that it will be of type `DeliveryEstimate!`, i.e. a non-nullable instance of the `DeliveryEstimate` type. Next we need to define an "entity resolver" for this field. Unlike the resolvers we have seen above, this resolver will be handling fields on the `ProductVariant` type _only_. This is done by scoping the resolver class that type by passing the type name to the `@Resolver()` decorator: ```ts title="src/plugins/delivery-time/product-variant-entity.resolver.ts" import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { Ctx, RequestContext, ProductVariant } from '@vendure/core'; import { DeliveryEstimateService } from '../services/delivery-estimate.service'; // highlight-next-line @Resolver('ProductVariant') export class ProductVariantEntityResolver { constructor(private deliveryEstimateService: DeliveryEstimateService) { } // highlight-start @ResolveField() delivery(@Ctx() ctx: RequestContext, @Parent() variant: ProductVariant) { return this.deliveryEstimateService.getEstimate(ctx, variant.id); } // highlight-end } ``` Finally we need to pass these schema extensions and the resolver to our plugin metadata: ```ts title="src/plugins/delivery-time/delivery-time.plugin.ts" import gql from 'graphql-tag'; import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { ProductVariantEntityResolver } from './api/product-variant-entity.resolver'; import { shopApiExtensions } from './api/api-extensions'; @VendurePlugin({ imports: [PluginCommonModule], shopApiExtensions: { // highlight-start schema: shopApiExtensions, resolvers: [ProductVariantEntityResolver] // highlight-end } }) export class DeliveryTimePlugin {} ``` ## Override built-in resolvers It is also possible to override an existing built-in resolver function with one of your own. To do so, you need to define a resolver with the same name as the query or mutation you wish to override. When that query or mutation is then executed, your code, rather than the default Vendure resolver, will handle it. ```ts import { Args, Query, Mutation, Resolver } from '@nestjs/graphql'; import { Ctx, RequestContext } from '@vendure/core' @Resolver() class OverrideExampleResolver { @Query() products(@Ctx() ctx: RequestContext, @Args() args: any) { // when the `products` query is executed, this resolver function will // now handle it. } @Transaction() @Mutation() addItemToOrder(@Ctx() ctx: RequestContext, @Args() args: any) { // when the `addItemToOrder` mutation is executed, this resolver function will // now handle it. } } ``` The same can be done for resolving fields: ```ts import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { Ctx, RequestContext, Product } from '@vendure/core'; @Resolver('Product') export class FieldOverrideExampleResolver { @ResolveField() description(@Ctx() ctx: RequestContext, @Parent() product: Product) { return this.wrapInFormatting(ctx, product.id); } private wrapInFormatting(ctx: RequestContext, id: ID): string { // implementation omitted, but wraps the description // text in some special formatting required by the storefront } } ``` ## Resolving union results When dealing with operations that return a GraphQL union type, there is an extra step needed. Union types are commonly returned from mutations in the Vendure APIs. For more detail on this see the section on [ErrorResults](/guides/developer-guide/error-handling#expected-errors-errorresults). For example: ```graphql type MyCustomErrorResult implements ErrorResult { errorCode: ErrorCode! message: String! } union MyCustomMutationResult = Order | MyCustomErrorResult extend type Mutation { myCustomMutation(orderId: ID!): MyCustomMutationResult! } ``` In this example, the resolver which handles the `myCustomMutation` operation will be returning either an `Order` object or a `MyCustomErrorResult` object. The problem here is that the GraphQL server has no way of knowing which one it is at run-time. Luckily Apollo Server (on which Vendure is built) has a means to solve this: > To fully resolve a union, Apollo Server needs to specify which of the union's types is being returned. To achieve this, you define a `__resolveType` function for the union in your resolver map. > > The `__resolveType` function is responsible for determining an object's corresponding GraphQL type and returning the name of that type as a string. > -- Source: [Apollo Server docs](https://www.apollographql.com/docs/apollo-server/schema/unions-interfaces/#resolving-a-union) In order to implement a `__resolveType` function as part of your plugin, you need to create a dedicated Resolver class with a single field resolver method which will look like this: ```ts import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { Ctx, RequestContext, ProductVariant } from '@vendure/core'; @Resolver('MyCustomMutationResult') export class MyCustomMutationResultResolver { @ResolveField() __resolveType(value: any): string { // If it has an "id" property we can assume it is an Order. return value.hasOwnProperty('id') ? 'Order' : 'MyCustomErrorResult'; } } ``` This resolver is then passed in to your plugin metadata like any other resolver: ```ts @VendurePlugin({ imports: [PluginCommonModule], shopApiExtensions: { schema: apiExtensions, resolvers: [/* ... */, MyCustomMutationResultResolver] } }) export class MyPlugin {} ``` Sticking to this example of `myCustomMutation`, you'll also want to use the [ErrorResultUnion](/reference/typescript-api/errors/error-result-union) in your `MyCustomMutationResolver` and corresponding service like so: ```ts import { Args, Mutation, Resolver } from "@nestjs/graphql"; import { Ctx, ErrorResultUnion, ID, Order, RequestContext, Transaction } from "@vendure/core"; @Resolver() export class MyCustomMutationResolver { constructor(private myCustomService: MyCustomService) {} @Mutation() @Transaction() async myCustomMutation( @Ctx() ctx: RequestContext, @Args() args: { orderId: ID } ): Promise> { return this.myCustomService.doMyCustomMutation(ctx, args.orderId); } } ``` This is because Typescript entities do not correspond 1-to-1 with their GraphQL type counterparts, which results in an error when you're returning the `Order`-Object because it is not assignable to `MyCustomMutationResult`. ## Defining custom scalars By default, Vendure bundles `DateTime` and a `JSON` custom scalars (from the [graphql-scalars library](https://github.com/Urigo/graphql-scalars)). From v1.7.0, you can also define your own custom scalars for use in your schema extensions: ```ts import { GraphQLScalarType} from 'graphql'; import { GraphQLEmailAddress } from 'graphql-scalars'; // Scalars can be custom-built as like this one, // or imported from a pre-made scalar library like // the GraphQLEmailAddress example. const FooScalar = new GraphQLScalarType({ name: 'Foo', description: 'A test scalar', serialize(value) { // ... }, parseValue(value) { // ... }, }); @VendurePlugin({ imports: [PluginCommonModule], shopApiExtensions: { schema: gql` scalar Foo scalar EmailAddress `, scalars: { // The key must match the scalar name // given in the schema Foo: FooScalar, EmailAddress: GraphQLEmailAddress, }, }, }) export class CustomScalarsPlugin {} ``` --- --- title: "Importing Data" showtoc: true --- If you have hundreds, thousands or more products, inputting all the data by hand via the Admin UI can be too inefficient. To solve this, Vendure supports bulk-importing product and other data. Data import is also useful for setting up test or demo environments, and is also used by the `@vendure/testing` package for end-to-end tests. ## Product Import Format Vendure uses a flat **.csv** format for importing product data. The format encodes data about: * products * product variants * product & variant assets * product & variant facets * product & variant custom fields Here's an example which defines 2 products, "Laptop" and "Clacky Keyboard". The laptop has 4 variants, and the keyboard only a single variant. ```csv name , slug , description , assets , facets , optionGroups , optionValues , sku , price , taxCategory , stockOnHand , trackInventory , variantAssets , variantFacets Laptop , laptop , "Description of laptop" , laptop_01.jpg|laptop_02.jpg , category:electronics|brand:Apple , screen size|RAM , 13 inch|8GB , L2201308 , 1299.00 , standard , 100 , false , , , , , , , , 15 inch|8GB , L2201508 , 1399.00 , standard , 100 , false , , , , , , , , 13 inch|16GB , L2201316 , 2199.00 , standard , 100 , false , , , , , , , , 15 inch|16GB , L2201516 , 2299.00 , standard , 100 , false , , Clacky Keyboard , clacky-keyboard , "Description of keyboard" , keyboard_01.jpg , category:electronics|brand:Logitech , , , A4TKLA45535 , 74.89 , standard , 100 , false , , ``` Here's an explanation of each column: * `name`: The name of the product. Rows with an empty "name" are interpreted as variants of the preceeding product row. * `slug`: The product's slug. Can be omitted, in which case will be generated from the name. * `description`: The product description. * `assets`: One or more asset file names separated by the pipe (`|`) character. The files can be located on the local file system, in which case the path is interpreted as being relative to the [`importAssetsDir`](/reference/typescript-api/import-export/import-export-options/#importassetsdir) as defined in the VendureConfig. Files can also be urls which will be fetched from a remote http/https url. If you need more control over how assets are imported, you can implement a custom [AssetImportStrategy](/reference/typescript-api/import-export/asset-import-strategy#assetimportstrategy). The first asset will be set as the featuredAsset. * `facets`: One or more facets to apply to the product separated by the pipe (`|`) character. A facet has the format `:`. * `optionGroups`: OptionGroups define what variants make up the product. Applies only to products with more than one variant. * `optionValues`: For each optionGroup defined, a corresponding value must be specified for each variant. Applies only to products with more than one variant. * `sku`: The Stock Keeping Unit (unique product code) for this product variant. * `price`: The price can be either with or without taxes, depending on your channel settings (can be set later). * `taxCategory`: The name of an existing tax category. Tax categories can be also be imported using the InitialData object. * `stockOnHand`: The number of units in stock. * `trackInventory`: Whether this variant should have its stock level tracked, i.e. the stock level is automatically decreased for each unit ordered. * `variantAssets`: Same as `assets` but applied to the product variant. * `variantFacets`: Same as `facets` but applied to the product variant. ### Importing Custom Field Data If you have [CustomFields](/guides/developer-guide/custom-fields/) defined on your Product or ProductVariant entities, this data can also be encoded in the import csv: * `product:`: The value of this column will populate `Product.customFields[customFieldName]`. * `variant:`: The value of this column will populate `ProductVariant.customFields[customFieldName]`. :::info For a real example, see the [products.csv file used to populate the Vendure demo data](https://github.com/vendure-ecommerce/vendure/blob/master/packages/core/mock-data/data-sources/products.csv) ::: #### Importing `relation` custom fields To import custom fields with the type `relation`, the value in the CSV must be a stringified object with an `id` property: ```csv ... ,product:featuredReview ... ,"{ ""id"": 123 }" ``` #### Importing `list` custom fields To import custom fields with `list` set to `true`, the data should be separated with a pipe (`|`) character: ```csv ... ,product:keywords ... ,tablet|pad|android ``` #### Importing data in multiple languages If a field is translatable (i.e. of `localeString` type), you can use column names with an appended language code (e.g. `name:en`, `name:de`, `product:keywords:en`, `product:keywords:de`) to specify its value in multiple languages. Use of language codes has to be consistent throughout the file. You don't have to translate every translatable field. If there are no translated columns for a field, the generic column's value will be used for all languages. But when you do translate columns, the set of languages for each of them needs to be the same. As an example, you cannot use `name:en` and `name:de`, but only provide `slug:en` (it's okay to use only a `slug` column though, in which case this slug will be used for both the English and the German version). ## Initial Data As well as product data, other initialization data can be populated using the [`InitialData` object](/reference/typescript-api/import-export/initial-data/). **This format is intentionally limited**; more advanced requirements (e.g. setting up ShippingMethods that use custom checkers & calculators) should be carried out via [custom populate scripts](#populating-the-server). ```ts import { InitialData, LanguageCode } from '@vendure/core'; export const initialData: InitialData = { paymentMethods: [ { name: 'Standard Payment', handler: { code: 'dummy-payment-handler', arguments: [{ name: 'automaticSettle', value: 'false' }], }, }, ], roles: [ { code: 'administrator', description: 'Administrator', permissions: [ Permission.CreateCatalog, Permission.ReadCatalog, Permission.UpdateCatalog, Permission.DeleteCatalog, Permission.CreateSettings, Permission.ReadSettings, Permission.UpdateSettings, Permission.DeleteSettings, Permission.CreateCustomer, Permission.ReadCustomer, Permission.UpdateCustomer, Permission.DeleteCustomer, Permission.CreateCustomerGroup, Permission.ReadCustomerGroup, Permission.UpdateCustomerGroup, Permission.DeleteCustomerGroup, Permission.CreateOrder, Permission.ReadOrder, Permission.UpdateOrder, Permission.DeleteOrder, Permission.CreateSystem, Permission.ReadSystem, Permission.UpdateSystem, Permission.DeleteSystem, ], }, ], defaultLanguage: LanguageCode.en, countries: [ { name: 'Austria', code: 'AT', zone: 'Europe' }, { name: 'Malaysia', code: 'MY', zone: 'Asia' }, { name: 'United Kingdom', code: 'GB', zone: 'Europe' }, ], defaultZone: 'Europe', taxRates: [ { name: 'Standard Tax', percentage: 20 }, { name: 'Reduced Tax', percentage: 10 }, { name: 'Zero Tax', percentage: 0 }, ], shippingMethods: [{ name: 'Standard Shipping', price: 500 }, { name: 'Express Shipping', price: 1000 }], collections: [ { name: 'Electronics', filters: [ { code: 'facet-value-filter', args: { facetValueNames: ['Electronics'], containsAny: false }, }, ], assetPaths: ['jakob-owens-274337-unsplash.jpg'], }, ], }; ``` * `paymentMethods`: Defines which payment methods are available. * `name`: Name of the payment method. * `handler`: Payment plugin handler information. * `roles`: Defines which user roles are available. * `code`: Role code name. * `description`: Role description. * `permissions`: List of permissions to apply to the role. * `defaultLanguage`: Sets the language that will be used for all translatable entities created by the initial data e.g. Products, ProductVariants, Collections etc. Should correspond to the language used in your product csv file. * `countries`: Defines which countries are available. * `name`: The name of the country in the language specified by `defaultLanguage` * `code`: A standardized code for the country, e.g. [ISO 3166-1](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) * `zone`: A [Zone](/reference/typescript-api/entities/zone) to which this country belongs. * `defaultZone`: Sets the default shipping & tax zone for the default Channel. The zone must correspond to a value of `zone` set in the `countries` array. * `taxRates`: For each item, a new [TaxCategory](/reference/typescript-api/entities/tax-category/) is created, and then a [TaxRate](/reference/typescript-api/entities/tax-rate) is created for each unique zone defined in the `countries` array. * `shippingMethods`: Allows simple flat-rate [ShippingMethods](/reference/typescript-api/entities/shipping-method) to be defined. * `collections`: Allows Collections to be created. Currently, only collections based on facet values can be created (`code: 'facet-value-filter'`). The `assetPaths` and `facetValueNames` values must correspond to a value specified in the products csv file. The name should match the value specified in the product csv file (or can be a normalized - lower-case & hyphenated - version thereof). If there are FacetValues in multiple Facets with the same name, the facet may be specified with a colon delimiter, e.g. `brand:apple`, `flavour: apple`. ## Populating The Server ### The `populate()` function The `@vendure/core` package exposes a [`populate()` function](/reference/typescript-api/import-export/populate/) which can be used along with the data formats described above to populate your Vendure server: ```ts title="src/my-populate-script.ts" import { bootstrap, DefaultJobQueuePlugin } from '@vendure/core'; import { populate } from '@vendure/core/cli'; import path from "path"; import { config } from './vendure-config'; import { initialData } from './my-initial-data'; const productsCsvFile = path.join(__dirname, 'path/to/products.csv') const populateConfig = { ...config, plugins: (config.plugins || []).filter( // Remove your JobQueuePlugin during populating to avoid // generating lots of unnecessary jobs as the Collections get created. plugin => plugin !== DefaultJobQueuePlugin, ), } populate( () => bootstrap(populateConfig), initialData, productsCsvFile, 'my-channel-token' // optional - used to assign imported ) // entities to the specified Channel .then(app => { return app.close(); }) .then( () => process.exit(0), err => { console.log(err); process.exit(1); }, ); ``` :::note When removing the `DefaultJobQueuePlugin` from the plugins list as in the code snippet above, one should manually rebuild the search index in order for the newly added products to appear. In the Admin UI, this can be done by navigating to the product list view and clicking the three icon next to the search input: ![Rebuild search index](./reindex.webp) ::: ### Populating test data When installing with @vendure/create, you have the option of populating test data (products, payment methods, countries, zones, tax rates etc). This guide illustrates how to populate that test data again on an existing Vendure installation, without needing to re-install from scratch. 1. `npm install --save-dev @vendure/create`. This installs the "create" package, which contains the test data we will need. 2. drop all tables from your database, but leave the actual database there. 3. create a script that looks like this: ```ts title="src/populate-test-data.ts" import { populate } from '@vendure/core/cli'; import { bootstrap, VendureConfig } from '@vendure/core'; import { config } from './vendure-config'; populate( () => bootstrap({ ...config, importExportOptions: { importAssetsDir: path.join( require.resolve('@vendure/create/assets/products.csv'), '../images' ), }, dbConnectionOptions: {...config.dbConnectionOptions, synchronize: true} }), require('@vendure/create/assets/initial-data.json'), require.resolve('@vendure/create/assets/products.csv') ) .then(app => app.close()) .catch(err => { console.log(err); process.exit(1); }); ``` Running this script will populate the database with the test data like when you first installed Vendure. ### Custom populate scripts If you require more control over how your data is being imported - for example if you also need to import data into custom entities, or import customer or order information - you can create your own CLI script to do this: see [Stand-Alone CLI Scripts](/guides/developer-guide/stand-alone-scripts/). In addition to all the services available in the [Service Layer](/guides/developer-guide/the-service-layer/), the following specialized import services are available: * [`ImportParser`](/reference/typescript-api/import-export/import-parser): Used to parse the CSV file into an array of objects. * [`FastImporterService`](/reference/typescript-api/import-export/fast-importer-service): Used to create new products & variants in bulk, optimized for speed. * [`Populator`](/reference/typescript-api/import-export/populator): Used to populate the initial data. * [`AssetImporter`](/reference/typescript-api/import-export/asset-importer): Creates new Assets in bulk, using the configured [`AssetImportStrategy`](/reference/typescript-api/import-export/asset-import-strategy). * [`Importer`](/reference/typescript-api/import-export/importer/): Uses all of the above services in combination - this is the basis of the `populate()` function described above. Using these specialized import services is preferable to using the normal service-layer services (`ProductService`, `ProductVariantService` etc.) for bulk imports. This is because these import services are optimized for bulk imports (they omit unnecessary checks, use optimized SQL queries) and also do not publish events when creating new entities. However, it is still possible to use the normal service-layer services if you prefer. For example, the following code snippet shows how to create a new ProductVariant using the `ProductVariantService`: ```ts title="src/create-new-variant-service.ts" import { INestApplicationContext } from '@nestjs/common'; import { ProductVariantService, TransactionalConnection, LanguageCode, RequestContext, RequestContextService, bootstrapWorker, ConfigService, ID, User, SearchService, } from '@vendure/core'; import { config } from './vendure-config'; async function createNewVariantService() { // We use the bootstrapWorker() function instead of bootstrap() because we don't // need to start the server, we just need access to the services. const { app } = await bootstrapWorker(config); // Most service methods require a RequestContext, so we'll create one here. const ctx = await getSuperadminContext(app); // Get the ProductVariantService instance from the application const productVariantService = app.get(ProductVariantService); // To reindex after importing products const searchService = app.get(SearchService); // Example: Creating a new ProductVariant for an existing product with ID 1 const productId = '1' as ID; // Create input data for the new variant const variantInput = { productId, translations: [ { languageCode: LanguageCode.en, name: 'New Variant 1', }, ], sku: 'NEW-VARIANT-001', // Specify additional variant properties... }; // Create the variant const newVariants = await productVariantService.create(ctx, [variantInput]); console.log('Created new product variants:', newVariants); // Rebuild search index to include the new variant await searchService.reindex(ctx); await app.close(); } /** * Creates a RequestContext configured for the default Channel with the activeUser set * as the superadmin user. */ export async function getSuperadminContext(app: INestApplicationContext): Promise { const {superadminCredentials} = app.get(ConfigService).authOptions; const superAdminUser = await app.get(TransactionalConnection) .getRepository(User) .findOneOrFail({where: {identifier: superadminCredentials.identifier}}); return app.get(RequestContextService).create({ apiType: 'admin', user: superAdminUser, }); } ``` ## Importing from other platforms If you are migrating from another platform, you can create a custom import script to import your data into Vendure. Your existing platform may provide an API which you can use to fetch the data, or it may provide a mechanism for exporting the data to a file. Therefore, you have a couple of options: 1. Export the data to a file, and then transform this into the Vendure CSV format for import as above. 2. Write a script which import the data via the other platform's API, and then import this data into Vendure using the services described above, or any other of the Vendure core services. The first option is the simplest, but may not be possible if the other platform does not provide a suitable export format. The second option is more complex, but allows for more flexibility and can be used to import data from any source, as well as allowing the import of other data such as customer and order information. As an illustrative example, let's imagine we are migrating away from an imaginary commerce platform, "OldCommerce", and we want to import our data into Vendure. Luckily, OldCommerce provides a client package which allows us to easily interact with their API. :::note This is a much-simplified example, but it should serve to illustrate the general approach. ::: ```ts title="src/import-from-other-platform.ts" import { INestApplicationContext } from '@nestjs/common'; import { bootstrapWorker, ConfigService, Importer, LanguageCode, ParsedProductWithVariants, RequestContext, RequestContextService, TransactionalConnection, User, SearchService, } from '@vendure/core'; import { createClient, OldCommerceProduct } from '@old-commerce/client'; import { config } from './vendure-config'; if (require.main === module) { importData().then( () => process.exit(0), err => { console.log(err); process.exit(1); }, ); } async function importData() { // We use the bootstrapWorker() function instead of bootstrap() because we don't // need to start the server, we just need access to the services. const {app} = await bootstrapWorker(config); // Create an instace of the client we'll be using to interact with the // OldCommerce API const client = createClient({ // OldCommerce client config }); // Let's grab a reference to each of the Vendure services we'll need. const importer = app.get(Importer); // Most service methods require a RequestContext, so we'll create one here. const ctx = await getSuperadminContext(app); // To reindex after importing products const searchService = app.get(SearchService); // Fetch all the products to import from the OldCommerce API const productsToImport: OldCommerceProduct[] = await client.getAllProducts(); // Transform the OldCommerce products into the format expected by the Importer const importRows: ParsedProductWithVariants[] = productsToImport.map(product => ({ product: { translations: [ { languageCode: LanguageCode.en, name: product.name, slug: product.slug, description: product.description, customFields: {}, }, ], assetPaths: product.images.map(image => image.sourceUrl), facets: [], optionGroups: product.options.map(option => ({ translations: [ { languageCode: LanguageCode.en, name: option.name, values: option.values.map(value => value.name), }, ], })), }, variants: product.variations.map(variation => { const optionValues = variation.options.map(option => option.value); return { sku: variation.productCode, price: variation.price, stockOnHand: variation.stock, translations: [{languageCode: LanguageCode.en, optionValues}], }; }), })); // Import the products await importer.importProducts(ctx, importRows, progress => { console.log(`Imported ${progress.imported} of ${importRows.length} products`); }); // Rebuild search index await searchService.reindex(ctx); // Close the app await app.close(); } /** * Creates a RequestContext configured for the default Channel with the activeUser set * as the superadmin user. */ export async function getSuperadminContext(app: INestApplicationContext): Promise { const {superadminCredentials} = app.get(ConfigService).authOptions; const superAdminUser = await app.get(TransactionalConnection) .getRepository(User) .findOneOrFail({where: {identifier: superadminCredentials.identifier}}); return app.get(RequestContextService).create({ apiType: 'admin', user: superAdminUser, }); } ``` --- --- title: "Logging" showtoc: true --- # Logging Logging allows you to see what is happening inside the Vendure server. It is useful for debugging and for monitoring the health of the server in production. In Vendure, logging is configured using the `logger` property of the [VendureConfig](/reference/typescript-api/configuration/vendure-config/#logger) object. The logger must implement the [VendureLogger](/reference/typescript-api/logger/vendure-logger) interface. :::info To implement a custom logger, see the [Implementing a custom logger](/reference/typescript-api/logger/#implementing-a-custom-logger) guide. ::: ## Log levels Vendure uses 5 log levels, in order of increasing severity: | Level | Description | |-----------|----------------------------------------------------------------------------------------------------------| | `Debug` | The most verbose level, used for debugging purposes. The output can be very noisy at this level | | `Verbose` | More information than the Info level, but less than `Debug` | | `Info` | General information about the normal running of the server | | `Warning` | Issues which might need attention or action, but which do not prevent the server from continuing to run. | | `Error` | Errors which should be investigated and handled; something has gone wrong. | ## DefaultLogger Vendure ships with a [DefaultLogger](/reference/typescript-api/logger/default-logger) which logs to the console (process.stdout). It can be configured with the desired log level: ```ts title="src/vendure-config.ts" import { DefaultLogger, VendureConfig } from '@vendure/core'; const config: VendureConfig = { // ... logger: new DefaultLogger({ level: LogLevel.Debug }), }; ``` ## Logging database queries To log database queries, set the `logging` property of the `dbConnectionOptions` as well as setting the logger to `Debug` level. ```ts title="src/vendure-config.ts" import { DefaultLogger, LogLevel, VendureConfig } from '@vendure/core'; const config: VendureConfig = { // ... logger: new DefaultLogger({ level: LogLevel.Debug }), dbConnectionOptions: { // ... etc logging: true, // You can also specify which types of DB events to log: // logging: ['error', 'warn', 'schema', 'query', 'info', 'log'], }, }; ``` More information about the `logging` option can be found in the [TypeORM logging documentation](https://typeorm.io/logging). ## Logging in your own plugins When you extend Vendure by creating your own plugins, it's a good idea to log useful information about what your plugin is doing. To do this, you need to import the [Logger](/reference/typescript-api/logger/) class from `@vendure/core` and use it in your plugin: ```ts title="src/plugins/my-plugin/my.plugin.ts" import { Logger } from '@vendure/core'; // It is customary to define a logger context for your plugin // so that the log messages can be easily identified. const loggerCtx = 'MyPlugin'; // somewhere in your code Logger.info(`My plugin is doing something!`, loggerCtx); ``` --- --- title: "Breaking API Changes" sidebar_position: 3 --- # Breaking API Changes ## Breaks from updated dependencies ### TypeScript - v2 is built on TypeScript **v4.9.5**. You should update your TypeScript version to match this. Doing so is quite likely to reveal new compiler errors (as is usual with TypeScript minor release updates). - If you are using `ts-node`, update it to the latest version - If you are targeting `ES2022` or `ESNEXT` in your `tsconfig.json`, you'll need to set `"useDefineForClassFields": false`. See [this issue](https://github.com/vendure-ecommerce/vendure/issues/2099) for more context. ### Apollo Server & GraphQL If you have any custom ApolloServerPlugins, the plugin methods must now return a Promise. Example: ```diff export class TranslateErrorsPlugin implements ApolloServerPlugin { constructor(private i18nService: I18nService) {} - requestDidStart(): GraphQLRequestListener { + async requestDidStart(): Promise { return { - willSendResponse: requestContext => { + willSendResponse: async requestContext => { const { errors, context } = requestContext; if (errors) { (requestContext.response as any).errors = errors.map(err => { return this.i18nService.translateError(context.req, err as GraphQLError) as any; }); } }, }; } } ``` With the update to GraphQL v16, you might run into issues with other packages in the GraphQL ecosystem that also depend on the `graphql` package, such as `graphql-code-generator`. In this case these packages will also need to be updated. For instance, if you are using the "typescript-compatibility" plugin to generate namespaced types, you'll need to drop this, as it is [no longer maintained](https://the-guild.dev/blog/whats-new-in-graphql-codegen-v2#typescript-compatibility). ### TypeORM TypeORM 0.3.x introduced a large number of breaking changes. For a complete guide, see the [TypeORM v0.3.0 release notes](https://github.com/typeorm/typeorm/releases/tag/0.3.0). Here are the main API changes you'll likely need to make: - You can no longer compare to `null`, you need to use the new `IsNull()` helper: ```diff + import { IsNull } from 'typeorm'; - .find({ where: { deletedAt: null } }) + .find({ where: { deletedAt: IsNull() } }) ``` - The `findOne()` method returns `null` rather than `undefined` if a record is not found. - The `findOne()` method no longer accepts an id argument. Lookup based on id must be done with a `where` clause: ```diff - .findOne(variantId) + .findOne({ where: { id: variantId } }) ``` - Where clauses must use an entity id rather than passing an entity itself: ```diff - .find({ where: { user } }) + .find({ where: { user: { id: user.id } } }) ``` - The `findByIds()` method has been deprecated. Use the new `In` helper instead: ```diff + import { In } from 'typeorm'; - .findByIds(ids) + .find({ where: { id: In(ids) } }) ``` ## Vendure TypeScript API Changes ### Custom Order / Fulfillment / Payment processes In v2, the hard-coded states & transition logic for the Order, Fulfillment and Payment state machines has been extracted from the core services and instead reside in a default `OrderProcess`, `FulfillmentProcess` and `PaymentProcess` object. This allows you to _fully_ customize these flows without having to work around the assumptions & logic implemented by the default processes. What this means is that if you are defining a custom process, you'll now need to explicitly add the default process to the array. ```diff + import { defaultOrderProcess } from '@vendure/core'; orderOptions: { - process: [myCustomOrderProcess], + process: [defaultOrderProcess, myCustomOrderProcess], } ``` Also note that `shippingOptions.customFulfillmentProcess` and `paymentOptions.customPaymentProcess` are both now renamed to `process`. The old names are still usable but are deprecated. ### OrderItem no longer exists As a result of [#1981](https://github.com/vendure-ecommerce/vendure/issues/1981), the `OrderItem` entity no longer exists. The function and data of `OrderItem` is now transferred to `OrderLine`. As a result, the following APIs which previously used OrderItem arguments have now changed: - `FulfillmentHandler` - `ChangedPriceHandlingStrategy` - `PromotionItemAction` - `TaxLineCalculationStrategy` If you have implemented any of these APIs, you'll need to check each one, remove the `OrderItem` argument from any methods that are using it, and update any logic as necessary. You may also be joining the OrderItem relation in your own TypeORM queries, so you'll need to check for code like this: ```diff const order = await this.connection .getRepository(Order) .createQueryBuilder('order') .leftJoinAndSelect('order.lines', 'line') - .leftJoinAndSelect('line.items', 'items') ``` or ```diff const order = await this.connection .getRepository(Order) .findOne(ctx, orderId, { - relations: ['lines', 'lines.items'], + relations: ['lines'], }); ``` ### ProductVariant stock changes With [#1545](https://github.com/vendure-ecommerce/vendure/issues/1545) we have changed the way we model stock levels in order to support multiple stock locations. This means that the `ProductVariant.stockOnHand` and `ProductVariant.stockAllocated` properties no longer exist on the `ProductVariant` entity in TypeScript. Instead, this information is now located at `ProductVariant.stockLevels`, which is an array of [StockLevel](/reference/typescript-api/entities/stock-level) entities. ### New return type for Channel, TaxCategory & Zone lists - The `ChannelService.findAll()` method now returns a `PaginatedList` instead of `Channel[]`. - The `channels` GraphQL query now returns a `PaginatedList` rather than a simple array of Channels. - The `TaxCategoryService.findAll()` method now returns a `PaginatedList` instead of `TaxCategory[]`. - The `taxCategories` GraphQL query now returns a `PaginatedList` rather than a simple array of TaxCategories. - The `ZoneService.findAll()` method now returns a `PaginatedList` instead of `Zone[]`. The old behaviour of `ZoneService.findAll()` (all Zones, cached for rapid access) can now be found under the new `ZoneService.getAllWithMembers()` method. - The `zones` GraphQL query now returns a `PaginatedList` rather than a simple array of Zones. ### Admin UI changes If you are using the `@vendure/ui-devkit` package to generate custom ui extensions, here are the breaking changes to be aware of: - As part of the major refresh to the Admin UI app, certain layout elements had be changed which can cause your custom routes to look bad. Wrapping all your custom pages in `` (or `
` if not built with Angular components) will improve things. There will soon be a comprehensive guide published on how to create seamless ui extensions that look just like the built-in screens. - If you use any of the scoped method of the Admin UI `DataService`, you might find that some no longer exist. They are now deprecated and will eventually be removed. Use the `dataService.query()` and `dataService.mutation()` methods only, passing your own GraphQL documents: ```ts // Old way this.dataService.product.getProducts().single$.subscribe(...); ``` ```ts // New way const GET_PRODUCTS = gql` query GetProducts { products { items { id name # ... etc } } } `; this.dataService.query(GET_PRODUCTS).single$.subscribe(...); ``` - The Admin UI component `vdr-product-selector` has been renamed to `vdr-product-variant-selector` to more accurately represent what it does. If you are using `vdr-product-selector` if any ui extensions code, update it to use the new selector. ### Other breaking API changes - **End-to-end tests using Jest** will likely run into issues due to our move towards using some dependencies that make use of ES modules. We have found the best solution to be to migrate tests over to [Vitest](https://vitest.dev), which can handle this and is also significantly faster than Jest. See the updated [Testing guide](/guides/developer-guide/testing) for instructions on getting started with Vitest. - Internal `ErrorResult` classes now take a single object argument rather than multiple args. - All monetary values are now represented in the GraphQL APIs with a new `Money` scalar type. If you use [graphql-code-generator](https://the-guild.dev/graphql/codegen), you'll want to tell it to treat this scalar as a number: ```ts import { CodegenConfig } from '@graphql-codegen/cli' const config: CodegenConfig = { schema: 'http://localhost:3000/shop-api', documents: ['src/**/*graphql.ts'], config: { scalars: { Money: 'number', }, }, generates: { // .. } }; ``` - A new `Region` entity has been introduced, which is a base class for `Country` and the new `Province` entity. The `Zone.members` property is now an array of `Region` rather than `Country`, since Zones may now be composed of both countries and provinces. If you have defined any custom fields on `Country`, you'll need to change it to `Region` in your custom fields config. - If you are using the **s3 storage strategy** of the AssetServerPlugin, it has been updated to use v3 of the AWS SDKs. This update introduces an [improved modular architecture to the AWS sdk](https://aws.amazon.com/blogs/developer/modular-packages-in-aws-sdk-for-javascript/), resulting in smaller bundle sizes. You need to install the `@aws-sdk/client-s3` & `@aws-sdk/lib-storage` packages, and can remove the `aws-sdk` package. If you are using it in combination with MinIO, you'll also need to rename a config property and provide a region: ```diff nativeS3Configuration: { endpoint: 'http://localhost:9000', - s3ForcePathStyle: true, + forcePathStyle: true, signatureVersion: 'v4', + region: 'eu-west-1', } ``` - The **Stripe plugin** has been made channel aware. This means your api key and webhook secret are now stored in the database, per channel, instead of environment variables. To migrate to v2 of the Stripe plugin from @vendure/payments you need to: 1. Remove the apiKey and webhookSigningSecret from the plugin initialization in vendure-config.ts: ```diff StripePlugin.init({ - apiKey: process.env.YOUR_STRIPE_SECRET_KEY, - webhookSigningSecret: process.env.YOUR_STRIPE_WEBHOOK_SIGNING_SECRET, storeCustomersInStripe: true, }), ``` 2. Start the server and login as administrator. For each channel that you'd like to use Stripe payments, you need to create a payment method with payment handler Stripe payment and the apiKey and webhookSigningSecret belonging to that channel's Stripe account. - If you are using the **BullMQJobQueuePlugin**, the minimum Redis recommended version is 6.2.0. - The `WorkerHealthIndicator` which was deprecated in v1.3.0 has been removed, as well as the `jobQueueOptions.enableWorkerHealthCheck` config option. - The `CustomerGroupEntityEvent` (fired on creation, update or deletion of a CustomerGroup) has been renamed to `CustomerGroupEvent`, and the former `CustomerGroupEvent` (fired when Customers are added to or removed from a group) has been renamed to `CustomerGroupChangeEvent`. - We introduced the [plugin compatibility API](/guides/developer-guide/plugins/#step-7-specify-compatibility) to allow plugins to indicate what version of Vendure they are compatible with. To avoid bootstrap messages you should add this property to your plugins. --- --- title: "Database Migration" sidebar_position: 2 --- # v2 Database Migration Vendure v2 introduces a number of breaking changes to the database schema, some of which require quite complex migrations in order to preserve existing data. To make this process as smooth as possible, we have created a migration tool which will handle the hard parts for you! :::warning **Important!** It is _critical_ that you back up your production data prior to attempting this migration. **Note for MySQL/MariaDB users:** transactions for migrations are [not supported by these databases](https://dev.mysql.com/doc/refman/5.7/en/cannot-roll-back.html). This means that if the migration fails for some reason, the statements that have executed will not get rolled back, and your DB schema can be left in an inconsistent state from which is it can be hard to recover. Therefore, it is doubly critical that you have a good backup that you can easily restore prior to attempting this migration. ::: 1. Make sure all your Vendure packages to the latest v2 versions. 2. Use your package manager to install the [v2 migration tool](https://github.com/vendure-ecommerce/v2-migration-tool): `npm install @vendure/migrate-v2` - Note, if you run into the error `"Cannot find module '@ardatan/aggregate-error'"`, delete node_modules and the lockfile and reinstall. 3. Add the `MigrationV2Plugin` to your plugins array: ```ts import { MigrationV2Plugin } from '@vendure/migrate-v2'; //... const config: VendureConfig = { //.. plugins: [ MigrationV2Plugin, ] } ``` The sole function of this plugin is to temporarily remove some "NOT NULL" constraints from certain columns, which allows us to run the next part of the migration. 4. Generate a new migration file, `npm run migration:generate v2` 5. Edit the newly-created migration file by following the comments in these examples: - [postgres](https://github.com/vendure-ecommerce/v2-migration-tool/blob/master/src/migrations/1686649098749-v201-postgres.ts) - [mysql](https://github.com/vendure-ecommerce/v2-migration-tool/blob/master/src/migrations/1686655918823-v201-mysql.ts) In your migrations files, you'll import the `vendureV2Migrations` from `@vendure/migrate-v2`. 6. Run the migration with `npm run migration:run`. 7. Upon successful migration, remove the `MigrationV2Plugin` from your plugins array, and generate _another_ migration. This one will add back the missing "NOT NULL" constraints now that all your data has been successfully migrated. --- --- title: "Migrating from v1" weight: 2 sidebar_position: 1 --- # Migrating from Vendure 1 to 2 This section contains guides for migrating from Vendure v1 to v2. There are a number of breaking changes between the two versions, which are due to a few major factors: 1. To support new features such as multi-vendor marketplace APIs and multiple stock locations, we had to make some changes to the database schema and some of the internal APIs. 2. We have updated all of our major dependencies to their latest versions. Some of these updates involve breaking changes in the dependencies themselves, and in those cases where you are using those dependencies directly (most notably TypeORM), you will need to make the corresponding changes to your code. 3. We have removed some old APIs which were previously marked as "deprecated". ## Migration steps Migration will consist of these main steps: 1. **Update your Vendure dependencies** to the latest versions ```diff { // ... "dependencies": { - "@vendure/common": "1.9.7", - "@vendure/core": "1.9.7", + "@vendure/common": "2.0.0", + "@vendure/core": "2.0.0", // etc. }, "devDependencies": { - "typescript": "4.3.5", + "typescript": "4.9.5", // etc. } } ``` 2. **Migrate your database**. This is covered in detail in the [database migration section](/guides/developer-guide/migrating-from-v1/database-migration). 3. **Update your custom code** (configuration, plugins, admin ui extensions) to handle the breaking changes. Details of these changes are covered in the [breaking API changes section](/guides/developer-guide/migrating-from-v1/breaking-api-changes). 4. **Update your storefront** to handle some small breaking changes in the Shop GraphQL API. See the [storefront migration section](/guides/developer-guide/migrating-from-v1/storefront-migration) for details. --- --- title: "Storefront Migration" sidebar_position: 4 --- # Storefront migration There are relatively few breaking changes that will affect the storefront. - The `setOrderShippingMethod` mutation now takes an array of shipping method IDs rather than just a single one. This is so we can support multiple shipping methods per Order. ```diff -mutation setOrderShippingMethod($shippingMethodId: ID!) { +mutation setOrderShippingMethod($shippingMethodId: [ID!]!) { setOrderShippingMethod(shippingMethodId: $shippingMethodId) { # ... etc } } ``` - The `OrderLine.fulfillments` field has been changed to `OrderLine.fulfillmentLines`. Your storefront may be using this when displaying the details of an Order. - If you are using the `graphql-code-generator` package to generate types for your storefront, all monetary values such as `Order.totalWithTax` or `ProductVariant.priceWithTax` are now represented by the new `Money` scalar rather than by an `Int`. You'll need to tell your codegen about this scalar and configure it to be interpreted as a number type: ```diff documents: - "app/**/*.{ts,tsx}" - "!app/generated/*" +config: + scalars: + Money: number generates: # ... etc ``` --- --- title: "Migrations" --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; Database migrations are needed whenever the database schema changes. This can be caused by: * changes to the [custom fields](/guides/developer-guide/custom-fields/) configuration * new [database entities defined by plugins](/guides/developer-guide/database-entity/) * occasional changes to the core Vendure database schema when updating to newer versions ## Synchronize vs migrate TypeORM (which Vendure uses to interact with the database) has a `synchronize` option which, when set to `true`, will automatically update your database schema to reflect the current Vendure configuration. This is equivalent to automatically generating and running a migration _every time_ the server starts up. This is convenient while developing, but **should not be used in production**, since a misconfiguration could potentially delete production data. In this case, migrations should be used. ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... dbConnectionOptions: { // ... // highlight-next-line synchronize: false, } }; ``` ## Migration workflow This section assumes a standard Vendure installation based on `@vendure/create`. Let's assume you have defined a new "keywords" custom field on the Product entity. The next time you start your server you'll see a message like this: ```bash [server] Your database schema does not match your current configuration. Generate a new migration for the following changes: [server] - ALTER TABLE "product" ADD "customFieldsKeywords" character varying(255) ``` Since we have `synchronize` set to `false`, we need to generate a migration to apply these changes to the database. The workflow for this is as follows: ### 1. Generate a migration :::cli Run `npx vendure migrate` and select "Generate a new migration" ::: ### 2. Check the migration file This will have created a new migration file in the `src/migrations` directory. Open this file and check that it looks correct. It should look something like this: ```ts title="src/migrations/1690558104092-add-keywords-field.ts" import {MigrationInterface, QueryRunner} from "typeorm"; export class addKeywordsField1690558104092 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "product" ADD "customFieldsKeywords" character varying(255)`, undefined); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "product" DROP COLUMN "customFieldsKeywords"`, undefined); } } ``` The `up()` function is what will be executed when the migration is run. The `down()` function is what will be executed if the migration is reverted. In this case, the `up()` function is adding a new column to the `product` table, and the `down()` function is removing it. :::note The exact query will depend on the database you are using. The above example is for PostgreSQL. ::: ### 3. Run the migration Assuming the migration file looks correct, the next time you start the server, the migration will be run automatically. This is because the `runMigrations` function is called in the `src/index.ts` file: ```ts title="src/index.ts" import { bootstrap, runMigrations } from '@vendure/core'; import { config } from './vendure-config'; // highlight-next-line runMigrations(config) .then(() => bootstrap(config)) .catch(err => { console.log(err); }); ``` It is also possible to run the migration manually without starting the server: :::cli Run `npx vendure migrate` and select "Run pending migrations" ::: :::caution TypeORM will attempt to run each migration inside a transaction. This means that if one of the migration commands fails, then the entire transaction will be rolled back to its original state. _However_ this is **not supported by MySQL / MariaDB**. This means that when using MySQL or MariaDB, errors in your migration script could leave your database in a broken or inconsistent state. Therefore it is **critical** that you first create a backup of your database before running a migration. You can read more about this issue in [typeorm/issues/7054](https://github.com/typeorm/typeorm/issues/7054) ::: ## Migrations in-depth Now we'll dive into what's going on under the hood. Vendure exposes a some helper function which wrap around the underlying [TypeORM migration functionality](https://typeorm.io/migrations). The reason for using these helper functions rather than using the TypeORM CLI directly is that Vendure generates additional schema information based on custom fields and plugin configurations which are not available to the TypeORM CLI. In a standard Vendure installation prior to v2.2.0, you'll see the following migration script in your project root directory. Running the `vendure migrate` command also uses a very similar script internally. ```ts title="migration.ts" import { generateMigration, revertLastMigration, runMigrations } from '@vendure/core'; import { Command } from 'commander'; import { config } from './src/vendure-config'; const program = new Command(); program .command('generate ') .description('Generate a new migration file with the given name') .action(name => { return generateMigration(config, { name, outputDir: './src/migrations' }); }); program .command('run') .description('Run all pending migrations') .action(() => { return runMigrations(config); }); program .command('revert') .description('Revert the last applied migration') .action(() => { return revertLastMigration(config); }); program.parse(process.argv); ``` and a set of scripts in your `package.json` file: ```json { // ... "scripts": { "migration:generate": "ts-node migration.ts generate", "migration:run": "ts-node migration.ts run", "migration:revert": "ts-node migration.ts revert" } } ``` When running and reverting migrations, Vendure is looking for migration files in the directory specified by the `dbConnectionOptions.migrations` option is set in your VendureConfig: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import path from 'path'; export const config: VendureConfig = { // ... dbConnectionOptions: { // ... // highlight-next-line migrations: [path.join(__dirname, './migrations/*.+(js|ts)')], } }; ``` TypeORM keeps track of which migrations have been run by creating a new `migrations` table in your database, and each time a migration is successfully run it adds a row to this table with the name of the migration class and a timestamp. This prevents the same migration from being run twice, and also allows TypeORM to know which migration to revert when the `revertLastMigration` function is called. ![Migrations table](./migration.webp) These are the underlying function exposed by Vendure which are used to generate, run and revert migrations: - [`generateMigration` function](/reference/typescript-api/migration/generate-migration/) - [`runMigrations` function](/reference/typescript-api/migration/run-migrations/) - [`revertLastMigration` function](/reference/typescript-api/migration/revert-last-migration/) ### Reverting a migration The `revertLastMigration` function will revert the last applied migration by applying the `down()` method. If run again it will then revert the one before that, and so on. In doing so, it will also remove the corresponding row from the `migrations` table. --- --- title: "Nest Devtools" --- The NestJS core team have built a [powerful set of dev tools](https://docs.nestjs.com/devtools/overview) which can be used to inspect, analyze and debug NestJS applications. Since a Vendure server is a NestJS application, these tools can be used to debug your Vendure application. :::note Nest Devtools is a paid service. You can [sign up for a free trial](https://devtools.nestjs.com/). ::: ## Installation First you'll need to install the `@nestjs/devtools-integration` package: ```bash npm i @nestjs/devtools-integration ``` ## Configuration Next you need to create a plugin which imports the `DevToolsModule` and adds it to the `imports` array: ```ts title="src/plugins/devtools/devtools-plugin.ts" import { VendurePlugin } from '@vendure/core'; import { DevtoolsModule } from '@nestjs/devtools-integration'; @VendurePlugin({ imports: [ DevtoolsModule.register({ // The reason we are checking the NODE_ENV environment // variable here is that you should never use this module in production! http: process.env.NODE_ENV !== 'production', }), ], }) class DevtoolsPlugin {} ``` Now we need to add this plugin to the `plugins` array in the `VendureConfig`. We need to make sure we are only adding it to the server config, and not the worker, otherwise we will get a port config when running the server and worker at the same time. Lastly we must set the `snapshot` option when bootstrapping the server. Note: this is only possible with Vendure v2.2 or later. ```ts title="src/index.ts" import { bootstrap } from '@vendure/core'; import { config } from './vendure-config'; const configWithDevtools = { ...config, plugins: [ ...config.plugins, DevtoolsPlugin, ], }; bootstrap(configWithDevtools, { nestApplicationOptions: { snapshot: true } }) .catch(err => { console.log(err); process.exit(1); }); ``` ## Usage Now you can start the server, and navigate to [devtools.nestjs.com](https://devtools.nestjs.com/) to start view your Vendure server in the Nest Devtools dashboard. ![Nest Devtools graphql explorer](./nest-devtools-graph.webp) ![Nest Devtools bootstrap performance](./nest-devtools-bootstrap-perf.webp) --- --- title: "Vendure Overview" sidebar_position: 2 --- Read this page to gain a high-level understanding of Vendure and concepts you will need to know to build your application. ## Architecture Vendure is a headless e-commerce platform. By "headless" we mean that it exposes all of its functionality via APIs. Specifically, Vendure features two GraphQL APIs: one for storefronts (Shop API) and the other for administrative functions (Admin API). These are the major parts of a Vendure application: * **Server**: The Vendure server is the part that handles requests coming in to the GraphQL APIs. It serves both the [Shop API](/reference/graphql-api/shop/queries) and [Admin API](/reference/graphql-api/admin/queries), and can send jobs to the Job Queue to be processed by the Worker. * **Worker**: The Worker runs in the background and deals with tasks such as updating the search index, sending emails, and other tasks which may be long-running, resource-intensive or require retries. * **Admin UI**: The Admin UI is how shop administrators manage orders, customers, products, settings and so on. It is not actually part of the Vendure core, but is provided as a plugin (the [AdminUiPlugin](/reference/core-plugins/admin-ui-plugin/)) which is installed for you in a standard Vendure installation. The Admin UI can be further extended to support custom functionality, as detailed in the [Extending the Admin UI](/guides/extending-the-admin-ui/getting-started/) section * **Storefront**: With headless commerce, you are free to implement your storefront exactly as you see fit, unconstrained by the back-end, using any technologies that you like. To make this process easier, we have created a number of [storefront starter kits](/guides/storefront/storefront-starters/), as well as [guides on building a storefront](/guides/storefront/connect-api/). ![./Vendure_docs-architecture.webp](./Vendure_docs-architecture.webp) ## Technology stack Vendure is built on the following open-source technologies: - **SQL Database**: Vendure requires an SQL database compatible with [TypeORM](https://typeorm.io/). Officially we support **PostgreSQL**, **MySQL/MariaDB** and **SQLite** but Vendure can also be used with API-compatible variants such [Amazon Aurora](https://aws.amazon.com/rds/aurora/), [CockroachDB](https://www.cockroachlabs.com/), or [PlanetScale](https://planetscale.com/). - **TypeScript & Node.js**: Vendure is written in [TypeScript](https://www.typescriptlang.org/) and runs on [Node.js](https://nodejs.org). - **NestJS**: The underlying framework is [NestJS](https://nestjs.com/), which is a full-featured application development framework for Node.js. Building on NestJS means that Vendure benefits from the well-defined structure and rich feature-set and ecosystem that NestJS provides. - **GraphQL**: The Shop and Admin APIs use [GraphQL](https://graphql.org/), which is a modern API technology which allows you to specify the exact data that your client application needs in a convenient and type-safe way. Internally we use [Apollo Server](https://www.apollographql.com/docs/apollo-server/) to power our GraphQL APIs. - **Angular**: The Admin UI is built with [Angular](https://angular.io/), a popular, stable application framework from Google. Note that you do not need to know Angular to use Vendure, and UI extensions can even be written in the front-end framework of your choice, such as React or Vue. ## Design principles Vendure is designed to be: - **Flexible**: Vendure is designed to be flexible enough to support a wide range of e-commerce use-cases, while taking care of the common functionality for you. It is not a "one-size-fits-all" solution, but rather a framework which you can extend and customize to suit your needs. - **Extensible**: A typical e-commerce application needs to integrate with many external systems for payments, shipping, inventory management, email sending, and so on. Vendure makes heavy use of the **strategy pattern** - a software design pattern which allows you to replace default behaviors with your own custom implementations as needed. - **Modular**: Vendure is built with a modular architecture, where each unit of functionality of your application is encapsulated in a **plugin**. This makes it easy to add or remove functionality as needed, and to share plugins with the community. - **Type-safe**: Vendure is written in TypeScript, which means that you get the benefits of static type-checking and code completion in your IDE. Our use of GraphQL for our APIs brings static typing to the API layer, enabling rapid development with type-safety across the entire stack. --- --- title: 'Plugins' sidebar_position: 6 --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; The heart of Vendure is its plugin system. Plugins not only allow you to instantly add new functionality to your Vendure server via third-part npm packages, they are also the means by which you build out the custom business logic of your application. Plugins in Vendure allow one to: - Modify the VendureConfig object, such as defining custom fields on existing entities. - Extend the GraphQL APIs, including modifying existing types and adding completely new queries and mutations. - Define new database entities and interact directly with the database. - Interact with external systems that you need to integrate with. - Respond to events such as new orders being placed. - Trigger background tasks to run on the worker process. … and more! In a typical Vendure application, custom logic and functionality is implemented as a set of plugins which are usually independent of one another. For example, there could be a plugin for each of the following: wishlists, product reviews, loyalty points, gift cards, etc. This allows for a clean separation of concerns and makes it easy to add or remove functionality as needed. ## Core Plugins Vendure provides a set of core plugins covering common functionality such as assets handling, email sending, and search. For documentation on these, see the [Core Plugins reference](/reference/core-plugins/). ## Plugin basics Here's a bare-minimum example of a plugin: ```ts title="src/plugins/avatar-plugin/avatar.plugin.ts" import { LanguageCode, PluginCommonModule, VendurePlugin } from '@vendure/core'; @VendurePlugin({ imports: [PluginCommonModule], configuration: config => { config.customFields.Customer.push({ type: 'string', name: 'avatarUrl', label: [{ languageCode: LanguageCode.en, value: 'Avatar URL' }], list: true, }); return config; }, }) export class AvatarPlugin {} ``` This plugin does one thing only: it adds a new custom field to the `Customer` entity. The plugin is then imported into the `VendureConfig`: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { AvatarPlugin } from './plugins/avatar-plugin/avatar.plugin'; export const config: VendureConfig = { // ... // highlight-next-line plugins: [AvatarPlugin], }; ``` The key feature is the `@VendurePlugin()` decorator, which marks the class as a Vendure plugin and accepts a configuration object on the type [`VendurePluginMetadata`](/reference/typescript-api/plugin/vendure-plugin-metadata/). A VendurePlugin is actually an enhanced version of a [NestJS Module](https://docs.nestjs.com/modules), and supports all the metadata properties that NestJS modules support: - `imports`: Allows importing other NestJS modules in order to make use of their exported providers. - `providers`: The providers (services) that will be instantiated by the Nest injector and that may be shared across this plugin. - `controllers`: Controllers allow the plugin to define REST-style endpoints. - `exports`: The providers which will be exported from this plugin and made available to other plugins. Additionally, the `VendurePlugin` decorator adds the following Vendure-specific properties: - `configuration`: A function which can modify the `VendureConfig` object before the server bootstraps. - `shopApiExtensions`: Allows the plugin to extend the GraphQL Shop API with new queries, mutations, resolvers & scalars. - `adminApiExtensions`: Allows the plugin to extend the GraphQL Admin API with new queries, mutations, resolvers & scalars. - `entities`: Allows the plugin to define new database entities. - `compatibility`: Allows the plugin to declare which versions of Vendure it is compatible with. :::info Since a Vendure plugin is a superset of a NestJS module, this means that many NestJS modules are actually valid Vendure plugins! ::: ## Plugin lifecycle Since a VendurePlugin is built on top of the NestJS module system, any plugin (as well as any providers it defines) can make use of any of the [NestJS lifecycle hooks](https://docs.nestjs.com/fundamentals/lifecycle-events): - onModuleInit - onApplicationBootstrap - onModuleDestroy - beforeApplicationShutdown - onApplicationShutdown :::caution Note that lifecycle hooks are run in both the server and worker contexts. If you have code that should only run either in the server context or worker context, you can inject the [ProcessContext provider](/reference/typescript-api/common/process-context/). ::: ### Configure Another hook that is not strictly a lifecycle hook, but which can be useful to know is the [`configure` method](https://docs.nestjs.com/middleware#applying-middleware) which is used by NestJS to apply middleware. This method is called _only_ for the server and _not_ for the worker, since middleware relates to the network stack, and the worker has no network part. ```ts import { MiddlewareConsumer, NestModule } from '@nestjs/common'; import { EventBus, PluginCommonModule, VendurePlugin } from '@vendure/core'; import { MyMiddleware } from './api/my-middleware'; @VendurePlugin({ imports: [PluginCommonModule] }) export class MyPlugin implements NestModule { configure(consumer: MiddlewareConsumer) { consumer .apply(MyMiddleware) .forRoutes('my-custom-route'); } } ``` ## Create a Plugin via CLI :::cli Run the `npx vendure add` command, and select "Create a new Vendure plugin". This will guide you through the creation of a new plugin and automate all aspects of the process. This is the recommended way of creating a new plugin. ::: ## Writing a plugin from scratch Although the [Vendure CLI](/guides/developer-guide/cli/) is the recommended way to create a new plugin, it can be useful to understand the process of creating a plugin manually. Vendure **plugins** are used to extend the core functionality of the server. Plugins can be pre-made functionality that you can install via npm, or they can be custom plugins that you write yourself. For any unit of functionality that you need to add to your project, you'll be creating a Vendure plugin. By convention, plugins are stored in the `plugins` directory of your project. However, this is not a requirement, and you are free to arrange your plugin files in any way you like. ```txt ├──src ├── index.ts ├── vendure-config.ts ├── plugins ├── reviews-plugin ├── cms-plugin ├── wishlist-plugin ├── stock-sync-plugin ``` :::info For a complete working example of a Vendure plugin, see the [real-world-vendure Reviews plugin](https://github.com/vendure-ecommerce/real-world-vendure/tree/master/src/plugins/reviews) You can also use the [Vendure CLI](/guides/developer-guide/cli) to quickly scaffold a new plugin. ::: In this guide, we will implement a simple but fully-functional **wishlist plugin** step-by-step. The goal of this plugin is to allow signed-in customers to add products to a wishlist, and to view and manage their wishlist. ### Step 1: Create the plugin file We'll start by creating a new directory to house our plugin, add create the main plugin file: ```txt ├──src ├── index.ts ├── vendure-config.ts ├── plugins // highlight-next-line ├── wishlist-plugin // highlight-next-line ├── wishlist.plugin.ts ``` ```ts title="src/plugins/wishlist-plugin/wishlist.plugin.ts" import { PluginCommonModule, VendurePlugin } from '@vendure/core'; @VendurePlugin({ imports: [PluginCommonModule], }) export class WishlistPlugin {} ``` The `PluginCommonModule` will be required in all plugins that you create. It contains the common services that are exposed by Vendure Core, allowing you to inject them into your plugin's services and resolvers. ### Step 2: Define an entity Next we will define a new database entity to store the wishlist items. Vendure uses [TypeORM](https://typeorm.io/) to manage the database schema, and an Entity corresponds to a database table. First let's create the file to house the entity: ```txt ├── wishlist-plugin ├── wishlist.plugin.ts ├── entities // highlight-next-line ├── wishlist-item.entity.ts ``` By convention, we'll store the entity definitions in the `entities` directory of the plugin. Again, this is not a requirement, but it is a good way to keep your plugin organized. ```ts title="src/plugins/wishlist-plugin/entities/wishlist-item.entity.ts" import { DeepPartial, ID, ProductVariant, VendureEntity, EntityId } from '@vendure/core'; import { Entity, ManyToOne } from 'typeorm'; @Entity() export class WishlistItem extends VendureEntity { constructor(input?: DeepPartial) { super(input); } @ManyToOne(type => ProductVariant) productVariant: ProductVariant; @EntityId() productVariantId: ID; } ``` Let's break down what's happening here: - The `WishlistItem` entity extends the [`VendureEntity` class](/reference/typescript-api/entities/vendure-entity/). This is a base class which provides the `id`, `createdAt` and `updatedAt` fields, and all custom entities should extend it. - The `@Entity()` decorator marks this class as a TypeORM entity. - The `@ManyToOne()` decorator defines a many-to-one relationship with the `ProductVariant` entity. This means that each `WishlistItem` will be associated with a single `ProductVariant`. - The `productVariantId` column is not strictly necessary, but it allows us to always have access to the ID of the related `ProductVariant` without having to load the entire `ProductVariant` entity from the database. - The `constructor()` is used to create a new instance of the entity. This is not strictly necessary, but it is a good practice to define a constructor which takes a `DeepPartial` of the entity as an argument. This allows us to create new instances of the entity using the `new` keyword, passing in a plain object with the desired properties. Next we need to register this entity with our plugin: ```ts title="src/plugins/wishlist-plugin/wishlist.plugin.ts" import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { WishlistItem } from './entities/wishlist-item.entity'; @VendurePlugin({ imports: [PluginCommonModule], entities: [WishlistItem], }) export class WishlistPlugin {} ``` ### Step 3: Add a custom field to the Customer entity We'll now define a new custom field on the Customer entity which will store a list of WishlistItems. This will allow us to easily query for all wishlist items associated with a particular customer. Custom fields are defined in the VendureConfig object, and in a plugin we use the `configuration` function to modify the config object: ```ts title="src/plugins/wishlist-plugin/wishlist.plugin.ts" import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { WishlistItem } from './entities/wishlist-item.entity'; @VendurePlugin({ imports: [PluginCommonModule], entities: [WishlistItem], configuration: config => { config.customFields.Customer.push({ name: 'wishlistItems', type: 'relation', list: true, entity: WishlistItem, internal: true, }); return config; }, }) export class WishlistPlugin {} ``` In this snippet we are pushing a new custom field definition onto the `Customer` entity's `customFields` array, and defining this new field as a list (array) of `WishlistItem` entities. Internally, this will tell TypeORM to update the database schema to store this new field. We set `internal: true` to indicate that this field should not be directly exposed to the GraphQL API as `Customer.customFields.wishlistItems`, but instead should be accessed via a custom resolver we will define later. In order to make use of this custom field in a type-safe way, we can tell TypeScript about this field in a new file: ```txt ├── wishlist-plugin ├── wishlist.plugin.ts // highlight-next-line ├── types.ts ``` ```ts title="src/plugins/wishlist-plugin/types.ts" import { WishlistItem } from './entities/wishlist-item.entity'; declare module '@vendure/core/dist/entity/custom-entity-fields' { interface CustomCustomerFields { wishlistItems: WishlistItem[]; } } ``` We can then import this types file in our plugin's main file: ```ts title="src/plugins/wishlist-plugin/wishlist.plugin.ts" // highlight-next-line import './types'; ``` :::note Custom fields are not solely restricted to Vendure's native entities though, it's also possible to add support for custom fields to your own custom entities. This way other plugins would be able to extend our example `WishlistItem`. See: [Supporting custom fields](/guides/developer-guide/database-entity/#supporting-custom-fields) ::: ### Step 4: Create a service A "service" is a class which houses the bulk of the business logic of any plugin. A plugin can define multiple services if needed, but each service should be responsible for a single unit of functionality, such as dealing with a particular entity, or performing a particular task. Let's create a service to handle the wishlist functionality: ```txt ├── wishlist-plugin ├── wishlist.plugin.ts ├── services // highlight-next-line ├── wishlist.service.ts ``` ```ts title="src/plugins/wishlist-plugin/services/wishlist.service.ts" import { Injectable } from '@nestjs/common'; import { Customer, ForbiddenError, ID, InternalServerError, ProductVariantService, RequestContext, TransactionalConnection, UserInputError, } from '@vendure/core'; import { WishlistItem } from '../entities/wishlist-item.entity'; @Injectable() export class WishlistService { constructor( private connection: TransactionalConnection, private productVariantService: ProductVariantService, ) {} async getWishlistItems(ctx: RequestContext): Promise { try { const customer = await this.getCustomerWithWishlistItems(ctx); return customer.customFields.wishlistItems; } catch (err: any) { return []; } } /** * Adds a new item to the active Customer's wishlist. */ async addItem(ctx: RequestContext, variantId: ID): Promise { const customer = await this.getCustomerWithWishlistItems(ctx); const variant = await this.productVariantService.findOne(ctx, variantId); if (!variant) { throw new UserInputError(`No ProductVariant with the id ${variantId} could be found`); } const existingItem = customer.customFields.wishlistItems.find(i => i.productVariantId === variantId); if (existingItem) { // Item already exists in wishlist, do not // add it again return customer.customFields.wishlistItems; } const wishlistItem = await this.connection .getRepository(ctx, WishlistItem) .save(new WishlistItem({ productVariantId: variantId })); customer.customFields.wishlistItems.push(wishlistItem); await this.connection.getRepository(ctx, Customer).save(customer, { reload: false }); return this.getWishlistItems(ctx); } /** * Removes an item from the active Customer's wishlist. */ async removeItem(ctx: RequestContext, itemId: ID): Promise { const customer = await this.getCustomerWithWishlistItems(ctx); const itemToRemove = customer.customFields.wishlistItems.find(i => i.id === itemId); if (itemToRemove) { await this.connection.getRepository(ctx, WishlistItem).remove(itemToRemove); customer.customFields.wishlistItems = customer.customFields.wishlistItems.filter( i => i.id !== itemId, ); } await this.connection.getRepository(ctx, Customer).save(customer); return this.getWishlistItems(ctx); } /** * Gets the active Customer from the context and loads the wishlist items. */ private async getCustomerWithWishlistItems(ctx: RequestContext): Promise { if (!ctx.activeUserId) { throw new ForbiddenError(); } const customer = await this.connection.getRepository(ctx, Customer).findOne({ where: { user: { id: ctx.activeUserId } }, relations: { customFields: { wishlistItems: { productVariant: true, }, }, }, }); if (!customer) { throw new InternalServerError(`Customer was not found`); } return customer; } } ``` Let's break down what's happening here: - The `WishlistService` class is decorated with the `@Injectable()` decorator. This is a standard NestJS decorator which tells the NestJS dependency injection (DI) system that this class can be injected into other classes. All your services should be decorated with this decorator. - The arguments passed to the constructor will be injected by the NestJS DI system. The `connection` argument is a [TransactionalConnection](/reference/typescript-api/data-access/transactional-connection/) instance, which is used to access and manipulate data in the database. The [`ProductVariantService`](/reference/typescript-api/services/product-variant-service/) argument is a built-in Vendure service which contains methods relating to ProductVariants. - The [`RequestContext`](/reference/typescript-api/request/request-context/) object is usually the first argument to any service method, and contains information and context about the current request as well as any open database transactions. It should always be passed to the methods of the `TransactionalConnection`. The service is then registered with the plugin metadata as a provider: ```ts title="src/plugins/wishlist-plugin/wishlist.plugin.ts" import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { WishlistService } from './services/wishlist.service'; @VendurePlugin({ imports: [PluginCommonModule], // highlight-next-line providers: [WishlistService], entities: [WishlistItem], configuration: config => { // ... }, }) export class WishlistPlugin {} ``` ### Step 5: Extend the GraphQL API This plugin will need to extend the Shop API, adding new mutations and queries to enable the customer to view and manage their wishlist. First we will create a new file to hold the GraphQL schema extensions: ```txt ├── wishlist-plugin ├── wishlist.plugin.ts ├── api // highlight-next-line ├── api-extensions.ts ``` ```ts title="src/plugins/wishlist-plugin/api/api-extensions.ts" import gql from 'graphql-tag'; export const shopApiExtensions = gql` type WishlistItem implements Node { id: ID! createdAt: DateTime! updatedAt: DateTime! productVariant: ProductVariant! productVariantId: ID! } extend type Query { activeCustomerWishlist: [WishlistItem!]! } extend type Mutation { addToWishlist(productVariantId: ID!): [WishlistItem!]! removeFromWishlist(itemId: ID!): [WishlistItem!]! } `; ``` :::note The `graphql-tag` package is a dependency of the Vendure core package. Depending on the package manager you are using, you may need to install it separately with `yarn add graphql-tag` or `npm install graphql-tag`. ::: The `api-extensions.ts` file is where we define the extensions we will be making to the Shop API GraphQL schema. We are defining a new `WishlistItem` type; a new query: `activeCustomerWishlist`; and two new mutations: `addToWishlist` and `removeFromWishlist`. This definition is written in [schema definition language](https://graphql.org/learn/schema/) (SDL), a convenient syntax for defining GraphQL schemas. Next we need to pass these extensions to our plugin's metadata: ```ts title="src/plugins/wishlist-plugin/wishlist.plugin.ts" import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { shopApiExtensions } from './api/api-extensions'; @VendurePlugin({ imports: [PluginCommonModule], shopApiExtensions: { schema: shopApiExtensions, resolvers: [], }, }) export class WishlistPlugin {} ``` ### Step 6: Create a resolver Now that we have defined the GraphQL schema extensions, we need to create a resolver to handle the new queries and mutations. A resolver in GraphQL is a function which actually implements the query or mutation defined in the schema. This is done by creating a new file in the `api` directory: ```txt ├── wishlist-plugin ├── wishlist.plugin.ts ├── api ├── api-extensions.ts // highlight-next-line ├── wishlist.resolver.ts ``` ```ts title="src/plugins/wishlist-plugin/api/wishlist.resolver.ts" import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { Allow, Ctx, Permission, RequestContext, Transaction } from '@vendure/core'; import { WishlistService } from '../services/wishlist.service'; @Resolver() export class WishlistShopResolver { constructor(private wishlistService: WishlistService) {} @Query() @Allow(Permission.Owner) async activeCustomerWishlist(@Ctx() ctx: RequestContext) { return this.wishlistService.getWishlistItems(ctx); } @Mutation() @Transaction() @Allow(Permission.Owner) async addToWishlist( @Ctx() ctx: RequestContext, @Args() { productVariantId }: { productVariantId: string }, ) { return this.wishlistService.addItem(ctx, productVariantId); } @Mutation() @Transaction() @Allow(Permission.Owner) async removeFromWishlist(@Ctx() ctx: RequestContext, @Args() { itemId }: { itemId: string }) { return this.wishlistService.removeItem(ctx, itemId); } } ``` Resolvers are usually "thin" functions that delegate the actual work to a service. Vendure, like NestJS itself, makes heavy use of decorators at the API layer to define various aspects of the resolver. Let's break down what's happening here: - The `@Resolver()` decorator tells the NestJS DI system that this class is a resolver. Since a Resolver is part of the NestJS DI system, we can also inject dependencies into its constructor. In this case we are injecting the `WishlistService` which we created in the previous step. - The `@Mutation()` decorator tells Vendure that this is a mutation resolver. Similarly, `@Query()` decorator defines a query resolver. The name of the method is the name of the query or mutation in the schema. - The `@Transaction()` decorator tells Vendure that this resolver method should be wrapped in a database transaction. This is important because we are performing multiple database operations in this method, and we want them to be atomic. - The `@Allow()` decorator tells Vendure that this mutation is only allowed for users with the `Owner` permission. The `Owner` permission is a special permission which indicates that the active user should be the owner of this operation. - The `@Ctx()` decorator tells Vendure that this method requires access to the `RequestContext` object. Every resolver should have this as the first argument, as it is required throughout the Vendure request lifecycle. This resolver is then registered with the plugin metadata: ```ts title="src/plugins/wishlist-plugin/wishlist.plugin.ts" import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { shopApiExtensions } from './api/api-extensions'; import { WishlistShopResolver } from './api/wishlist.resolver'; @VendurePlugin({ imports: [PluginCommonModule], shopApiExtensions: { schema: shopApiExtensions, // highlight-next-line resolvers: [WishlistShopResolver], }, configuration: config => { // ... }, }) export class WishlistPlugin {} ``` :::info More information about resolvers can be found in the [NestJS docs](https://docs.nestjs.com/graphql/resolvers). ::: ### Step 7: Specify compatibility Since Vendure v2.0.0, it is possible for a plugin to specify which versions of Vendure core it is compatible with. This is especially important if the plugin is intended to be made publicly available via npm or another package registry. The compatibility is specified via the `compatibility` property in the plugin metadata: ```ts title="src/plugins/wishlist-plugin/wishlist.plugin.ts" @VendurePlugin({ // ... // highlight-next-line compatibility: '^2.0.0', }) export class WishlistPlugin {} ``` The value of this property is a [semver range](https://docs.npmjs.com/about-semantic-versioning) which specifies the range of compatible versions. In this case, we are saying that this plugin is compatible with any version of Vendure core which is `>= 2.0.0 < 3.0.0`. ### Step 8: Add the plugin to the VendureConfig The final step is to add the plugin to the `VendureConfig` object. This is done in the `vendure-config.ts` file: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { WishlistPlugin } from './plugins/wishlist-plugin/wishlist.plugin'; export const config: VendureConfig = { // ... plugins: [ // ... // highlight-next-line WishlistPlugin, ], }; ``` ### Test the Plugin Now that the plugin is installed, we can test it out. Since we have defined a custom field, we'll need to generate and run a migration to add the new column to the database. 1. **Generate the Migration File** Run the following command to generate a migration file for the `wishlist-plugin`: ```bash npx vendure migrate wishlist-plugin ``` When prompted, select the "Generate a new migration" option. This will create a new migration file in the `src/migrations` folder. 2. **Run the Migration** After generating the migration file, apply the changes to the database by running the same command again: ```bash npx vendure migrate wishlist-plugin ``` Then start the server: ```bash npm run dev ``` Once the server is running, we should be able to log in as an existing Customer, and then add a product to the wishlist: ```graphql mutation Login { login(username: "alec.breitenberg@gmail.com", password: "test") { ... on CurrentUser { id identifier } ... on ErrorResult { errorCode message } } } ``` ```json { "data": { "login": { "id": "9", "identifier": "alec.breitenberg@gmail.com" } } } ``` ```graphql mutation AddToWishlist { addToWishlist(productVariantId: "7") { id productVariant { id name } } } ``` ```json { "data": { "addToWishlist": [ { "id": "4", "productVariant": { "id": "7", "name": "Wireless Optical Mouse" } } ] } } ``` We can then query the wishlist items: ```graphql query GetWishlist { activeCustomerWishlist { id productVariant { id name } } } ``` ```json { "data": { "activeCustomerWishlist": [ { "id": "4", "productVariant": { "id": "7", "name": "Wireless Optical Mouse" } } ] } } ``` And finally, we can test removing an item from the wishlist: ```graphql mutation RemoveFromWishlist { removeFromWishlist(itemId: "4") { id productVariant { id name } } } ``` ```json { "data": { "removeFromWishlist": [] } } ``` ## Publishing plugins If you have created a plugin that you would like to share with the community, you can publish it to npm, and even have it listed on the [Vendure Hub](https://vendure.io/hub). For a full guide to publishing plugins, see the [Publishing a Plugin how-to guide](/guides/how-to/publish-plugin/). --- --- title: "Add a REST endpoint" showtoc: true --- REST-style endpoints can be defined as part of a [plugin](/guides/developer-guide/plugins/). :::info REST endpoints are implemented as NestJS Controllers. For comprehensive documentation, see the [NestJS controllers documentation](https://docs.nestjs.com/controllers). ::: In this guide we will define a plugin that adds a single REST endpoint at `http://localhost:3000/products` which returns a list of all products. ## Create a controller First let's define the controller: ```ts title="src/plugins/rest-plugin/api/products.controller.ts" // products.controller.ts import { Controller, Get } from '@nestjs/common'; import { Ctx, ProductService, RequestContext } from '@vendure/core'; @Controller('products') export class ProductsController { constructor(private productService: ProductService) { } @Get() findAll(@Ctx() ctx: RequestContext) { return this.productService.findAll(ctx); } } ``` The key points to note here are: - The `@Controller()` decorator defines the base path for all endpoints defined in this controller. In this case, all endpoints will be prefixed with `/products`. - The `@Get()` decorator defines a GET endpoint at the base path. The method name `findAll` is arbitrary. - The `@Ctx()` decorator injects the [RequestContext](/reference/typescript-api/request/request-context/) which is required for all service methods. ## Register the controller with the plugin ```ts title="src/plugins/rest-plugin/rest.plugin.ts" import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { ProductsController } from './api/products.controller'; @VendurePlugin({ imports: [PluginCommonModule], controllers: [ProductsController], }) export class RestPlugin {} ``` :::info **Note:** [The `PluginCommonModule`](/reference/typescript-api/plugin/plugin-common-module/) should be imported to gain access to Vendure core providers - in this case it is required in order to be able to inject `ProductService` into our controller. ::: The plugin can then be added to the `VendureConfig`: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { RestPlugin } from './plugins/rest-plugin/rest.plugin'; export const config: VendureConfig = { // ... plugins: [ // ... // highlight-next-line RestPlugin, ], }; ``` ## Controlling access to REST endpoints You can use the [`@Allow()` decorator](/reference/typescript-api/request/allow-decorator/) to declare the permissions required to access a REST endpoint: ```ts title="src/plugins/rest-plugin/api/products.controller.ts" import { Controller, Get } from '@nestjs/common'; import { Allow, Permission, Ctx, ProductService, RequestContext } from '@vendure/core'; @Controller('products') export class ProductsController { constructor(private productService: ProductService) {} // highlight-next-line @Allow(Permission.ReadProduct) @Get() findAll(@Ctx() ctx: RequestContext) { return this.productService.findAll(ctx); } } ``` :::tip The following Vendure [API decorators](/guides/developer-guide/the-api-layer/#api-decorators) can also be used with NestJS controllers: `@Allow()`, `@Transaction()`, `@Ctx()`. Additionally, NestJS supports a number of other REST decorators detailed in the [NestJS controllers guide](https://docs.nestjs.com/controllers#request-object) ::: --- --- title: 'Scheduled Tasks' showtoc: true --- Scheduled tasks are a way of executing some code at pre-defined intervals. There are many examples of work that can be done using scheduled tasks, such as: - Generating a sitemap - Synchronizing data between different systems - Sending abandoned cart emails - Cleaning up old data Since Vendure v3.3, there is a built-in mechanism which allows you to define scheduled tasks in a convenient and powerful way. :::info All the information on page applies to Vendure v3.3+ For older versions, there is no built-in support for scheduled tasks, but you can instead use a [stand-alone script](/guides/developer-guide/stand-alone-scripts/) triggered by a cron job. ::: ## Setting up the DefaultSchedulerPlugin In your Vendure config, import and add the [DefaultSchedulerPlugin](/reference/typescript-api/scheduled-tasks/default-scheduler-plugin) to your plugins array. If you created your project with a version newer than v3.3, this should already be configured. ```ts title="vendure-config.ts" import { DefaultSchedulerPlugin, VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... plugins: [DefaultSchedulerPlugin.init()], }; ``` When you first add this plugin to your config, you'll need to [generate a migration](/guides/developer-guide/migrations/) because the plugin will make use of a new database table in order to guarantee only-once execution of tasks. You can then start adding tasks. Vendure ships with a task that will clean up old sessions from the database. :::note The `cleanSessionsTask` task is actually configured by default from v3.3+, so normally you won't have to specify this manually unless you wish to change any of the default configuration using the `.configure()` method. ::: ```ts title="vendure-config.ts" import { cleanSessionsTask, DefaultSchedulerPlugin, VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... schedulerOptions: { tasks: [ // Use the task as is cleanSessionsTask, // or further configure the task cleanSessionsTask.configure({ // Run the task every day at 3:00am // The default schedule is every day at 00:00am schedule: cron => cron.everyDayAt(3, 0), params: { // How many sessions to process in each batch // Default: 10_000 batchSize: 5_000, }, }), ], }, plugins: [DefaultSchedulerPlugin.init()], }; ``` ## Creating a Scheduled Task Let's imagine that you have created a `SitemapPlugin` that exposes a `SitemapService` which generates a sitemap for your store. You want to run this task every night at midnight. Inside the plugin, you would first define a new [ScheduledTask](/reference/typescript-api/scheduled-tasks/scheduled-task) instance: ```ts title="/plugins/sitemap/config/generate-sitemap-task.ts" import { ScheduledTask, RequestContextService } from '@vendure/core'; import { SitemapService } from '../services/sitemap.service'; export const generateSitemapTask = new ScheduledTask({ // Give your task a unique ID id: 'generate-sitemap', // A human-readable description of the task description: 'Generates a sitemap file', // Params can be used to further configure aspects of the // task. They get passed in to the `execute` function as the // second argument. // They can be later modified using the `.configure()` method on the instance params: { shopBaseUrl: 'https://www.myshop.com', }, // Define a default schedule. This can be modified using the // `.configure()` method on the instance later. schedule: cron => cron.everyDayAt(0, 0), // This is the function that will be executed per the schedule. async execute({injector, params}) { // Using `injector.get()` we can grab an instance of _any_ provider defined in the // Vendure core as well as by our plugins. const sitemapService = injector.get(SitemapService); // For most service methods, we'll need to pass a RequestContext object. // We can use the RequestContextService to create one. const ctx = await injector.get(RequestContextService).create({ apiType: 'admin', }); // Here's the actual work we want to perform. const result = await sitemapService.generateSitemap(ctx); // The return value from the `execute` function will be available // as the `lastResult` property when viewing tasks. return { result }; }, }); ``` ## Using a task Now that the task has been defined, we need to tell Vendure to use it. To do so we need to add it to the [schedulerOptions.tasks](/reference/typescript-api/scheduled-tasks/scheduler-options#tasks) array. ### Adding directly in Vendure config This can be done directly in your Vendure config file: ```ts title="vendure-config.ts" import { cleanSessionsTask, DefaultSchedulerPlugin, VendureConfig } from '@vendure/core'; // highlight-next-line import { SitemapPlugin, generateSitemapTask } from './plugins/sitemap'; export const config: VendureConfig = { // ... schedulerOptions: { tasks: [ cleanSessionsTask, // highlight-start // Here's an example of overriding the // default params using the `configure()` method. generateSitemapTask.configure({ params: { shopBaseUrl: 'https://www.shoes.com' } }), // highlight-end ], }, plugins: [ // highlight-next-line SitemapPlugin, DefaultSchedulerPlugin.init() ], }; ``` ### Adding in plugin configuration function An alternative is that a plugin can automatically add the task to the config using the plugin's [configuration function](/reference/typescript-api/plugin/vendure-plugin-metadata#configuration), which allows plugins to alter the Vendure config. This allows a plugin to encapsulate any scheduled tasks so that the plugin consumer only needs to add the plugin, and not worry about separately adding the task to the tasks array. ```ts title="src/plugins/sitemap/sitemap.plugin.ts" import { VendurePlugin, PluginCommonModule, Type, ScheduledTask, VendureConfig } from '@vendure/core'; import { PLUGIN_OPTIONS } from './constants'; import { SitemapPluginOptions } from './types'; import { SitemapService } from './services/sitemap.service'; import { generateSitemapTask } from './config/generate-sitemap-task'; @VendurePlugin({ imports: [PluginCommonModule], providers: [SitemapService], configuration: (config: VendureConfig) => { // highlight-start // Add the task to the schedulerOptions.tasks array config.schedulerOptions.tasks.push( generateSitemapTask.configure({ params: { shopBaseUrl: SitemapPlugin.options.shopBaseUrl, } }) ); // highlight-end return config; }, }) export class SitemapPlugin { static options: SitemapPluginOptions; static init(options?: SitemapPluginOptions) { this.options = { shopBaseUrl: '', ...(options ?? {}), } } } ``` This plugin can now be consumed like this: ```ts title="vendure-config.ts" import { DefaultSchedulerPlugin, VendureConfig } from '@vendure/core'; // highlight-next-line import { SitemapPlugin } from './plugins/sitemap'; export const config: VendureConfig = { // ... plugins: [ // highlight-start SitemapPlugin.init({ shopBaseUrl: 'https://www.shoes.com' }), // highlight-end DefaultSchedulerPlugin.init() ], }; ``` ## How scheduled tasks work The key problems solved by Vendure's task scheduler are: - Ensuring that a task is only run a single time per scheduled execution, even when you have multiple instances of servers and workers running. - Keeping scheduled task work away from the server instances, so that it does not affect API responsiveness. The first problem is handled by the [SchedulerStrategy](/reference/typescript-api/scheduled-tasks/scheduler-strategy), which implements a locking mechanism to ensure that the task is executed only once. The second problem is handled by having tasks only executed on worker processes. ## Scheduled tasks vs job queue There is some overlap between the use of a scheduled task and a [job queue job](/guides/developer-guide/worker-job-queue/). They both perform some task on the worker, independent of requests coming in to the server. The first difference is that jobs must be triggered explicitly, whereas scheduled tasks are triggered automatically according to the schedule. Secondly, jobs are put in a _queue_ and executed once any prior pending jobs have been processed. On the other hand, scheduled tasks are executed as soon as the schedule dictates. It is possible to combine the two: namely, you can define a scheduled task which adds a job to the job queue. This is, for instance, how the built-in [cleanSessionsTask](/reference/typescript-api/scheduled-tasks/clean-sessions-task) works. This pattern is something you should consider if the scheduled task may take a significant amount of time or resources and you want to let the job queue manage that. It also has the advantage of giving you a record of results for that work that has been put on the job queue, whereas scheduled tasks only record that result of the last execution. ## A note on @nestjs/schedule NestJS provides a [dedicated package for scheduling tasks](https://docs.nestjs.com/techniques/task-scheduling), called `@nestjs/schedule`. You can also use this approach to schedule tasks, but you need to aware of a very important caveat: :::warning When using `@nestjs/schedule`, any method decorated with the `@Cron()` decorator will run on _all_ instances of the application. This means it will run on the server _and_ on the worker. If you are running multiple instances, then it will run on all instances. This is the specific issue solved by the built-in ScheduledTask system described above. Therefore it is not recommended to use the `@nestjs/schedule` package under normal circumstances. ::: You can, for instance, inject the [ProcessContext](/reference/typescript-api/common/process-context) into the service and check if the current instance is the worker or the server. ```ts import { Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; @Injectable() export class SitemapService { constructor(private processContext: ProcessContext) {} @Cron('0 0 * * *') async generateSitemap() { // highlight-start if (this.processContext.isWorker) { // Only run on the worker await this.triggerGenerate(); } // highlight-end } } ``` The above code will run the `generateSitemap()` method every night at midnight, but only on the worker instance. Again, if you have multiple worker instances running, it would run on all instances. --- --- title: "Security" --- Security of your Vendure application includes considering how to prevent and protect against common security threats such as: - Data breaches - Unauthorized access - Attacks aimed at disrupting the service Vendure itself is designed with security in mind, but you must also consider the security of your own application code, the server environment, and the network architecture. ## Basics Here are some basic measures you should use to secure your Vendure application. These are not exhaustive, but they are a good starting point. ### Change the default credentials Do not deploy any public Vendure instance with the default superadmin credentials (`superadmin:superadmin`). Use your hosting platform's environment variables to set a **strong** password for the Superadmin account. ```ts import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { authOptions: { tokenMethod: ['bearer', 'cookie'], superadminCredentials: { identifier: process.env.SUPERADMIN_USERNAME, password: process.env.SUPERADMIN_PASSWORD, }, }, // ... }; ``` ### Use the HardenPlugin It is recommended that you install and configure the [HardenPlugin](/reference/core-plugins/harden-plugin/) for all production deployments. This plugin locks down your schema (disabling introspection and field suggestions) and protects your Shop API against malicious queries that could otherwise overwhelm your server. Install the plugin: ```sh npm install @vendure/harden-plugin # or yarn add @vendure/harden-plugin ``` Then add it to your VendureConfig: ```ts import { VendureConfig } from '@vendure/core'; import { HardenPlugin } from '@vendure/harden-plugin'; const IS_DEV = process.env.APP_ENV === 'dev'; export const config: VendureConfig = { // ... plugins: [ HardenPlugin.init({ maxQueryComplexity: 500, apiMode: IS_DEV ? 'dev' : 'prod', }), // ... ] }; ``` :::info For a detailed explanation of how to best configure this plugin, see the [HardenPlugin docs](/reference/core-plugins/harden-plugin/). ::: ### Harden the AssetServerPlugin If you are using the [AssetServerPlugin](/reference/core-plugins/asset-server-plugin/), it is possible by default to use the dynamic image transform feature to overload the server with requests for new image sizes & formats. To prevent this, you can configure the plugin to only allow transformations for the preset sizes, and limited quality levels and formats. Since v3.1 we ship the [PresetOnlyStrategy](/reference/core-plugins/asset-server-plugin/preset-only-strategy/) for this purpose, and you can also create your own strategies. ```ts import { VendureConfig } from '@vendure/core'; import { AssetServerPlugin, PresetOnlyStrategy } from '@vendure/asset-server-plugin'; export const config: VendureConfig = { // ... plugins: [ AssetServerPlugin.init({ // ... // highlight-start imageTransformStrategy: new PresetOnlyStrategy({ defaultPreset: 'large', permittedQuality: [0, 50, 75, 85, 95], permittedFormats: ['jpg', 'webp', 'avif'], allowFocalPoint: false, }), // highlight-end }), ] }; ``` ## OWASP Top Ten Security Assessment The Open Worldwide Application Security Project (OWASP) is a nonprofit foundation that works to improve the security of software. It publishes a top 10 list of common web application vulnerabilities: https://owasp.org/Top10 This section assesses Vendure against this list, stating what is covered **out of the box** (built in to the framework or easily configurable) and what needs to be **additionally considered.** ### 1. Broken Access Control Reference: https://owasp.org/Top10/A01_2021-Broken_Access_Control/ Out of the box: - Vendure uses role-based access control - We deny by default for non-public API requests - Built-in CORS controls for session cookies - Directory listing is not possible via default configuration (e.g. exposing web root dir contents) - Stateful session identifiers should be invalidated on the server after logout. On logout we delete all session records from the DB & session cache. To consider: - Rate limit API and controller access to minimize the harm from automated attack tooling. ### 2. Cryptographic Failures Reference: https://owasp.org/Top10/A02_2021-Cryptographic_Failures/ Out of the box: - Vendure defaults to bcrypt with 12 salt rounds for storing passwords. This strategy is configurable if security requirements mandate alternative algorithms. - No deprecated hash functions (SHA1, MD5) are used in security-related contexts (only for things like creating cache keys). - Payment information is not stored in Vendure by default. Payment integrations rely on the payment provider to store all sensitive data. To consider: - The Vendure server will not use TLS be default. The usual configuration is to handle this at the gateway level on your production platform. - If a network caching layer is used (e.g. Stellate), ensure it is configured to not cache user-related data (customer details, active order etc) ### 3. Injection Reference: https://owasp.org/Top10/A03_2021-Injection/ Out of the box: - GraphQL has built-in validation of incoming data - All database operations are parameterized - no string concatenation using user-supplied data. - List queries apply default limits to prevent mass disclosure of records. To consider: - If using custom fields, you should consider defining a validation function to prevent bad data from getting into the database. ### 4. Insecure Design Reference: https://owasp.org/Top10/A04_2021-Insecure_Design/ Out of the box: - Use of established libraries for the critical underlying components: NestJS, TypeORM, Angular. - End-to-end tests of security-related flows such as authentication, verification, and RBAC permissions controls. - Harden plugin provides pre-configured protections against common attack vectors targeting GraphQL APIs. To consider: - Tiered exposure such as an API gateway which prevents exposure of the Admin API to the public internet. - Limit resource usage of Vendure server & worker instances via containerization. - Rate limiting & other network-level protections (such as Cloudflare) should be considered. ### 5. Security Misconfiguration Reference: https://owasp.org/Top10/A05_2021-Security_Misconfiguration/ Out of the box: - Single point of configuration for the entire application, reducing the chance of misconfiguration. - A default setup only requires a database, which means there are few components to configure and harden. - Stack traces are not leaked in API errors To consider: - Ensure the default superadmin credentials are not used in production - Use environment variables to turn off development features such as the GraphQL playground - Use the HardenPlugin in production to automatically turn of development features and restrict system information leaking via API. - Use fine-grained permissions and roles for your administrator accounts to reduce the attack surface if an account is compromised. ### 6. Vulnerable and Outdated Components Reference: https://owasp.org/Top10/A06_2021-Vulnerable_and_Outdated_Components/ Out of the box: - All dependencies are updated to current versions with each minor release - Modular design limits the number of dependencies for core packages. - Automated code & dependency scanning is used in the Vendure repo To consider: - Run your own audits on your code base. - Use version override mechanisms if needed to patch and critical Vendure dependencies that did not yet get updated. ### 7. Identification and Authentication Failures Reference: https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/ Out of the box: - Valid usernames are not leaked via mechanisms such as account reset - Does not permit "knowlege-based" account recovery - Uses strong password hashing (bcrypt with 12 salt rounds) - Session identifiers are not exposed in API urls (instead we use headers/cookies) - New session tokens always regenerated after successful login - Sessions deleted during logout - Cryptographically-strong, high-entropy session tokens are used (crypto.randomBytes API) To consider: - Implementing a multi-factor authentication flow - Do not use default superadmin credentials in production - Implementing a custom PasswordValidationStrategy to disallow weak/common passwords - Subscribe to AttemptedLoginEvent to implement detection of brute-force attacks ### 8. Software and Data Integrity Failures Reference: https://owasp.org/Top10/A08_2021-Software_and_Data_Integrity_Failures/ To consider: - Exercise caution when introducing new dependencies to your project. - Do not use untrusted Vendure plugins. Where possible review the code prior to use. - Exercise caution if using auto-updating mechanisms for dependencies. - If storing serialized data in custom fields, implement validation to prevent untrusted data getting into the database. - Evaluate your CI/CD pipeline against the OWASP recommendations for this point ### 9. Security Logging and Monitoring Failures Reference: https://owasp.org/Top10/A09_2021-Security_Logging_and_Monitoring_Failures/ Out of the box: - APIs for integrating logging & monitoring tools & services, e.g. configurable Logger interface & ErrorHandlerStrategy - Official Sentry integration for application performance monitoring To consider: - Integrate with dedicated logging tools for improved log management - Integrate with monitoring tools such as Sentry - Use the EventBus to monitor events such as repeated failed login attempts and high-value orders ### 10. Server-Side Request Forgery (SSRF) Reference: [https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_(SSRF)/](https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/) Out of the box: - By default Vendure does not rely on requests to remote servers for core functionality To consider: - Review the OWASP recommendations against your network architecture --- --- title: 'Settings Store' showtoc: true --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Settings Store The Settings Store is a flexible system for storing configuration data with support for scoping, permissions, and validation. It allows plugins and the core system to store and retrieve arbitrary JSON data with fine-grained control over access and isolation. It provides a robust, secure, and flexible system for managing configuration data in your Vendure application. Use it to store user preferences, plugin settings, feature flags, and any other settings data your application needs. :::info The APIs in this guide were introduced in Vendure v3.4 ::: ## Overview The Settings Store provides: - **Scoped Storage**: Data can be scoped globally, per-user, per-channel, or with custom scope - **Permission Control**: Fields can require specific permissions to access - **Validation**: Custom validation functions for field values - **GraphQL API**: Admin API for reading and writing values - **Service API**: Programmatic access via the [SettingsStoreService](/reference/typescript-api/services/settings-store-service) - **Automatic Cleanup**: Scheduled task to remove orphaned entries ## Settings Store vs Custom Fields Settings fields share some similarities to custom fields, but the important differences are: - Custom fields are attached to particular Vendure entities. Settings fields are not. - Defining a custom field adds a new column in the database, whereas settings fields do not. - Custom fields are reflected in corresponding GraphQL APIs and in the Admin UI & Dashboard UIs. - Custom fields are statically typed, whereas settings fields store any kind of JSON-serializable data. Settings fields are best suited to storing config-like values that are global in scope, or which configure data for a particular plugin. ## Defining Settings Fields Settings fields are defined in your Vendure configuration using the `settingsStoreFields` option: ```ts import { VendureConfig, SettingsStoreScopes } from '@vendure/core'; export const config: VendureConfig = { // ... other config settingsStoreFields: { dashboard: [ { name: 'theme', scope: SettingsStoreScopes.user, }, { name: 'companyName', scope: SettingsStoreScopes.global, } ] } }; ``` ```ts import { VendureConfig, SettingsStoreScopes, Permission } from '@vendure/core'; export const config: VendureConfig = { // ... other config settingsStoreFields: { dashboard: [ { name: 'theme', scope: SettingsStoreScopes.user, }, { name: 'tableFilters', scope: SettingsStoreScopes.userAndChannel, } ], payment: [ { name: 'stripeApiKey', scope: SettingsStoreScopes.global, readonly: true, // Cannot be modified via GraphQL API requiresPermission: Permission.SuperAdmin, validate: (value, injector, ctx) => { if (typeof value !== 'string' || !value.startsWith('sk_')) { return 'Stripe API key must be a string starting with "sk_"'; } } } ], ui: [ { name: 'welcomeMessage', scope: SettingsStoreScopes.channel, validate: async (value, injector, ctx) => { if (typeof value !== 'string' || value.length > 500) { return 'Welcome message must be a string with max 500 characters'; } } } ] } }; ``` ### Field Configuration Options Each field supports the following configuration options: | Option | Type | Description | | -------------------- | ---------------------------- | ----------------------------------------------------------- | | `name` | `string` | The field name (combined with namespace to create full key) | | `scope` | `SettingsStoreScopeFunction` | How the field should be scoped (see scoping section) | | `readonly` | `boolean` | If true, field cannot be modified via GraphQL API | | `requiresPermission` | `Permission \| Permission[]` | Permissions required to access this field | | `validate` | `function` | Custom validation function for field values | ### Scoping The Settings Store supports four built-in scoping strategies: ```ts import { SettingsStoreScopes } from '@vendure/core'; // Global - single value for entire system SettingsStoreScopes.global; // User-specific - separate values per user SettingsStoreScopes.user; // Channel-specific - separate values per channel SettingsStoreScopes.channel; // User and channel specific - separate values per user per channel SettingsStoreScopes.userAndChannel; ``` You can also create custom scope functions: ```ts const customScope: SettingsStoreScopeFunction = ({ key, value, ctx }) => { // Custom scoping logic const env = process.env.NODE_ENV === 'production' ? 'prod' : 'dev'; return `env:${env}`; }; export const config: VendureConfig = { settingsStoreFields: { myNamespace: [ { name: 'customField', // The value will be saved with the scope // "env:prod" or "env:dev" scope: customScope, }, ], }, }; ``` ## GraphQL API The Settings Store provides GraphQL queries and mutations in the Admin API: ### Queries ```graphql # Get a single value query GetSettingsStoreValue($key: String!) { getSettingsStoreValue(key: $key) } # Get multiple values query GetSettingsStoreValues($keys: [String!]!) { getSettingsStoreValues(keys: $keys) } ``` ### Mutations Any kind of JSON-serializable data can be set as the value. For example: strings, numbers, arrays, or even deeply-nested objects and arrays. ```graphql # Set a single value mutation SetSettingsStoreValue($input: SettingsStoreInput!) { setSettingsStoreValue(input: $input) { key result error } } # Set multiple values mutation SetSettingsStoreValues($inputs: [SettingsStoreInput!]!) { setSettingsStoreValues(inputs: $inputs) { key result error } } ``` :::note By default, the Settings Store is not exposed in the Shop API. However, you can expose this functionality via a custom mutations & queries that internally use the `SettingsStoreService` (see next section). ::: ### Usage Examples ```ts // Setting a value const result = await adminClient.query(gql` mutation SetSettingsStoreValue($input: SettingsStoreInput!) { setSettingsStoreValue(input: $input) { key result error } } `, { input: { key: 'dashboard.theme', value: 'dark' } }); // Getting a value const theme = await adminClient.query(gql` query GetSettingsStoreValue($key: String!) { getSettingsStoreValue(key: $key) } `, { key: 'dashboard.theme' }); ``` ```ts // Setting multiple values const results = await adminClient.query(gql` mutation SetSettingsStoreValues($inputs: [SettingsStoreInput!]!) { setSettingsStoreValues(inputs: $inputs) { key result error } } `, { inputs: [ { key: 'dashboard.theme', value: 'dark' }, { key: 'dashboard.language', value: 'en' } ] }); // Getting multiple values const settings = await adminClient.query(gql` query GetSettingsStoreValues($keys: [String!]!) { getSettingsStoreValues(keys: $keys) } `, { keys: ['dashboard.theme', 'dashboard.language'] }); // Returns: {"dashboard.theme": "dark", "dashboard.language": "en"} ``` ## Using the SettingsStoreService For programmatic access within plugins or services, use the [SettingsStoreService](/reference/typescript-api/services/settings-store-service): ```ts import { Injectable } from '@nestjs/common'; import { SettingsStoreService, RequestContext } from '@vendure/core'; @Injectable() export class MyService { constructor(private settingsStoreService: SettingsStoreService) {} async getUserTheme(ctx: RequestContext): Promise { const theme = await this.settingsStoreService.get('dashboard.theme', ctx); return theme || 'light'; // Default fallback } async setUserTheme(ctx: RequestContext, theme: string): Promise { const result = await this.settingsStoreService.set('dashboard.theme', theme, ctx); return result.result; } } ``` ```ts import { Injectable } from '@nestjs/common'; import { SettingsStoreService, RequestContext } from '@vendure/core'; interface DashboardSettings { theme: 'light' | 'dark'; language: string; notifications: boolean; } @Injectable() export class DashboardService { constructor(private settingsStoreService: SettingsStoreService) {} async getDashboardSettings(ctx: RequestContext): Promise { const settings = await this.settingsStoreService.getMany([ 'dashboard.theme', 'dashboard.language', 'dashboard.notifications' ], ctx); return { theme: settings['dashboard.theme'] || 'light', language: settings['dashboard.language'] || 'en', notifications: settings['dashboard.notifications'] ?? true, }; } async updateDashboardSettings( ctx: RequestContext, settings: Partial ): Promise<{ success: boolean; errors: string[] }> { const updates: Record = {}; if (settings.theme) updates['dashboard.theme'] = settings.theme; if (settings.language) updates['dashboard.language'] = settings.language; if (settings.notifications !== undefined) { updates['dashboard.notifications'] = settings.notifications; } const results = await this.settingsStoreService.setMany(updates, ctx); return { success: results.every(r => r.result), errors: results.filter(r => !r.result).map(r => r.error || 'Unknown error') }; } } ``` ### SettingsStoreService Methods | Method | Description | | ------------------------- | --------------------------------------------------- | | `get(key, ctx)` | Get a single value with optional type parameter | | `getMany(keys, ctx)` | Get multiple values efficiently in a single query | | `set(key, value, ctx)` | Set a value with structured result feedback | | `setMany(values, ctx)` | Set multiple values with individual result feedback | | `getFieldDefinition(key)` | Get the field configuration for a key | ## Orphaned Entries Cleanup When field definitions are removed from your configuration, the corresponding database entries become "orphaned". The Settings Store includes an automatic cleanup system to handle this. ### Manual Cleanup You can also perform cleanup manually via the service: ```ts // Find orphaned entries const orphanedEntries = await settingsStoreService.findOrphanedEntries({ olderThan: '7d', maxDeleteCount: 1000, }); // Clean them up const cleanupResult = await settingsStoreService.cleanupOrphanedEntries({ olderThan: '7d', dryRun: false, batchSize: 100, }); ``` ## Best Practices 1. **Use appropriate scoping**: Choose the most restrictive scope that meets your needs 2. **Implement validation**: Add validation for fields that accept user input 3. **Set permissions**: Use`requiresPermission` for sensitive configuration data 4. **Mark sensitive fields readonly**: Prevent GraphQL modification of critical settings 5. **Consider value size limits**: Large values can impact performance ## Examples ### Plugin Integration ```ts import { VendurePlugin, SettingsStoreScopes } from '@vendure/core'; @VendurePlugin({ configuration: config => { config.settingsStoreFields = { ...config.settingsStoreFields, myPlugin: [ { name: 'apiEndpoint', scope: SettingsStoreScopes.global, requiresPermission: Permission.UpdateSettings, validate: value => { if (typeof value !== 'string' || !value.startsWith('https://')) { return 'API endpoint must be a valid HTTPS URL'; } }, }, { name: 'userPreferences', scope: SettingsStoreScopes.userAndChannel, }, ], }; return config; }, }) export class MyPlugin {} ``` ### Frontend usage ```tsx import React from 'react'; import { useQuery, useMutation } from '@apollo/client'; import gql from 'graphql-tag'; const GET_THEME = gql` query GetTheme { getSettingsStoreValue(key: "dashboard.theme") } `; const SET_THEME = gql` mutation SetTheme($theme: String!) { setSettingsStoreValue(input: { key: "dashboard.theme", value: $theme }) { result error } } `; export function ThemeSelector() { const { data } = useQuery(GET_THEME); const [setTheme] = useMutation(SET_THEME, { refetchQueries: [GET_THEME], }); const currentTheme = data?.getSettingsStoreValue || 'light'; const handleThemeChange = (theme: string) => { setTheme({ variables: { theme } }); }; return ( ); } ``` --- --- title: "Stand-alone CLI Scripts" --- # Stand-alone CLI Scripts It is possible to create stand-alone scripts that can be run from the command-line by using the [bootstrapWorker function](/reference/typescript-api/worker/bootstrap-worker/). This can be useful for a variety of use-cases such as running cron jobs or importing data. ## Minimal example Here's a minimal example of a script which will bootstrap the Vendure Worker and then log the number of products in the database: ```ts title="src/get-product-count.ts" import { bootstrapWorker, Logger, ProductService, RequestContextService } from '@vendure/core'; import { config } from './vendure-config'; if (require.main === module) { getProductCount() .then(() => process.exit(0)) .catch(err => { Logger.error(err); process.exit(1); }); } async function getProductCount() { // This will bootstrap an instance of the Vendure Worker, providing // us access to all of the services defined in the Vendure core. // (but without the unnecessary overhead of the API layer). const { app } = await bootstrapWorker(config); // Using `app.get()` we can grab an instance of _any_ provider defined in the // Vendure core as well as by our plugins. const productService = app.get(ProductService); // For most service methods, we'll need to pass a RequestContext object. // We can use the RequestContextService to create one. const ctx = await app.get(RequestContextService).create({ apiType: 'admin', }); // We use the `findAll()` method to get the total count. Since we aren't // interested in the actual product objects, we can set the `take` option to 0. const { totalItems } = await productService.findAll(ctx, {take: 0}); Logger.info( [ '\n-----------------------------', `There are ${totalItems} products`, '------------------------------', ].join('\n'), ) } ``` This script can then be run from the command-line: ```shell npx ts-node src/get-product-count.ts # or yarn ts-node src/get-product-count.ts ``` resulting in the following output: ```shell info 01/08/23, 11:50 - [Vendure Worker] Bootstrapping Vendure Worker (pid: 4428)... info 01/08/23, 11:50 - [Vendure Worker] Vendure Worker is ready info 01/08/23, 11:50 - [Vendure Worker] ----------------------------------------- There are 56 products in the database ----------------------------------------- ``` ## The `app` object The `app` object returned by the `bootstrapWorker()` function is an instance of the [NestJS Application Context](https://docs.nestjs.com/standalone-applications). It has full access to the NestJS dependency injection container, which means that you can use the `app.get()` method to retrieve any of the services defined in the Vendure core or by any plugins. ```ts title="src/import-customer-data.ts" import { bootstrapWorker, CustomerService } from '@vendure/core'; import { config } from './vendure-config'; // ... async function importCustomerData() { const { app } = await bootstrapWorker(config); // highlight-start const customerService = app.get(CustomerService); // highlight-end } ``` ## Creating a RequestContext Almost all the methods exposed by Vendure's core services take a `RequestContext` object as the first argument. Usually, this object is created in the [API Layer](/guides/developer-guide/the-api-layer/#resolvers) by the `@Ctx()` decorator, and contains information related to the current API request. When running a stand-alone script, we aren't making any API requests, so we need to create a `RequestContext` object manually. This can be done using the [`RequestContextService`](/reference/typescript-api/request/request-context-service/): ```ts title="src/get-product-count.ts" // ... import { RequestContextService } from '@vendure/core'; async function getProductCount() { const { app } = await bootstrapWorker(config); const productService = app.get(ProductService); // highlight-start const ctx = await app.get(RequestContextService).create({ apiType: 'admin', }); // highlight-end const { totalItems } = await productService.findAll(ctx, {take: 0}); } ``` --- --- title: 'Strategies & Configurable Operations' sidebar_position: 4 --- Vendure is built to be highly configurable and extensible. Two methods of providing this extensibility are **strategies** and **configurable operations**. ## Strategies A strategy is named after the [Strategy Pattern](https://en.wikipedia.org/wiki/Strategy_pattern), and is a way of providing a pluggable implementation of a particular feature. Vendure makes heavy use of this pattern to delegate the implementation of key points of extensibility to the developer. Examples of strategies include: - [`OrderCodeStrategy`](/reference/typescript-api/orders/order-code-strategy/) - determines how order codes are generated - [`StockLocationStrategy`](/reference/typescript-api/products-stock/stock-location-strategy/) - determines which stock locations are used to fulfill an order - [`ActiveOrderStrategy`](/reference/typescript-api/orders/active-order-strategy/) - determines how the active order in the Shop API is selected - [`AssetStorageStrategy`](/reference/typescript-api/assets/asset-storage-strategy/) - determines where uploaded assets are stored - [`GuestCheckoutStrategy`](/reference/typescript-api/orders/guest-checkout-strategy/) - defines rules relating to guest checkouts - [`OrderItemPriceCalculationStrategy`](/reference/typescript-api/orders/order-item-price-calculation-strategy/) - determines how items are priced when added to the order - [`TaxLineCalculationStrategy`](/reference/typescript-api/tax/tax-line-calculation-strategy/) - determines how tax is calculated for an order line As an example, let's take the [`OrderCodeStrategy`](/reference/typescript-api/orders/order-code-strategy/). This strategy determines how codes are generated when new orders are created. By default, Vendure will use the built-in `DefaultOrderCodeStrategy` which generates a random 16-character string. What if you need to change this behavior? For instance, you might have an existing back-office system that is responsible for generating order codes, which you need to integrate with. Here's how you would do this: ```ts title="src/config/my-order-code-strategy.ts" import { OrderCodeStrategy, RequestContext } from '@vendure/core'; import { OrderCodeService } from '../services/order-code.service'; export class MyOrderCodeStrategy implements OrderCodeStrategy { private orderCodeService: OrderCodeService; init(injector) { this.orderCodeService = injector.get(OrderCodeService); } async generate(ctx: RequestContext): string { return this.orderCodeService.getNewOrderCode(); } } ``` :::info All strategies can make use of existing services by using the `init()` method. This is because all strategies extend the underlying [`InjectableStrategy` interface](/reference/typescript-api/common/injectable-strategy). In this example we are assuming that we already created an `OrderCodeService` which contains all the specific logic for connecting to our backend service which generates the order codes. ::: We then need to pass this custom strategy to our config: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { MyOrderCodeStrategy } from '../config/my-order-code-strategy'; export const config: VendureConfig = { // ... orderOptions: { // highlight-next-line orderCodeStrategy: new MyOrderCodeStrategy(), }, } ``` ### Strategy lifecycle Strategies can use two optional lifecycle methods: - `init(injector: Injector)` - called during the bootstrap phase when the server or worker is starting up. This is where you can inject any services which you need to use in the strategy. You can also perform any other setup logic needed, such as instantiating a connection to an external service. - `destroy()` - called during the shutdown of the server or worker. This is where you can perform any cleanup logic, such as closing connections to external services. ### Passing options to a strategy Sometimes you might want to pass some configuration options to a strategy. For example, imagine you want to create a custom [`StockLocationStrategy`](/reference/typescript-api/products-stock/stock-location-strategy/) which selects a location within a given proximity to the customer's address. You might want to pass the maximum distance to the strategy in your config: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { MyStockLocationStrategy } from '../config/my-stock-location-strategy'; export const config: VendureConfig = { // ... catalogOptions: { // highlight-next-line stockLocationStrategy: new MyStockLocationStrategy({ maxDistance: 100 }), }, } ``` This config will be passed to the strategy's constructor: ```ts title="src/config/my-stock-location-strategy.ts" import { ID, ProductVariant, RequestContext, StockLevel, StockLocationStrategy } from '@vendure/core'; export class MyStockLocationStrategy implements StockLocationStrategy { constructor(private options: { maxDistance: number }) {} getAvailableStock( ctx: RequestContext, productVariantId: ID, stockLevels: StockLevel[] ): ProductVariant[] { const maxDistance = this.options.maxDistance; // ... implementation omitted } } ``` ## Configurable Operations Configurable operations are similar to strategies in that they allow certain aspects of the system to be customized. However, the main difference is that they can also be _configured_ via the Admin UI. This allows the store owner to make changes to the behavior of the system without having to restart the server. So they are typically used to supply some custom logic that needs to accept configurable arguments which can change at runtime. Vendure uses the following configurable operations: - [`CollectionFilter`](/reference/typescript-api/configuration/collection-filter/) - determines which products are included in a collection - [`PaymentMethodHandler`](/reference/typescript-api/payment/payment-method-handler/) - determines how payments are processed - [`PromotionCondition`](/reference/typescript-api/promotions/promotion-condition/) - determines whether a promotion is applicable - [`PromotionAction`](/reference/typescript-api/promotions/promotion-action/) - determines what happens when a promotion is applied - [`ShippingEligibilityChecker`](/reference/typescript-api/shipping/shipping-eligibility-checker/) - determines whether a shipping method is available - [`ShippingCalculator`](/reference/typescript-api/shipping/shipping-calculator/) - determines how shipping costs are calculated Whereas strategies are typically used to provide a single implementation of a particular feature, configurable operations are used to provide a set of implementations which can be selected from at runtime. For example, Vendure ships with a set of default CollectionFilters: ```ts title="default-collection-filters.ts" export const defaultCollectionFilters = [ facetValueCollectionFilter, variantNameCollectionFilter, variantIdCollectionFilter, productIdCollectionFilter, ]; ``` When setting up a Collection, you can choose from these available default filters: ![CollectionFilters](./collection-filters.webp) When one is selected, the UI will allow you to configure the arguments for that filter: ![CollectionFilters args](./collection-filters-args.webp) Let's take a look at a simplified implementation of the `variantNameCollectionFilter`: ```ts title="variant-name-collection-filter.ts" import { CollectionFilter, LanguageCode } from '@vendure/core'; export const variantNameCollectionFilter = new CollectionFilter({ args: { operator: { type: 'string', ui: { component: 'select-form-input', options: [ { value: 'startsWith' }, { value: 'endsWith' }, { value: 'contains' }, { value: 'doesNotContain' }, ], }, }, term: { type: 'string' }, }, code: 'variant-name-filter', description: [{ languageCode: LanguageCode.en, value: 'Filter by product variant name' }], apply: (qb, args) => { // ... implementation omitted }, }); ``` Here are the important parts: - Configurable operations are **instances** of a pre-defined class, and are instantiated before being passed to your config. - They must have a `code` property which is a unique string identifier. - They must have a `description` property which is a localizable, human-readable description of the operation. - They must have an `args` property which defines the arguments which can be configured via the Admin UI. If the operation has no arguments, then this would be an empty object. - They will have one or more methods that need to be implemented, depending on the type of operation. In this case, the `apply()` method is used to apply the filter to the query builder. ### Configurable operation args The `args` property is an object which defines the arguments which can be configured via the Admin UI. Each property of the `args` object is a key-value pair, where the key is the name of the argument, and the value is an object which defines the type of the argument and any additional configuration. As an example let's look at the `dummyPaymentMethodHandler`, a test payment method which we ship with Vendure core: ```ts title="dummy-payment-method.ts" import { PaymentMethodHandler, LanguageCode } from '@vendure/core'; export const dummyPaymentHandler = new PaymentMethodHandler({ code: 'dummy-payment-handler', description: [/* omitted for brevity */], args: { automaticSettle: { type: 'boolean', label: [ { languageCode: LanguageCode.en, value: 'Authorize and settle in 1 step', }, ], description: [ { languageCode: LanguageCode.en, value: 'If enabled, Payments will be created in the "Settled" state.', }, ], required: true, defaultValue: false, }, }, createPayment: async (ctx, order, amount, args, metadata, method) => { // Inside this method, the `args` argument is type-safe and will be // an object with the following shape: // { // automaticSettle: boolean // } // ... implementation omitted }, }) ``` The following properties are used to configure the argument: #### type Required [`ConfigArgType`](/reference/typescript-api/configurable-operation-def/config-arg-type) The following types are available: `string`, `int`, `float`, `boolean`, `datetime`, `ID`. #### label Optional [`LocalizedStringArray`](/reference/typescript-api/configurable-operation-def/localized-string-array/) A human-readable label for the argument. This is used in the Admin UI. #### description Optional [`LocalizedStringArray`](/reference/typescript-api/configurable-operation-def/localized-string-array/) A human-readable description for the argument. This is used in the Admin UI as a tooltip. #### required Optional `boolean` Whether the argument is required. If `true`, then the Admin UI will not allow the user to save the configuration unless a value has been provided for this argument. #### defaultValue Optional `any` (depends on the `type`) The default value for the argument. If not provided, then the argument will be `undefined` by default. #### list Optional `boolean` Whether the argument is a list of values. If `true`, then the Admin UI will allow the user to add multiple values for this argument. Defaults to `false`. #### ui Optional Allows you to specify the UI component that will be used to render the argument in the Admin UI, by specifying a `component` property, and optional properties to configure that component. ```ts { args: { operator: { type: 'string', ui: { component: 'select-form-input', options: [ { value: 'startsWith' }, { value: 'endsWith' }, { value: 'contains' }, { value: 'doesNotContain' }, ], }, }, } } ``` A full description of the available UI components can be found in the [Custom Fields guide](/guides/developer-guide/custom-fields/#custom-field-ui). ### Injecting dependencies Configurable operations are instantiated before being passed to your config, so the mechanism for injecting dependencies is similar to that of strategies: namely you use an optional `init()` method to inject dependencies into the operation instance. The main difference is that the injected dependency cannot then be stored as a class property, since you are not defining a class when you define a configurable operation. Instead, you can store the dependency as a closure variable. Here’s an example of a ShippingCalculator that injects a service which has been defined in a plugin: ```ts title="src/config/custom-shipping-calculator.ts" import { Injector, ShippingCalculator } from '@vendure/core'; import { ShippingRatesService } from './shipping-rates.service'; // We keep reference to our injected service by keeping it // in the top-level scope of the file. let shippingRatesService: ShippingRatesService; export const customShippingCalculator = new ShippingCalculator({ code: 'custom-shipping-calculator', description: [], args: {}, init(injector: Injector) { // The init function is called during bootstrap, and allows // us to inject any providers we need. shippingRatesService = injector.get(ShippingRatesService); }, calculate: async (order, args) => { // We can now use the injected provider in the business logic. const { price, priceWithTax } = await shippingRatesService.getRate({ destination: order.shippingAddress, contents: order.lines, }); return { price, priceWithTax, }; }, }); ``` --- --- title: "Testing" showtoc: true --- # Testing Vendure plugins allow you to extend all aspects of the standard Vendure server. When a plugin gets somewhat complex (defining new entities, extending the GraphQL schema, implementing custom resolvers), you may wish to create automated tests to ensure your plugin is correct. The `@vendure/testing` package gives you some simple but powerful tooling for creating end-to-end tests for your custom Vendure code. By "end-to-end" we mean we are testing the _entire server stack_ - from API, to services, to database - by making a real API request, and then making assertions about the response. This is a very effective way to ensure that _all_ parts of your plugin are working correctly together. :::info For a working example of a Vendure plugin with e2e testing, see the [real-world-vendure Reviews plugin](https://github.com/vendure-ecommerce/real-world-vendure/tree/master/src/plugins/reviews) ::: ## Usage ### Install dependencies * [`@vendure/testing`](https://www.npmjs.com/package/@vendure/testing) * [`vitest`](https://vitest.dev/) You'll need to install a testing framework. In this example, we will use [Vitest](https://vitest.dev/) as it has very good support for the modern JavaScript features that Vendure uses, and is very fast. * [`graphql-tag`](https://www.npmjs.com/package/graphql-tag) This is not strictly required but makes it much easier to create the DocumentNodes needed to query your server. * We also need to install some packages to allow us to compile TypeScript code that uses decorators: - `@swc/core` - `unplugin-swc` ```sh npm install --save-dev @vendure/testing vitest graphql-tag @swc/core unplugin-swc ``` ### Configure Vitest Create a `vitest.config.mts` file in the root of your project: ```ts title="vitest.config.mts" import path from 'path'; import swc from 'unplugin-swc'; import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['**/*.e2e-spec.ts'], typecheck: { tsconfig: path.join(__dirname, 'tsconfig.e2e.json'), }, }, plugins: [ // SWC required to support decorators used in test plugins // See https://github.com/vitest-dev/vitest/issues/708#issuecomment-1118628479 // Vite plugin swc.vite({ jsc: { transform: { // See https://github.com/vendure-ecommerce/vendure/issues/2099 useDefineForClassFields: false, }, }, }), ], }); ``` and a `tsconfig.e2e.json` tsconfig file for the tests: ```json title="tsconfig.e2e.json" { "extends": "./tsconfig.json", "compilerOptions": { "types": ["node"], "lib": ["es2015"], "useDefineForClassFields": false, "skipLibCheck": true, "inlineSourceMap": false, "sourceMap": true, "allowSyntheticDefaultImports": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "esModuleInterop": true } } ``` ### Register database-specific initializers The `@vendure/testing` package uses "initializers" to create the test databases and populate them with initial data. We ship with initializers for `sqljs`, `postgres` and `mysql`. Custom initializers can be created to support running e2e tests against other databases supported by TypeORM. See the [`TestDbInitializer` docs](/reference/typescript-api/testing/test-db-initializer/) for more details. ```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts" import { MysqlInitializer, PostgresInitializer, SqljsInitializer, registerInitializer, } from '@vendure/testing'; const sqliteDataDir = path.join(__dirname, '__data__'); registerInitializer('sqljs', new SqljsInitializer(sqliteDataDir)); registerInitializer('postgres', new PostgresInitializer()); registerInitializer('mysql', new MysqlInitializer()); ``` :::info Note re. the `sqliteDataDir`: The first time this test suite is run with the `SqljsInitializer`, the populated data will be saved into an SQLite file, stored in the directory specified by this constructor arg. On subsequent runs of the test suite, the data-population step will be skipped and the initial data directly loaded from the SQLite file. This method of caching significantly speeds up the e2e test runs. All the .sqlite files created in the `sqliteDataDir` can safely be deleted at any time. ::: ### Create a test environment The `@vendure/testing` package exports a [`createTestEnvironment` function](/reference/typescript-api/testing/create-test-environment/) which is used to set up a Vendure server and GraphQL clients to interact with both the Shop and Admin APIs: ```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts" import { createTestEnvironment, testConfig } from '@vendure/testing'; import { describe } from 'vitest'; import { MyPlugin } from '../my-plugin.ts'; describe('my plugin', () => { const {server, adminClient, shopClient} = createTestEnvironment({ ...testConfig, plugins: [MyPlugin], }); }); ``` Notice that we pass a [`VendureConfig`](/reference/typescript-api/configuration/vendure-config/) object into the `createTestEnvironment` function. The testing package provides a special [`testConfig`](/reference/typescript-api/testing/test-config/) which is pre-configured for e2e tests, but any aspect can be overridden for your tests. Here we are configuring the server to load the plugin under test, `MyPlugin`. :::caution **Note**: If you need to deeply merge in some custom configuration, use the [`mergeConfig` function](/reference/typescript-api/configuration/merge-config/) which is provided by `@vendure/core`. ::: ### Initialize the server The [`TestServer`](/reference/typescript-api/testing/test-server/) needs to be initialized before it can be used. The `TestServer.init()` method takes an options object which defines how to populate the server: ```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts" import { beforeAll, afterAll } from 'vitest'; import { myInitialData } from './fixtures/my-initial-data.ts'; // ... beforeAll(async () => { await server.init({ productsCsvPath: path.join(__dirname, 'fixtures/e2e-products.csv'), initialData: myInitialData, customerCount: 2, }); await adminClient.asSuperAdmin(); }, 60000); afterAll(async () => { await server.destroy(); }); ``` An explanation of the options: * `productsCsvPath` This is a path to an optional CSV file containing product data. See [Product Import Format](/guides/developer-guide/importing-data/#product-import-format). You can see [an example used in the Vendure e2e tests](https://github.com/vendure-ecommerce/vendure/blob/master/packages/core/e2e/fixtures/e2e-products-full.csv) to get an idea of how it works. To start with you can just copy this file directly and use it as-is. * `initialData` This is an object which defines how other non-product data (Collections, ShippingMethods, Countries etc.) is populated. See [Initial Data Format](/guides/developer-guide/importing-data/#initial-data). You can [copy this example from the Vendure e2e tests](https://github.com/vendure-ecommerce/vendure/blob/master/e2e-common/e2e-initial-data.ts) * `customerCount` Specifies the number of fake Customers to create. Defaults to 10 if not specified. ### Write your tests Now we are all set up to create a test. Let's test one of the GraphQL queries used by our fictional plugin: ```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts" import gql from 'graphql-tag'; import { it, expect, beforeAll, afterAll } from 'vitest'; import { myInitialData } from './fixtures/my-initial-data.ts'; it('myNewQuery returns the expected result', async () => { adminClient.asSuperAdmin(); // log in as the SuperAdmin user const query = gql` query MyNewQuery($id: ID!) { myNewQuery(id: $id) { field1 field2 } } `; const result = await adminClient.query(query, {id: 123}); expect(result.myNewQuery).toEqual({ /* ... */}) }); ``` Running the test will then assert that your new query works as expected. ### Run your tests All that's left is to run your tests to find out whether your code behaves as expected! :::caution **Note:** When using **Vitest** with multiple test suites (multiple `.e2e-spec.ts` files), it will attempt to run them in parallel. If all the test servers are running on the same port (the default in the `testConfig` is `3050`), then this will cause a port conflict. To avoid this, you can manually set a unique port for each test suite. Be aware that `mergeConfig` is used here: ```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts" import { createTestEnvironment, testConfig } from '@vendure/testing'; import { mergeConfig } from "@vendure/core"; import { describe } from 'vitest'; import { MyPlugin } from '../my-plugin.ts'; describe('my plugin', () => { const {server, adminClient, shopClient} = createTestEnvironment(mergeConfig(testConfig, { // highlight-start apiOptions: { port: 3051, }, // highlight-end plugins: [MyPlugin], })); }); ``` ::: ## Accessing internal services It is possible to access any internal service of the Vendure server via the `server.app` object, which is an instance of the NestJS `INestApplication`. For example, to access the `ProductService`: ```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts" import { createTestEnvironment, testConfig } from '@vendure/testing'; import { describe, beforeAll } from 'vitest'; import { MyPlugin } from '../my-plugin.ts'; describe('my plugin', () => { const { server, adminClient, shopClient } = createTestEnvironment({ ...testConfig, plugins: [MyPlugin], }); // highlight-next-line let productService: ProductService; beforeAll(async () => { await server.init({ productsCsvPath: path.join(__dirname, 'fixtures/e2e-products.csv'), initialData: myInitialData, customerCount: 2, }); await adminClient.asSuperAdmin(); // highlight-next-line productService = server.app.get(ProductService); }, 60000); }); ``` --- --- title: 'The API Layer' sidebar_position: 1 --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; Vendure is a headless platform, which means that all functionality is exposed via GraphQL APIs. The API can be thought of as a number of layers through which a request will pass, each of which is responsible for a different aspect of the request/response lifecycle. ## The journey of an API call Let's take a basic API call and trace its journey from the client to the server and back again. ```graphql title="GraphQL Playground Shop API" query { product(id: "1") { id name description } } ``` This query is asking for the `id`, `name` and `description` of a `Product` with the id of `1`. ```json { "data": { "product": { "id": "1", "name": "Laptop", "description": "Now equipped with seventh-generation Intel Core processors, Laptop is snappier than ever. From daily tasks like launching apps and opening files to more advanced computing, you can power through your day thanks to faster SSDs and Turbo Boost processing up to 3.6GHz." } } } ``` GraphQL returns only the specific fields you ask for in your query. :::note If you have your local development server running, you can try this out by opening the GraphQL Playground in your browser: [http://localhost:3000/shop-api](http://localhost:3000/shop-api) ::: ![./Vendure_docs-api_request.webp](./Vendure_docs-api_request.webp) ## Middleware "Middleware" is a term for a function which is executed before or after the main logic of a request. In Vendure, middleware is used to perform tasks such as authentication, logging, and error handling. There are several types of middleware: ### Express middleware At the lowest level, Vendure makes use of the popular Express server library. [Express middleware](https://expressjs.com/en/guide/using-middleware.html) can be added to the sever via the [`apiOptions.middleware`](/reference/typescript-api/configuration/api-options#middleware) config property. There are hundreds of tried-and-tested Express middleware packages available, and they can be used to add functionality such as CORS, compression, rate-limiting, etc. Here's a simple example demonstrating Express middleware which will log a message whenever a request is received to the Admin API: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { RequestHandler } from 'express'; /** * This is a custom middleware function that logs a message whenever a request is received. */ const myMiddleware: RequestHandler = (req, res, next) => { console.log('Request received!'); next(); }; export const config: VendureConfig = { // ... apiOptions: { middleware: [ { // We will execute our custom handler only for requests to the Admin API route: 'admin-api', handler: myMiddleware, } ], }, }; ``` ### NestJS middleware You can also define [NestJS middleware](https://docs.nestjs.com/middleware) which works like Express middleware but also has access to the NestJS dependency injection system. ```ts title="src/vendure-config.ts" import { VendureConfig, ConfigService } from '@vendure/core'; import { Injectable, NestMiddleware } from '@nestjs/common'; import { Request, Response, NextFunction } from 'express'; @Injectable() class MyNestMiddleware implements NestMiddleware { // Dependencies can be injected via the constructor constructor(private configService: ConfigService) {} use(req: Request, res: Response, next: NextFunction) { console.log(`NestJS middleware: current port is ${this.configService.apiOptions.port}`); next(); } } export const config: VendureConfig = { // ... apiOptions: { middleware: [ { route: 'admin-api', handler: MyNestMiddleware, } ], }, }; ``` NestJS allows you to define specific types of middleware including [Guards](https://docs.nestjs.com/guards), [Interceptors](https://docs.nestjs.com/interceptors), [Pipes](https://docs.nestjs.com/pipes) and [Filters](https://docs.nestjs.com/exception-filters). Vendure uses a number of these mechanisms internally to handle authentication, transaction management, error handling and data transformation. ### Global NestJS middleware Guards, interceptors, pipes and filters can be added to your own custom resolvers and controllers using the NestJS decorators as given in the NestJS docs. However, a common pattern is to register them globally via a [Vendure plugin](/guides/developer-guide/plugins/): ```ts title="src/plugins/my-plugin/my-plugin.ts" import { VendurePlugin } from '@vendure/core'; import { APP_GUARD, APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; // Some custom NestJS middleware classes which we want to apply globally import { MyCustomGuard, MyCustomInterceptor, MyCustomExceptionFilter } from './my-custom-middleware'; @VendurePlugin({ // ... providers: [ // This is the syntax needed to apply your guards, // interceptors and filters globally { provide: APP_GUARD, useClass: MyCustomGuard, }, { provide: APP_INTERCEPTOR, useClass: MyCustomInterceptor, }, { // Note: registering a global "catch all" exception filter // must be used with caution as it will override the built-in // Vendure exception filter. See https://github.com/nestjs/nest/issues/3252 // To implement custom error handling, it is recommended to use // a custom ErrorHandlerStrategy instead. provide: APP_FILTER, useClass: MyCustomExceptionFilter, }, ], }) export class MyPlugin {} ``` Adding this plugin to your Vendure config `plugins` array will now apply these middleware classes to all requests. ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { MyPlugin } from './plugins/my-plugin/my-plugin'; export const config: VendureConfig = { // ... plugins: [ MyPlugin, ], }; ``` ### Apollo Server plugins Apollo Server (the underlying GraphQL server library used by Vendure) allows you to define [plugins](https://www.apollographql.com/docs/apollo-server/integrations/plugins/) which can be used to hook into various stages of the GraphQL request lifecycle and perform tasks such as data transformation. These are defined via the [`apiOptions.apolloServerPlugins`](/reference/typescript-api/configuration/api-options#apolloserverplugins) config property. ## Resolvers A "resolver" is a GraphQL concept, and refers to a function which is responsible for returning the data for a particular field. In Vendure, a resolver can also refer to a class which contains multiple resolver functions. For every query or mutation, there is a corresponding resolver function which is responsible for returning the requested data (and performing side-effect such as updating data in the case of mutations). Here's a simplified example of a resolver function for the `product` query: ```ts import { Query, Resolver, Args } from '@nestjs/graphql'; import { Ctx, RequestContext, ProductService } from '@vendure/core'; @Resolver() export class ShopProductsResolver { constructor(private productService: ProductService) {} @Query() product(@Ctx() ctx: RequestContext, @Args() args: { id: string }) { return this.productService.findOne(ctx, args.id); } } ``` - The `@Resolver()` decorator marks this class as a resolver. - The `@Query()` decorator marks the `product()` method as a resolver function. - The `@Ctx()` decorator injects the [`RequestContext` object](/reference/typescript-api/request/request-context/), which contains information about the current request, such as the current user, the active channel, the active language, etc. The `RequestContext` is a key part of the Vendure architecture, and is used throughout the application to provide context to the various services and plugins. In general, your resolver functions should always accept a `RequestContext` as the first argument, and pass it through to the services. - The `@Args()` decorator injects the arguments passed to the query, in this case the `id` that we provided in our query. As you can see, the resolver function is very simple, and simply delegates the work to the `ProductService` which is responsible for fetching the data from the database. :::tip In general, resolver functions should be kept as simple as possible, and the bulk of the business logic should be delegated to the service layer. ::: ## API Decorators Following the pattern of NestJS, Vendure makes use of decorators to control various aspects of the API. Here are the important decorators to be aware of: ### `@Resolver()` This is exported by the `@nestjs/graphql` package. It marks a class as a resolver, meaning that its methods can be used to resolve the fields of a GraphQL query or mutation. ```ts title="src/plugins/wishlist/api/wishlist.resolver.ts" import { Resolver } from '@nestjs/graphql'; // highlight-next-line @Resolver() export class WishlistResolver { // ... } ``` ### `@Query()` This is exported by the `@nestjs/graphql` package. It marks a method as a resolver function for a query. The method name should match the name of the query in the GraphQL schema, or if the method name is different, a name can be provided as an argument to the decorator. ```ts title="src/plugins/wishlist/api/wishlist.resolver.ts" import { Query, Resolver } from '@nestjs/graphql'; @Resolver() export class WishlistResolver { // highlight-next-line @Query() wishlist() { // ... } } ``` ### `@Mutation()` This is exported by the `@nestjs/graphql` package. It marks a method as a resolver function for a mutation. The method name should match the name of the mutation in the GraphQL schema, or if the method name is different, a name can be provided as an argument to the decorator. ```ts title="src/plugins/wishlist/api/wishlist.resolver.ts" import { Mutation, Resolver } from '@nestjs/graphql'; @Resolver() export class WishlistResolver { // highlight-next-line @Mutation() addItemToWishlist() { // ... } } ``` ### `@Allow()` The [`Allow` decorator](/reference/typescript-api/request/allow-decorator) is exported by the `@vendure/core` package. It is used to control access to queries and mutations. It takes a list of [Permissions](/reference/typescript-api/common/permission/) and if the current user does not have at least one of the permissions, then the query or mutation will return an error. ```ts title="src/plugins/wishlist/api/wishlist.resolver.ts" import { Mutation, Resolver } from '@nestjs/graphql'; import { Allow, Permission } from '@vendure/core'; @Resolver() export class WishlistResolver { @Mutation() // highlight-next-line @Allow(Permission.UpdateCustomer) updateCustomerWishlist() { // ... } } ``` ### `@Transaction()` The [`Transaction` decorator](/reference/typescript-api/request/transaction-decorator/) is exported by the `@vendure/core` package. It is used to wrap a resolver function in a database transaction. It is normally used with mutations, since queries typically do not modify data. ```ts title="src/plugins/wishlist/api/wishlist.resolver.ts" import { Mutation, Resolver } from '@nestjs/graphql'; import { Transaction } from '@vendure/core'; @Resolver() export class WishlistResolver { // highlight-next-line @Transaction() @Mutation() addItemToWishlist() { // if an error is thrown here, the // entire transaction will be rolled back } } ``` :::note The `@Transaction()` decorator _only_ works when used with a `RequestContext` object (see the `@Ctx()` decorator below). This is because the `Transaction` decorator stores the transaction context on the `RequestContext` object, and by passing this object to the service layer, the services and thus database calls can access the transaction context. ::: ### `@Ctx()` The [`Ctx` decorator](/reference/typescript-api/request/ctx-decorator/) is exported by the `@vendure/core` package. It is used to inject the [`RequestContext` object](/reference/typescript-api/request/request-context/) into a resolver function. The `RequestContext` contains information about the current request, such as the current user, the active channel, the active language, etc. The `RequestContext` is a key part of the Vendure architecture, and is used throughout the application to provide context to the various services and plugins. ```ts title="src/plugins/wishlist/api/wishlist.resolver.ts" import { Mutation, Resolver } from '@nestjs/graphql'; import { Ctx, RequestContext } from '@vendure/core'; @Resolver() export class WishlistResolver { @Mutation() // highlight-next-line addItemToWishlist(@Ctx() ctx: RequestContext) { // ... } } ``` :::tip As a general rule, _always_ use the `@Ctx()` decorator to inject the `RequestContext` into your resolver functions. ::: ### `@Args()` This is exported by the `@nestjs/graphql` package. It is used to inject the arguments passed to a query or mutation. Given the a schema definition like this: ```graphql extend type Mutation { addItemToWishlist(variantId: ID!): Wishlist } ``` The resolver function would look like this: ```ts title="src/plugins/wishlist/api/wishlist.resolver.ts" import { Mutation, Resolver, Args } from '@nestjs/graphql'; import { Ctx, RequestContext, ID } from '@vendure/core'; @Resolver() export class WishlistResolver { @Mutation() addItemToWishlist( @Ctx() ctx: RequestContext, // highlight-next-line @Args() args: { variantId: ID } ) { // ... } } ``` As you can see, the `@Args()` decorator injects the arguments passed to the query, in this case the `variantId` that we provided in our query. ## Field resolvers So far, we've seen examples of resolvers for queries and mutations. However, there is another type of resolver which is used to resolve the fields of a type. For example, given the following schema definition: ```graphql type WishlistItem { id: ID! // highlight-next-line product: Product! } ``` The `product` field is a relation to the `Product` type. The `product` field resolver would look like this: ```ts title="src/plugins/wishlist/api/wishlist-item.resolver.ts" import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { Ctx, RequestContext } from '@vendure/core'; import { WishlistItem } from '../entities/wishlist-item.entity'; // highlight-next-line @Resolver('WishlistItem') export class WishlistItemResolver { // highlight-next-line @ResolveField() product( @Ctx() ctx: RequestContext, // highlight-next-line @Parent() wishlistItem: WishlistItem ) { // ... } } ``` Note that in this example, the `@Resolver()` decorator has an argument of `'WishlistItem'`. This tells NestJS that this resolver is for the `WishlistItem` type, and that when we use the `@ResolveField()` decorator, we are defining a resolver for a field of that type. In this example we're defining a resolver for the `product` field of the `WishlistItem` type. The `@ResolveField()` decorator is used to mark a method as a field resolver. The method name should match the name of the field in the GraphQL schema, or if the method name is different, a name can be provided as an argument to the decorator. ## REST endpoints Although Vendure is primarily a GraphQL-based API, it is possible to add REST endpoints to the API. This is useful if you need to integrate with a third-party service or client application which only supports REST, for example. Creating a REST endpoint is covered in detail in the [Add a REST endpoint guide](/guides/developer-guide/rest-endpoint/). --- --- title: 'The Service Layer' sidebar_position: 2 --- The service layer is the core of the application. This is where the business logic is implemented, and where the application interacts with the database. When a request comes in to the API, it gets routed to a resolver which then calls a service method to perform the required operation. ![../the-api-layer/Vendure_docs-api_request.webp](../the-api-layer/Vendure_docs-api_request.webp) :::info Services are classes which, in NestJS terms, are [providers](https://docs.nestjs.com/providers#services). They follow all the rules of NestJS providers, including dependency injection, scoping, etc. ::: Services are generally scoped to a specific domain or entity. For instance, in the Vendure core, there is a [`Product` entity](/reference/typescript-api/entities/product), and a corresponding [`ProductService`](/reference/typescript-api/services/product-service) which contains all the methods for interacting with products. Here's a simplified example of a `ProductService`, including an implementation of the `findOne()` method that was used in the example in the [previous section](/guides/developer-guide/the-api-layer/#resolvers): ```ts title="src/services/product.service.ts" import { Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import { ID, Product, RequestContext, TransactionalConnection, TranslatorService } from '@vendure/core'; @Injectable() export class ProductService { constructor(private connection: TransactionalConnection, private translator: TranslatorService){} /** * @description * Returns a Product with the given id, or undefined if not found. */ async findOne(ctx: RequestContext, productId: ID): Promise { const product = await this.connection.findOneInChannel(ctx, Product, productId, ctx.channelId, { where: { deletedAt: IsNull(), }, }); if (!product) { return; } return this.translator.translate(product, ctx); } // ... other methods findMany() {} create() {} update() {} } ``` - The `@Injectable()` decorator is a [NestJS](https://docs.nestjs.com/providers#services) decorator which allows the service to be injected into other services or resolvers. - The `constructor()` method is where the dependencies of the service are injected. In this case, the `TransactionalConnection` is used to access the database, and the `TranslatorService` is used to translate the Product entity into the current language. ## Using core services All the internal Vendure services can be used in your own plugins and scripts. They are listed in the [Services API reference](/reference/typescript-api/services/) and can be imported from the `@vendure/core` package. To make use of a core service in your own plugin, you need to ensure your plugin is importing the `PluginCommonModule` and then inject the desired service into your own service's constructor: ```ts title="src/my-plugin/my.plugin.ts" import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { MyService } from './services/my.service'; @VendurePlugin({ // highlight-start imports: [PluginCommonModule], providers: [MyService], // highlight-end }) export class MyPlugin {} ``` ```ts title="src/my-plugin/services/my.service.ts" import { Injectable } from '@nestjs/common'; import { ProductService } from '@vendure/core'; @Injectable() export class MyService { // highlight-next-line constructor(private productService: ProductService) {} // you can now use the productService methods } ``` ## Accessing the database One of the main responsibilities of the service layer is to interact with the database. For this, you will be using the [`TransactionalConnection` class](/reference/typescript-api/data-access/transactional-connection/), which is a wrapper around the [TypeORM `DataSource` object](https://typeorm.io/data-source-api). The primary purpose of the `TransactionalConnection` is to ensure that database operations can be performed within a transaction (which is essential for ensuring data integrity), even across multiple services. Furthermore, it exposes some helper methods which make it easier to perform common operations. :::info Always pass the `RequestContext` (`ctx`) to the `TransactionalConnection` methods. This ensures the operation occurs within any active transaction. ::: There are two primary APIs for accessing data provided by TypeORM: the **Find API** and the **QueryBuilder API**. ### The Find API This API is the most convenient and type-safe way to query the database. It provides a powerful type-safe way to query including support for eager relations, pagination, sorting, filtering and more. Here are some examples of using the Find API: ```ts title="src/services/item.service.ts" import { Injectable } from '@nestjs/common'; import { ID, RequestContext, TransactionalConnection } from '@vendure/core'; import { IsNull } from 'typeorm'; import { Item } from '../entities/item.entity'; @Injectable() export class ItemService { constructor(private connection: TransactionalConnection) {} findById(ctx: RequestContext, itemId: ID): Promise { return this.connection.getRepository(ctx, Item).findOne({ where: { id: itemId }, }); } findByName(ctx: RequestContext, name: string): Promise { return this.connection.getRepository(ctx, Item).findOne({ where: { // Multiple where clauses can be specified, // which are joined with AND name, deletedAt: IsNull(), }, }); } findWithRelations() { return this.connection.getRepository(ctx, Item).findOne({ where: { name }, relations: { // Join the `item.customer` relation customer: true, product: { // Here we are joining a nested relation `item.product.featuredAsset` featuredAsset: true, }, }, }); } findMany(ctx: RequestContext): Promise { return this.connection.getRepository(ctx, Item).find({ // Pagination skip: 0, take: 10, // Sorting order: { name: 'ASC', }, }); } } ``` :::info Further examples can be found in the [TypeORM Find Options documentation](https://typeorm.io/find-options). ::: ### The QueryBuilder API When the Find API is not sufficient, the QueryBuilder API can be used to construct more complex queries. For instance, if you want to have a more complex `WHERE` clause than what can be achieved with the Find API, or if you want to perform sub-queries, then the QueryBuilder API is the way to go. Here are some examples of using the QueryBuilder API: ```ts title="src/services/item.service.ts" import { Injectable } from '@nestjs/common'; import { ID, RequestContext, TransactionalConnection } from '@vendure/core'; import { Brackets, IsNull } from 'typeorm'; import { Item } from '../entities/item.entity'; @Injectable() export class ItemService { constructor(private connection: TransactionalConnection) {} findById(ctx: RequestContext, itemId: ID): Promise { // This is simple enough that you should prefer the Find API, // but here is how it would be done with the QueryBuilder API: return this.connection.getRepository(ctx, Item).createQueryBuilder('item') .where('item.id = :id', { id: itemId }) .getOne(); } findManyWithSubquery(ctx: RequestContext, name: string) { // Here's a more complex query that would not be possible using the Find API: return this.connection.getRepository(ctx, Item).createQueryBuilder('item') .where('item.name = :name', { name }) .andWhere( new Brackets(qb1 => { qb1.where('item.state = :state1', { state1: 'PENDING' }) .orWhere('item.state = :state2', { state2: 'RETRYING' }); }), ) .orderBy('item.createdAt', 'ASC') .getMany(); } } ``` :::info Further examples can be found in the [TypeORM QueryBuilder documentation](https://typeorm.io/select-query-builder). ::: ### Working with relations One limitation of TypeORM's typings is that we have no way of knowing at build-time whether a particular relation will be joined at runtime. For instance, the following code will build without issues, but will result in a runtime error: ```ts const product = await this.connection.getRepository(ctx, Product).findOne({ where: { id: productId }, }); if (product) { // highlight-start console.log(product.featuredAsset.preview); // ^ Error: Cannot read property 'preview' of undefined // highlight-end } ``` This is because the `featuredAsset` relation is not joined by default. The simple fix for the above example is to use the `relations` option: ```ts const product = await this.connection.getRepository(ctx, Product).findOne({ where: { id: productId }, // highlight-next-line relations: { featuredAsset: true }, }); ``` or in the case of the QueryBuilder API, we can use the `leftJoinAndSelect()` method: ```ts const product = await this.connection.getRepository(ctx, Product).createQueryBuilder('product') // highlight-next-line .leftJoinAndSelect('product.featuredAsset', 'featuredAsset') .where('product.id = :id', { id: productId }) .getOne(); ``` ### Using the EntityHydrator But what about when we do not control the code which fetches the entity from the database? For instance, we might be implementing a function which gets an entity passed to it by Vendure. In this case, we can use the [`EntityHydrator`](/reference/typescript-api/data-access/entity-hydrator/) to ensure that a given relation is "hydrated" (i.e. joined) before we use it: ```ts import { EntityHydrator, ShippingCalculator } from '@vendure/core'; let entityHydrator: EntityHydrator; const myShippingCalculator = new ShippingCalculator({ // ... rest of config omitted for brevity init(injector) { entityHydrator = injector.get(EntityHydrator); }, calculate: (ctx, order, args) => { // highlight-start // ensure that the customer and customer.groups relations are joined await entityHydrator.hydrate(ctx, order, { relations: ['customer.groups' ]}); // highlight-end if (order.customer?.groups?.some(g => g.name === 'VIP')) { // ... do something special for VIP customers } else { // ... do something else } }, }); ``` ### Joining relations in built-in service methods Many of the core services allow an optional `relations` argument in their `findOne()` and `findMany()` and related methods. This allows you to specify which relations should be joined when the query is executed. For instance, in the [`ProductService`](/reference/typescript-api/services/product-service) there is a `findOne()` method which allows you to specify which relations should be joined: ```ts const productWithAssets = await this.productService .findOne(ctx, productId, ['featuredAsset', 'assets']); ``` --- --- title: "Implementing Translatable" showtoc: true --- ## Defining translatable entities Making an entity translatable means that string properties of the entity can have a different values for multiple languages. To make an entity translatable, you need to implement the [`Translatable`](/reference/typescript-api/entities/interfaces/#translatable) interface and add a `translations` property to the entity. ```ts title="src/plugins/requests/entities/product-request.entity.ts" import { DeepPartial } from '@vendure/common/lib/shared-types'; import { VendureEntity, Product, EntityId, ID, Translatable } from '@vendure/core'; import { Column, Entity, ManyToOne } from 'typeorm'; import { ProductRequestTranslation } from './product-request-translation.entity'; @Entity() class ProductRequest extends VendureEntity implements Translatable { constructor(input?: DeepPartial) { super(input); } // highlight-start text: LocaleString; // highlight-end @ManyToOne(type => Product) product: Product; @EntityId() productId: ID; // highlight-start @OneToMany(() => ProductRequestTranslation, translation => translation.base, { eager: true }) translations: Array>; // highlight-end } ``` The `translations` property is a `OneToMany` relation to the translations. Any fields that are to be translated are of type `LocaleString`, and **do not have a `@Column()` decorator**. This is because the `text` field here does not in fact exist in the database in the `product_request` table. Instead, it belongs to the `product_request_translations` table of the `ProductRequestTranslation` entity: ```ts title="src/plugins/requests/entities/product-request-translation.entity.ts" import { DeepPartial } from '@vendure/common/lib/shared-types'; import { HasCustomFields, Translation, VendureEntity, LanguageCode } from '@vendure/core'; import { Column, Entity, Index, ManyToOne } from 'typeorm'; import { ProductRequest } from './release-note.entity'; @Entity() export class ProductRequestTranslation extends VendureEntity implements Translation, HasCustomFields { constructor(input?: DeepPartial>) { super(input); } @Column('varchar') languageCode: LanguageCode; @Column('varchar') // highlight-start text: string; // same name as the translatable field in the base entity // highlight-end @Index() @ManyToOne(() => ProductRequest, base => base.translations, { onDelete: 'CASCADE' }) base: ProductRequest; } ``` Thus there is a one-to-many relation between `ProductRequest` and `ProductRequestTranslation`, which allows Vendure to handle multiple translations of the same entity. The `ProductRequestTranslation` entity also implements the `Translation` interface, which requires the `languageCode` field and a reference to the base entity. ### Translations in the GraphQL schema Since the `text` field is getting hydrated with the translation it should be exposed in the GraphQL Schema. Additionally, the `ProductRequestTranslation` type should be defined as well, to access other translations as well: ```graphql title="src/plugins/requests/api/types.ts" type ProductRequestTranslation { id: ID! createdAt: DateTime! updatedAt: DateTime! // highlight-start languageCode: LanguageCode! text: String! // highlight-end } type ProductRequest implements Node { id: ID! createdAt: DateTime! updatedAt: DateTime! # Will be filled with the translation for the current language text: String! // highlight-next-line translations: [ProductRequestTranslation!]! } ``` ## Creating translatable entities Creating a translatable entity is usually done by using the [`TranslateableSaver`](/reference/typescript-api/service-helpers/translatable-saver/). This injectable service provides a `create` and `update` method which can be used to save or update a translatable entity. ```ts title="src/plugins/requests/service/product-request.service.ts" export class RequestService { constructor(private translatableSaver: TranslatableSaver) {} async create(ctx: RequestContext, input: CreateProductRequestInput): Promise { const request = await this.translatableSaver.create({ ctx, input, entityType: ProductRequest, translationType: ProductRequestTranslation, beforeSave: async f => { // Assign relations here }, }); return request; } } ``` Important for the creation of translatable entities is the input object. The input object should contain a `translations` array with the translations for the entity. This can be done by defining the types like `CreateRequestInput` inside the GraphQL schema: ```graphql title="src/plugins/requests/api/types.ts" input ProductRequestTranslationInput { # Only defined for update mutations id: ID // highlight-start languageCode: LanguageCode! text: String! // highlight-end } input CreateProductRequestInput { text: String! // highlight-next-line translations: [ProductRequestTranslationInput!]! } ``` ## Updating translatable entities Updating a translatable entity is done in a similar way as creating one. The [`TranslateableSaver`](/reference/typescript-api/service-helpers/translatable-saver/) provides an `update` method which can be used to update a translatable entity. ```ts title="src/plugins/requests/service/product-request.service.ts" export class RequestService { constructor(private translatableSaver: TranslatableSaver) {} async update(ctx: RequestContext, input: UpdateProductRequestInput): Promise { const updatedEntity = await this.translatableSaver.update({ ctx, input, entityType: ProductRequest, translationType: ProductRequestTranslation, beforeSave: async f => { // Assign relations here }, }); return updatedEntity; } } ``` Once again it's important to provide the `translations` array in the input object. This array should contain the translations for the entity. ```graphql title="src/plugins/requests/api/types.ts" input UpdateProductRequestInput { text: String // highlight-next-line translations: [ProductRequestTranslationInput!] } ``` ## Loading translatable entities If your plugin needs to load a translatable entity, you will need to use the [`TranslatorService`](/reference/typescript-api/service-helpers/translator-service/) to hydrate all the `LocaleString` fields will the actual translated values from the correct translation. ```ts title="src/plugins/requests/service/product-request.service.ts" export class RequestService { constructor(private translator: TranslatorService) {} findAll( ctx: RequestContext, options?: ListQueryOptions, relations?: RelationPaths, ): Promise>> { return this.listQueryBuilder .build(ProductRequest, options, { relations, ctx, }) .getManyAndCount() .then(([items, totalItems]) => { return { // highlight-next-line items: items.map(item => this.translator.translate(item, ctx)), totalItems, }; }); } findOne( ctx: RequestContext, id: ID, relations?: RelationPaths, ): Promise | null> { return this.connection .getRepository(ctx, ProductRequest) .findOne({ where: { id }, relations, }) // highlight-next-line .then(entity => entity && this.translator.translate(entity, ctx)); } } ``` --- --- title: "Translations" showtoc: true --- The following items in Vendure can be translated: - Entities which implement the [`Translatable`](/reference/typescript-api/entities/interfaces/#translatable) interface. - Admin UI text labels and messages - Server error message ## Translatable entities The following entities implement the `Translatable` interface: - [Collection](/reference/typescript-api/entities/collection/) - [Country](/reference/typescript-api/entities/country/) - [Facet](/reference/typescript-api/entities/facet/) - [FacetValue](/reference/typescript-api/entities/facet-value/) - [PaymentMethod](/reference/typescript-api/entities/payment-method/) - [Product](/reference/typescript-api/entities/product/) - [ProductOption](/reference/typescript-api/entities/product-option/) - [ProductOptionGroup](/reference/typescript-api/entities/product-option-group/) - [ProductVariant](/reference/typescript-api/entities/product-variant/) - [Promotion](/reference/typescript-api/entities/promotion/) - [Province](/reference/typescript-api/entities/province/) - [Region](/reference/typescript-api/entities/region/) - [ShippingMethod](/reference/typescript-api/entities/shipping-method/) To understand how translatable entities are implemented, let's take a look at a simplified version of the `Facet` entity: ```ts @Entity() export class Facet extends VendureEntity implements Translatable { // highlight-next-line name: LocaleString; @Column({ unique: true }) code: string; // highlight-next-line @OneToMany(type => FacetTranslation, translation => translation.base, { eager: true }) // highlight-next-line translations: Array>; } ``` All translatable entities have a `translations` field which is a relation to the translations. Any fields that are to be translated are of type `LocaleString`, and **do note have a `@Column()` decorator**. This is because the `name` field here does not in fact exist in the database in the `facet` table. Instead, it belongs to the `facet_translations` table, which brings us to the `FacetTranslation` entity (again simplified for clarity): ```ts @Entity() export class FacetTranslation extends VendureEntity implements Translation { @Column('varchar') languageCode: LanguageCode; // highlight-next-line @Column() name: string; @Index() @ManyToOne(type => Facet, base => base.translations, { onDelete: 'CASCADE' }) base: Facet; } ``` Thus there is a one-to-many relation between `Facet` and `FacetTranslation`, which allows Vendure to handle multiple translations of the same entity. The `FacetTranslation` entity also implements the `Translation` interface, which requires the `languageCode` field and a reference to the base entity. ### Loading translatable entities If your plugin needs to load a translatable entity, you will need to use the [`TranslatorService`](/reference/typescript-api/service-helpers/translator-service/) to hydrate all the `LocaleString` fields will the actual translated values from the correct translation. For example, if you are loading a `Facet` entity, you would do the following: ```ts import { Facet } from '@vendure/core'; import { LanguageCode, RequestContext, TranslatorService, TransactionalConnection } from '@vendure/core'; @Injectable() export class MyService { constructor(private connection: TransactionalConnection, private translator: TranslatorService) {} async getFacet(ctx: RequestContext, id: ID): Promise { const facet = await this.connection.getRepository(ctx, Facet).findOne(id); if (facet) { // highlight-next-line return this.translatorService.translate(facet, ctx); } } async getFacets(ctx: RequestContext): Promise { // highlight-next-line const facets = await this.connection.getRepository(ctx, Facet).find(); // highlight-next-line return Promise.all(facets.map(facet => this.translatorService.translate(facet, ctx))); } } ``` ## Admin UI translations See the [Adding Admin UI Translations guide](/guides/extending-the-admin-ui/adding-ui-translations/). ## Server message translations Let's say you've implemented some custom server-side functionality as part of a plugin. You may be returning custom errors or other messages. Here's how you can provide these messages in multiple languages. Using [`addTranslation`](/reference/typescript-api/common/i18n-service/#addtranslation) inside the `onApplicationBootstrap` ([Nestjs lifecycle hooks](https://docs.nestjs.com/fundamentals/lifecycle-events)) of a Plugin is the easiest way to add new translations. While Vendure is only using `error`, `errorResult` and `message` resource keys you are free to use your own. ### Translatable Error This example shows how to create a custom translatable error ```ts /** * Custom error class */ class CustomError extends ErrorResult { readonly __typename = 'CustomError'; readonly errorCode = 'CUSTOM_ERROR'; readonly message = 'CUSTOM_ERROR'; //< looks up errorResult.CUSTOM_ERROR } @VendurePlugin({ imports: [PluginCommonModule], providers: [I18nService], // ... }) export class TranslationTestPlugin implements OnApplicationBootstrap { constructor(private i18nService: I18nService) { } onApplicationBootstrap(): any { this.i18nService.addTranslation('en', { errorResult: { CUSTOM_ERROR: 'A custom error message', }, anything: { foo: 'bar' } }); this.i18nService.addTranslation('de', { errorResult: { CUSTOM_ERROR: 'Eine eigene Fehlermeldung', }, anything: { foo: 'bar' } }); } } ``` To receive an error in a specific language you need to use the `languageCode` query parameter `query(QUERY_WITH_ERROR_RESULT, { variables }, { languageCode: LanguageCode.de });` ### Use translations Vendure uses the internationalization-framework [i18next](https://www.i18next.com/). Therefore you are free to use the i18next translate function to [access keys](https://www.i18next.com/translation-function/essentials#accessing-keys) \ `i18next.t('error.any-message');` --- --- title: "Updating Vendure" showtoc: true --- # Updating Vendure This guide provides guidance for updating the Vendure core framework to a newer version. ## How to update First, check the [changelog](https://github.com/vendure-ecommerce/vendure/blob/master/CHANGELOG.md) for an overview of the changes and any breaking changes in the next version. In your project's `package.json` file, find all the `@vendure/...` packages and change the version to the latest. All the Vendure packages have the same version, and are all released together. ```diff { // ... "dependencies": { - "@vendure/common": "1.1.5", + "@vendure/common": "1.2.0", - "@vendure/core": "1.1.5", + "@vendure/core": "1.2.0", // etc. } } ``` Then run `npm install` or `yarn install` depending on which package manager you prefer. ## Admin UI changes If you are using UI extensions to create your own custom Admin UI using the [`compileUiExtensions`](/reference/admin-ui-api/ui-devkit/compile-ui-extensions/) function, then you'll need to **delete and re-compile your admin-ui directory after upgrading** (this is the directory specified by the [`outputPath`](/reference/admin-ui-api/ui-devkit/ui-extension-compiler-options#outputpath) property). If you also have an `.angular` directory in your project, you should delete this too after the update to ensure that any stale cached files are removed. ## Versioning Policy & Breaking changes Vendure generally follows the [SemVer convention](https://semver.org/) for version numbering. This means that breaking API changes will only be introduced with changes to the major version (the first of the 3 digits in the version). However, there are some exceptions to this rule: - In minor versions, (e.g. v2.0 to v2.1) we may update underlying dependencies to new major versions, which may in turn introduce breaking changes. These will be clearly noted in the changelog. - In minor versions we may also occasionally introduce non-destructive changes to the database schema. For instance, we may add a new column which would then require a database migration. We will _not_ introduce database schema changes that could potentially result in data loss in a minor version. Any instances of these exceptions will be clearly indicated in the [Changelog](https://github.com/vendure-ecommerce/vendure/blob/master/CHANGELOG.md). The reasoning for these exceptions is discussed in the [Versioning policy RFC](https://github.com/vendure-ecommerce/vendure/issues/1846). ### What kinds of breaking changes can be expected? Major version upgrades (e.g. v1.x to v2.x) can include: * Changes to the database schema * Changes to the GraphQL schema * Updates of major underlying libraries, such as upgrading NestJS to a new major version Every release will be accompanied by an entry in the [changelog](https://github.com/vendure-ecommerce/vendure/blob/master/CHANGELOG.md), listing the changes in that release. And breaking changes are clearly listed under a **BREAKING CHANGE** heading. ### Database migrations Database changes are one of the most common causes for breaking changes. In most cases, the changes are minor (such as the addition of a new column) and non-destructive (i.e. performing the migration has no risk of data loss). However, some more fundamental changes occasionally require a careful approach to database migration in order to preserve existing data. The key rule is **never run your production instance with the `synchronize` option set to `true`**. Doing so can cause inadvertent data loss in rare cases. For any database schema changes, it is advised to: 1. Read the changelog breaking changes entries to see what changes to expect 2. **Important:** Make a backup of your database! 3. Create a new database migration as described in the [Migrations guide](/guides/developer-guide/migrations/) 4. Manually check the migration script. In some cases manual action is needed to customize the script in order to correctly migrate your existing data. 5. Test the migration script against non-production data. 6. Only when you have verified that the migration works as expected, run it against your production database. ### GraphQL schema changes If you are using a code-generation tool (such as [graphql-code-generator](https://graphql-code-generator.com/)) for your custom plugins or storefront, it is a good idea to re-generate after upgrading, which will catch any errors caused by changes to the GraphQL schema. ### TypeScript API changes If you are using Vendure providers (services, JobQueue, EventBus etc.) in your custom plugins, you should look out for breakages caused by changes to those services. Major changes will be listed in the changelog, but occasionally internal changes may also impact your code. The best way to check whether this is the case is to build your entire project after upgrading, to see if any new TypeScript compiler errors emerge. --- --- title: "Uploading Files" showtoc: true --- # Uploading Files Vendure handles file uploads with the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). Internally, we use the [graphql-upload package](https://github.com/jaydenseric/graphql-upload). Once uploaded, a file is known as an [Asset](/guides/core-concepts/images-assets/). Assets are typically used for images, but can represent any kind of binary data such as PDF files or videos. ## Upload clients Here is a [list of client implementations](https://github.com/jaydenseric/graphql-multipart-request-spec#client) that will allow you to upload files using the spec. If you are using Apollo Client, then you should install the [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client) npm package. For testing, it is even possible to use a [plain curl request](https://github.com/jaydenseric/graphql-multipart-request-spec#single-file). ## The `createAssets` mutation The [createAssets mutation](/reference/graphql-api/admin/mutations/#createassets) in the Admin API is the only means of uploading files by default. Here's an example of how a file upload would look using the `apollo-upload-client` package: ```tsx import { gql, useMutation } from "@apollo/client"; const MUTATION = gql` mutation CreateAssets($input: [CreateAssetInput!]!) { createAssets(input: $input) { ... on Asset { id name fileSize } ... on ErrorResult { message } } } `; function UploadFile() { const [mutate] = useMutation(MUTATION); function onChange(event) { const {target} = event; if (target.validity.valid) { mutate({ variables: { input: Array.from(target.files).map((file) => ({file})); } }); } } return ; } ``` ## Custom upload mutations How about if you want to implement a custom mutation for file uploads? Let's take an example where we want to allow customers to set an avatar image. To do this, we'll add a [custom field](/guides/developer-guide/custom-fields/) to the Customer entity and then define a new mutation in the Shop API. ### Configuration Let's define a custom field to associate the avatar `Asset` with the `Customer` entity. To keep everything encapsulated, we'll do all of this in a [plugin](/guides/developer-guide/plugins/) ```ts title="src/plugins/customer-avatar/customer-avatar.plugin.ts" import { Asset, LanguageCode, PluginCommonModule, VendurePlugin } from '@vendure/core'; @VendurePlugin({ imports: [PluginCommonModule], configure: config => { // highlight-start config.customFields.Customer.push({ name: 'avatar', type: 'relation', label: [{languageCode: LanguageCode.en, value: 'Customer avatar'}], entity: Asset, nullable: true, }); // highlight-end return config; }, }) export class CustomerAvatarPlugin {} ``` ### Schema definition Next, we will define the schema for the mutation: ```ts title="src/plugins/customer-avatar/api/api-extensions.ts" import gql from 'graphql-tag'; export const shopApiExtensions = gql` extend type Mutation { setCustomerAvatar(file: Upload!): Asset }` ``` ### Resolver The resolver will make use of the built-in [AssetService](/reference/typescript-api/services/asset-service) to handle the processing of the uploaded file into an Asset. ```ts title="src/plugins/customer-avatar/api/customer-avatar.resolver.ts" import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { Asset } from '@vendure/common/lib/generated-types'; import { Allow, AssetService, Ctx, CustomerService, isGraphQlErrorResult, Permission, RequestContext, Transaction } from '@vendure/core'; @Resolver() export class CustomerAvatarResolver { constructor(private assetService: AssetService, private customerService: CustomerService) {} @Transaction() @Mutation() @Allow(Permission.Authenticated) async setCustomerAvatar( @Ctx() ctx: RequestContext, @Args() args: { file: any }, ): Promise { const userId = ctx.activeUserId; if (!userId) { return; } const customer = await this.customerService.findOneByUserId(ctx, userId); if (!customer) { return; } // Create an Asset from the uploaded file const asset = await this.assetService.create(ctx, { file: args.file, tags: ['avatar'], }); // Check to make sure there was no error when // creating the Asset if (isGraphQlErrorResult(asset)) { // MimeTypeError throw asset; } // Asset created correctly, so assign it as the // avatar of the current Customer await this.customerService.update(ctx, { id: customer.id, customFields: { avatarId: asset.id, }, }); return asset; } } ``` ### Complete Customer Avatar Plugin Let's put all these parts together into the plugin: ```ts import { Asset, PluginCommonModule, VendurePlugin } from '@vendure/core'; import { shopApiExtensions } from './api/api-extensions'; import { CustomerAvatarResolver } from './api/customer-avatar.resolver'; @VendurePlugin({ imports: [PluginCommonModule], shopApiExtensions: { schema: shopApiExtensions, resolvers: [CustomerAvatarResolver], }, configuration: config => { config.customFields.Customer.push({ name: 'avatar', type: 'relation', label: [{languageCode: LanguageCode.en, value: 'Customer avatar'}], entity: Asset, nullable: true, }); return config; }, }) export class CustomerAvatarPlugin { } ``` ### Uploading a Customer Avatar In our storefront, we would then upload a Customer's avatar like this: ```tsx import { gql, useMutation } from "@apollo/client"; const MUTATION = gql` mutation SetCustomerAvatar($file: Upload!) { setCustomerAvatar(file: $file) { id name fileSize } } `; function UploadAvatar() { const [mutate] = useMutation(MUTATION); function onChange(event) { const { target } = event; if (target.validity.valid && target.files.length === 1) { mutate({ variables: { file: target.files[0], } }); } } return ; } ``` --- --- title: 'Worker & Job Queue' sidebar_position: 5 --- The Vendure Worker is a Node.js process responsible for running computationally intensive or otherwise long-running tasks in the background. For example, updating a search index or sending emails. Running such tasks in the background allows the server to stay responsive, since a response can be returned immediately without waiting for the slower tasks to complete. Put another way, the Worker executes **jobs** which have been placed in the **job queue**. ![Worker & Job Queue](./worker-job-queue.webp) ## The worker The worker is started by calling the [`bootstrapWorker()`](/reference/typescript-api/worker/bootstrap-worker/) function with the same configuration as is passed to the main server `bootstrap()`. In a standard Vendure installation, this is found in the `index-worker.ts` file: ```ts title="src/index-worker.ts" import { bootstrapWorker } from '@vendure/core'; import { config } from './vendure-config'; bootstrapWorker(config) .then(worker => worker.startJobQueue()) .catch(err => { console.log(err); }); ``` ### Underlying architecture The Worker is a NestJS standalone application. This means it is almost identical to the main server app, but does not have any network layer listening for requests. The server communicates with the worker via a “job queue” architecture. The exact implementation of the job queue is dependent on the configured [`JobQueueStrategy`](/reference/typescript-api/job-queue/job-queue-strategy/), but by default the worker polls the database for new jobs. ### Multiple workers It is possible to run multiple workers in parallel to better handle heavy loads. Using the [`JobQueueOptions.activeQueues`](/reference/typescript-api/job-queue/job-queue-options#activequeues) configuration, it is even possible to have particular workers dedicated to one or more specific types of jobs. For example, if your application does video transcoding, you might want to set up a dedicated worker just for that task: ```ts title="src/transcoder-worker.ts" import { bootstrapWorker, mergeConfig } from '@vendure/core'; import { config } from './vendure-config'; const transcoderConfig = mergeConfig(config, { jobQueueOptions: { activeQueues: ['transcode-video'], } }); bootstrapWorker(transcoderConfig) .then(worker => worker.startJobQueue()) .catch(err => { console.log(err); }); ``` ### Running jobs on the main process It is possible to run jobs from the Job Queue on the main server. This is mainly used for testing and automated tasks, and is not advised for production use, since it negates the benefits of running long tasks off of the main process. To do so, you need to manually start the JobQueueService: ```ts title="src/index.ts" import { bootstrap, JobQueueService } from '@vendure/core'; import { config } from './vendure-config'; bootstrap(config) .then(app => app.get(JobQueueService).start()) .catch(err => { console.log(err); process.exit(1); }); ``` ### ProcessContext Sometimes your code may need to be aware of whether it is being run as part of a server or worker process. In this case you can inject the [`ProcessContext`](/reference/typescript-api/common/process-context/) provider and query it like this: ```ts title="src/plugins/my-plugin/services/my.service.ts" import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { ProcessContext } from '@vendure/core'; @Injectable() export class MyService implements OnApplicationBootstrap { constructor(private processContext: ProcessContext) {} onApplicationBootstrap() { if (this.processContext.isServer) { // code which will only execute when running in // the server process } } } ``` ## The job queue Vendure uses a [job queue](https://en.wikipedia.org/wiki/Job_queue) to handle the processing of certain tasks which are typically too slow to run in the normal request-response cycle. A normal request-response looks like this: ![Regular request response](./Vendure_docs-job-queue.webp) In the normal request-response, all intermediate tasks (looking up data in the database, performing business logic etc.) occur before the response can be returned. For most operations this is fine, since those intermediate tasks are very fast. Some operations however will need to perform much longer-running tasks. For example, updating the search index on thousands of products could take up to a minute or more. In this case, we certainly don’t want to delay the response until that processing has completed. That’s where a job queue comes in: ![Request response with job queue](./Vendure_docs-job-queue-2.webp) ### What does Vendure use the job queue for? By default, Vendure uses the job queue for the following tasks: - Re-building the search index - Updating the search index when changes are made to Products, ProductVariants, Assets etc. - Updating the contents of Collections - Sending transactional emails ### How does the Job Queue work? This diagram illustrates the job queue mechanism: ![Job queue sequence](./Vendure_docs-job-queue-3.webp) The server adds jobs to the queue. The worker then picks up these jobs from the queue and processes them in sequence, one by one (it is possible to increase job queue throughput by running multiple workers or by increasing the concurrency of a single worker). ### JobQueueStrategy The actual queue part is defined by the configured [`JobQueueStrategy`](/reference/typescript-api/job-queue/job-queue-strategy/). If no strategy is defined, Vendure uses an [in-memory store](/reference/typescript-api/job-queue/in-memory-job-queue-strategy/) of the contents of each queue. While this has the advantage of requiring no external dependencies, it is not suitable for production because when the server is stopped, the entire queue will be lost and any pending jobs will never be processed. Moreover, it cannot be used when running the worker as a separate process. A better alternative is to use the [DefaultJobQueuePlugin](/reference/typescript-api/job-queue/default-job-queue-plugin/) (which will be used in a standard `@vendure/create` installation), which configures Vendure to use the [SqlJobQueueStrategy](/reference/typescript-api/job-queue/sql-job-queue-strategy). This strategy uses the database as a queue, and means that even if the Vendure server stops, pending jobs will be persisted and upon re-start, they will be processed. It is also possible to implement your own JobQueueStrategy to take advantage of other technologies. Examples include RabbitMQ, Google Cloud Pub Sub & Amazon SQS. It may make sense to implement a custom strategy based on one of these if the default database-based approach does not meet your performance requirements. ### Job Queue Performance It is common for larger Vendure projects to define multiple custom job queues, When using the [DefaultJobQueuePlugin](/reference/typescript-api/job-queue/default-job-queue-plugin/) with many queues, performance may be impacted. This is because the `SqlJobQueueStrategy` uses polling to check for new jobs in the database. Each queue will (by default) query the database every 200ms. So if there are 10 queues, this will result in a constant 50 queries/second. In this case it is recommended to try the [BullMQJobQueuePlugin](/reference/core-plugins/job-queue-plugin/bull-mqjob-queue-plugin/), which uses an efficient push-based strategy built on Redis. ## Using Job Queues in a plugin If your plugin involves long-running tasks, you can also make use of the job queue. :::info A real example of this can be seen in the [EmailPlugin source](https://github.com/vendure-ecommerce/vendure/blob/master/packages/email-plugin/src/plugin.ts) ::: Let's say you are building a plugin which allows a video URL to be specified, and then that video gets transcoded into a format suitable for streaming on the storefront. This is a long-running task which should not block the main thread, so we will use the job queue to run the task on the worker. First we'll add a new mutation to the Admin API schema: ```ts title="src/plugins/product-video/api/api-extensions.ts" import gql from 'graphql-tag'; export const adminApiExtensions = gql` extend type Mutation { addVideoToProduct(productId: ID! videoUrl: String!): Job! } `; ``` The resolver looks like this: ```ts title="src/plugins/product-video/api/product-video.resolver.ts" import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { Allow, Ctx, RequestContext, Permission, RequestContext } from '@vendure/core' import { ProductVideoService } from '../services/product-video.service'; @Resolver() export class ProductVideoResolver { constructor(private productVideoService: ProductVideoService) {} @Mutation() @Allow(Permission.UpdateProduct) addVideoToProduct(@Ctx() ctx: RequestContext, @Args() args: { productId: ID; videoUrl: string; }) { return this.productVideoService.transcodeForProduct( args.productId, args.videoUrl, ); } } ``` The resolver just defines how to handle the new `addVideoToProduct` mutation, delegating the actual work to the `ProductVideoService`. ### Creating a job queue :::cli Use `npx vendure add` to easily add a job queue to a service. ::: The [`JobQueueService`](/reference/typescript-api/job-queue/job-queue-service/) creates and manages job queues. The queue is created when the application starts up (see [NestJS lifecycle events](https://docs.nestjs.com/fundamentals/lifecycle-events)), and then we can use the `add()` method to add jobs to the queue. ```ts title="src/plugins/product-video/services/product-video.service.ts" import { Injectable, OnModuleInit } from '@nestjs/common'; import { JobQueue, JobQueueService, ID, Product, TransactionalConnection } from '@vendure/core'; import { transcode } from 'third-party-video-sdk'; @Injectable() class ProductVideoService implements OnModuleInit { private jobQueue: JobQueue<{ productId: ID; videoUrl: string; }>; constructor(private jobQueueService: JobQueueService, private connection: TransactionalConnection) { } async onModuleInit() { this.jobQueue = await this.jobQueueService.createQueue({ name: 'transcode-video', process: async job => { // Inside the `process` function we define how each job // in the queue will be processed. // In this case we call out to some imaginary 3rd-party video // transcoding API, which performs the work and then // returns a new URL of the transcoded video, which we can then // associate with the Product via the customFields. const result = await transcode(job.data.videoUrl); await this.connection.getRepository(Product).save({ id: job.data.productId, customFields: { videoUrl: result.url, }, }); // The value returned from the `process` function is stored as the "result" // field of the job (for those JobQueueStrategies that support recording of results). // // Any error thrown from this function will cause the job to fail. return result; }, }); } transcodeForProduct(productId: ID, videoUrl: string) { // Add a new job to the queue and immediately return the // job itself. return this.jobQueue.add({productId, videoUrl}, {retries: 2}); } } ``` Notice the generic type parameter of the `JobQueue`: ```ts JobQueue<{ productId: ID; videoUrl: string; }> ``` This means that when we call `jobQueue.add()` we must pass in an object of this type. This data will then be available in the `process` function as the `job.data` property. :::note The data passed to `jobQueue.add()` must be JSON-serializable, because it gets serialized into a string when stored in the job queue. Therefore you should avoid passing in complex objects such as `Date` instances, `Buffer`s, etc. ::: The `ProductVideoService` is in charge of setting up the JobQueue and adding jobs to that queue. Calling ```ts productVideoService.transcodeForProduct(id, url); ``` will add a transcoding job to the queue. :::tip Plugin code typically gets executed on both the server _and_ the worker. Therefore, you sometimes need to explicitly check what context you are in. This can be done with the [ProcessContext](/reference/typescript-api/common/process-context/) provider. ::: Finally, the `ProductVideoPlugin` brings it all together, extending the GraphQL API, defining the required CustomField to store the transcoded video URL, and registering our service and resolver. The [PluginCommonModule](/reference/typescript-api/plugin/plugin-common-module/) is imported as it exports the `JobQueueService`. ```ts title="src/plugins/product-video/product-video.plugin.ts" import gql from 'graphql-tag'; import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { ProductVideoService } from './services/product-video.service'; import { ProductVideoResolver } from './api/product-video.resolver'; import { adminApiExtensions } from './api/api-extensions'; @VendurePlugin({ imports: [PluginCommonModule], providers: [ProductVideoService], adminApiExtensions: { schema: adminApiExtensions, resolvers: [ProductVideoResolver] }, configuration: config => { config.customFields.Product.push({ name: 'videoUrl', type: 'string', }); return config; } }) export class ProductVideoPlugin {} ``` ### Passing the RequestContext Sometimes you need to pass the [RequestContext object](/reference/typescript-api/request/request-context) to the `process` function of a job, since `ctx` is required by many Vendure service methods that you may be using inside your `process` function. However, the `RequestContext` object itself is not serializable, so it cannot be passed directly to the `JobQueue.add()` method. Instead, you can serialize the `RequestContext` using the [`ctx.serialize()` method](/reference/typescript-api/request/request-context/#serialize), and then deserialize it in the `process` function using the static `deserialize` method. ```ts import { Injectable, OnModuleInit } from '@nestjs/common'; import { JobQueue, JobQueueService, Product, TransactionalConnection, SerializedRequestContext, RequestContext } from '@vendure/core'; @Injectable() class ProductExportService implements OnModuleInit { // highlight-next-line private jobQueue: JobQueue<{ ctx: SerializedRequestContext; }>; constructor(private jobQueueService: JobQueueService, private connection: TransactionalConnection) { } async onModuleInit() { this.jobQueue = await this.jobQueueService.createQueue({ name: 'export-products', process: async job => { // highlight-next-line const ctx = RequestContext.deserialize(job.data.ctx); const allProducts = await this.connection.getRepository(ctx, Product).find(); // ... logic to export the product omitted for brevity }, }); } exportAllProducts(ctx: RequestContext) { // highlight-next-line return this.jobQueue.add({ ctx: ctx.serialize() }); } } ``` :::warning Serializing the RequestContext should be done with caution, since it is a relatively large object and will significantly increase the size of the job data. In cases where the job is created in large quantities (hundreds or thousands of jobs per day), this can lead to performance issues. Especially when using the [BullMQJobQueuePlugin](/reference/core-plugins/job-queue-plugin/bull-mqjob-queue-plugin/), which stores the job data in Redis, the size of the job data can lead to too much memory usage which can cause the Redis server to crash. ::: Instead of serializing the entire RequestContext, consider passing only the necessary data you need and then reconstructing the RequestContext in the `process` function: ```ts import { Injectable, OnModuleInit } from '@nestjs/common'; import { JobQueue, JobQueueService, RequestContext, ID, LanguageCode, RequestContextService } from '@vendure/core'; @Injectable() class ProductExportService implements OnModuleInit { // highlight-next-line private jobQueue: JobQueue<{ channelToken: string; languageCode: LanguageCode; }>; constructor(private jobQueueService: JobQueueService, private requestContextService: RequestContextService) { } async onModuleInit() { this.jobQueue = await this.jobQueueService.createQueue({ name: 'export-products', process: async job => { // highlight-start // Reconstruct the RequestContext from the passed data const ctx = await this.requestContextService.create({ channelOrToken: job.data.channelToken, languageCode: job.data.languageCode, }) // highlight-end // ... logic to export the product omitted for brevity }, }); } exportAllProducts(ctx: RequestContext) { // highlight-start // Pass only the necessary data return this.jobQueue.add({ channelId: ctx.channel.token, languageCode: ctx.languageCode }); // highlight-end } } ``` ### Handling job cancellation It is possible for an administrator to cancel a running job. Doing so will cause the configured job queue strategy to mark the job as cancelled, but on its own this will not stop the job from running. This is because the job queue itself has no direct control over the `process` function once it has been started. It is up to the `process` function to check for cancellation and stop processing if the job has been cancelled. This can be done by checking the `job.state` property, and if the job is cancelled, the `process` function can throw an error to indicate that the job was interrupted by early cancellation: ```ts import { Injectable, OnModuleInit } from '@nestjs/common'; import { JobQueue, JobQueueService, Product, TransactionalConnection, SerializedRequestContext, RequestContext } from '@vendure/core'; import { JobState } from '@vendure/common/lib/generated-types'; import { IsNull } from 'typeorm'; @Injectable() class ProductExportService implements OnModuleInit { private jobQueue: JobQueue<{ ctx: SerializedRequestContext; }>; constructor(private jobQueueService: JobQueueService, private connection: TransactionalConnection) { } async onModuleInit() { this.jobQueue = await this.jobQueueService.createQueue({ name: 'export-products', process: async job => { const ctx = RequestContext.deserialize(job.data.ctx); const allProducts = await this.connection.getRepository(ctx, Product).find({ where: { deletedAt: IsNull() } }); let successfulExportCount = 0; for (const product of allProducts) { // highlight-start if (job.state === JobState.CANCELLED) { // If the job has been cancelled, stop processing // to prevent unnecessary work. throw new Error('Job was cancelled'); } // highlight-end // ... logic to export the product omitted for brevity successfulExportCount++; } return { successfulExportCount }; }, }); } exportAllProducts(ctx: RequestContext) { return this.jobQueue.add({ ctx: ctx.serialize() }); } } ``` ### Subscribing to job updates When creating a new job via `JobQueue.add()`, it is possible to subscribe to updates to that Job (progress and status changes). This allows you, for example, to create resolvers which are able to return the results of a given Job. In the video transcoding example above, we could modify the `transcodeForProduct()` call to look like this: ```ts title="src/plugins/product-video/services/product-video.service.ts" import { Injectable, OnModuleInit } from '@nestjs/common'; import { of } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; import { ID, Product, TransactionalConnection } from '@vendure/core'; @Injectable() class ProductVideoService implements OnModuleInit { // ... omitted (see above) transcodeForProduct(productId: ID, videoUrl: string) { const job = await this.jobQueue.add({productId, videoUrl}, {retries: 2}); return job.updates().pipe( map(update => { // The returned Observable will emit a value for every update to the job // such as when the `progress` or `status` value changes. Logger.info(`Job ${update.id}: progress: ${update.progress}`); if (update.state === JobState.COMPLETED) { Logger.info(`COMPLETED ${update.id}: ${update.result}`); } return update.result; }), catchError(err => of(err.message)), ); } } ``` If you prefer to work with Promises rather than Rxjs Observables, you can also convert the updates to a promise: ```ts const job = await this.jobQueue.add({ productId, videoUrl }, { retries: 2 }); return job.updates().toPromise() .then(/* ... */) .catch(/* ... */); ``` --- --- title: 'Page ActionBar Buttons' weight: 5 --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; The `ActionBar` is the horizontal area at the top of each list or detail page, which contains the main buttons for that page. This guide explains how to add new buttons and dropdown menu items to the ActionBar. For example, consider an "order invoice" extension that allows you to print invoices for orders. You can add a "print invoice" button to the ActionBar of the order detail page, either as a button or as a dropdown menu item. ## ActionBar button example Adding a button is done using the [`addActionBarItem`](/reference/admin-ui-api/action-bar/add-action-bar-item/) function. ```ts title="src/plugins/invoice/ui/providers.ts" import { addActionBarItem } from '@vendure/admin-ui/core'; export default [ addActionBarItem({ id: 'print-invoice', locationId: 'order-detail', label: 'Print invoice', icon: 'printer', routerLink: route => { const id = route.snapshot.params.id; return ['./extensions/order-invoices', id]; }, requiresPermission: 'ReadOrder', }), ]; ``` ![./ui-extensions-actionbar.webp](./ui-extensions-actionbar.webp) In each list or detail view in the app, the ActionBar has a unique `locationId` which is how the app knows in which view to place your button. The complete list of available locations into which you can add new ActionBar can be found in the [PageLocationId docs](/reference/admin-ui-api/action-bar/page-location-id/). You can also press `ctrl + u` when in development mode to see the location of all UI extension points. ## ActionBar dropdown menu example Vendure v2.2.0 introduced the ability to add dropdown menu items to the ActionBar. If you want to add an action which is less commonly used, or want to take up less space in the action bar, then a dropdown menu item is a good choice. This is done using the [`addActionBarDropdownMenuItem`](/reference/admin-ui-api/action-bar/add-action-bar-dropdown-menu-item/) function. Let's re-work the "print invoice" button example to display it instead as a dropdown menu item: ```ts title="src/plugins/invoice/ui/providers.ts" import { addActionBarDropdownMenuItem } from '@vendure/admin-ui/core'; export default [ addActionBarDropdownMenuItem({ id: 'print-invoice', locationId: 'order-detail', label: 'Print invoice', icon: 'printer', routerLink: route => { const id = route.snapshot.params.id; return ['./extensions/order-invoices', id]; }, requiresPermission: 'ReadOrder', // When set to `true`, a horizontal divider will be // displayed above this item in the dropdown menu. hasDivider: true, }), ]; ``` ![./ui-extensions-actionbar-dropdown.webp](./ui-extensions-actionbar-dropdown.webp) ## Handling button clicks There are two ways to handle the click event of an ActionBar button or dropdown menu item: 1. Use the `routerLink` property to navigate to a new route when the button is clicked. 2. Use the `onClick` property to execute a function when the button is clicked. ### Using routerLink The `routerLink` property allows you to specify a route to navigate to when the button is clicked. The route can be a constant value, or it can be a function which receives the current route as well as a [`context` object](/reference/admin-ui-api/action-bar/action-bar-context) as arguments. ```ts title="src/plugins/invoice/ui/providers.ts" import { addActionBarItem } from '@vendure/admin-ui/core'; export default [ addActionBarItem({ id: 'print-invoice', label: 'Print invoice', locationId: 'order-detail', // highlight-start // The route can be a constant value... routerLink: ['./extensions/order-invoices'], // highlight-end }), ]; ``` ```ts title="src/plugins/invoice/ui/providers.ts" import { addActionBarItem } from '@vendure/admin-ui/core'; export default [ addActionBarItem({ id: 'print-invoice', label: 'Print invoice', locationId: 'order-detail', // highlight-start // The route can be a function routerLink: route => { const id = route.snapshot.params.id; return ['./extensions/order-invoices', id]; }, // highlight-end }), ]; ``` ### Using onClick The onClick property of the addActionBarItem function allows you to define a function that will be executed when the ActionBar button is clicked. This function receives two arguments: the click event and the current context. The context object provides access to commonly-used services, which allows you to perform GraphQL queries and mutations, and the current route, which can be used to get parameters from the URL. Here's an example of how to use the onClick property to perform a GraphQL mutation when the ActionBar button is clicked: ```ts title="src/plugins/invoice/ui/providers.ts" import gql from 'graphql-tag'; import { firstValueFrom } from 'rxjs'; import { addActionBarItem } from '@vendure/admin-ui/core'; const mutation = gql` mutation MyMutation($orderId: ID!) { myMutation(orderId: $orderId) } `; export default [ addActionBarItem({ id: 'myButtonId', label: 'My Button Label', locationId: 'order-detail', // highlight-start onClick: async (event, context) => { try { const orderId = context.route.snapshot.params.id; await firstValueFrom(context.dataService.mutate(mutation, { orderId })); } catch (error) { context.notificationService.error('Error executing mutation: ' + error.message); } }, // highlight-end }), ]; ``` In this example, clicking the ActionBar button triggers a GraphQL mutation. The `context.dataService` is utilized to execute the mutation. It can also be employed to retrieve additional information about the current order if needed. The `context.route` is used to extract the ID of the current order from the URL. The utility function `firstValueFrom` from the RxJS library is used in this example to convert the Observable returned by `context.dataService.mutate(...)` into a Promise. This conversion allows the use of the `await` keyword to pause execution until the Observable emits its first value or completes. ## Setting visibility & disabled state Use the `buttonState` property (added in v2.1) to control the visibility and disabled state of the button. This property is a function which receives the current context as an argument and returns an Observable of the button state: ```ts title="src/plugins/invoice/ui/providers.ts" import { map, switchMap } from 'rxjs/operators'; import { addActionBarItem } from '@vendure/admin-ui/core'; export default [ addActionBarItem({ id: 'print-invoice', label: 'Print invoice', locationId: 'order-detail', buttonState: context => { // For any of the detail pages, we can get an observable stream // of the entity via `context.entity$`: return context.entity$.pipe( map(order => ({ disabled: order?.state === 'AddingItems', visible: true, })), ); }, }), ]; ``` :::note The `context.entity$` property was introduced in Vendure v2.2. If you are using v2.1, you can achieve equivalent functionality with this code: ```ts buttonState: context => { return context.route.data.pipe( switchMap(data => data.detail.entity), map((order: any) => ({ disabled: order.state === 'AddingItems', visible: true, })), ); } ``` ::: ## Restricting access by permissions You can use the `requiresPermission` property to restrict access to the button by permission. This property accepts a single permission string or an array of permission strings. If the current user does not have the required permission, the button will not be visible. ```ts title="src/plugins/invoice/ui/providers.ts" import { addActionBarItem } from '@vendure/admin-ui/core'; export default [ addActionBarItem({ id: 'print-invoice', label: 'Print invoice', locationId: 'order-detail', routerLink: ['./extensions/order-invoices'], // highlight-next-line requiresPermission: 'CreateInvoice', }), ]; ``` --- --- title: 'Adding UI Translations' --- The Vendure Admin UI is fully localizable, allowing you to: * create custom translations for your UI extensions * override existing translations * add complete translations for whole new languages ![The UI language is set from the User menu](./ui-translations-01.webp) ## Translation format The Admin UI uses the [Messageformat](https://messageformat.github.io/messageformat/) specification to convert i18n tokens to localized strings in the browser. Each language should have a corresponding JSON file containing the translations for that language. Here is an excerpt from the `en.json` file that ships with the Admin UI: ```JSON title="en.json" { "admin": { "create-new-administrator": "Create new administrator" }, "asset": { "add-asset": "Add asset", "add-asset-with-count": "Add {count, plural, 0 {assets} one {1 asset} other {{count} assets}}", "assets-selected-count": "{ count } assets selected", "dimensions": "Dimensions" } } ``` The translation tokens are grouped into a single-level deep nested structure. In the Angular code, these are referenced like this: ```HTML ``` That is, the `{ ... }` represent variables that are passed from the application code and interpolated into the final localized string. ## Adding a new language The Admin UI ships with built-in support for many languages, but allows you to add support for any other language without the need to modify the package internals. 1. **Create your translation file** Start by copying the contents of the [English language file](https://github.com/vendure-ecommerce/vendure/blob/master/packages/admin-ui/src/lib/static/i18n-messages/en.json) into a new file, `.json`, where `languageCode` is the 2-character [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for the language. Replace the strings with the translation for the new language. 2. **Install `@vendure/ui-devkit`** If not already installed, install the `@vendure/ui-devkit` package, which allows you to create custom builds of the Admin UI. 3. **Register the translation file** Here's a minimal directory structure and sample code to add your new translation: ```text /src ├─ vendure-config.ts └─ translations/ └─ ms.json ``` And the config code to register the translation file: ```ts title="src.vendure-config.ts" import path from 'path'; import { VendureConfig } from '@vendure/core'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ port: 3002, app: compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [{ translations: { ms: path.join(__dirname, 'translations/ms.json'), } }], }), adminUiConfig:{ defaultLanguage: LanguageCode.ms, availableLanguages: [LanguageCode.ms, LanguageCode.en], } }), ], }; ``` ## Translating UI Extensions You can also create translations for your own UI extensions, in much the same way as outlined above in "Adding a new language". Your translations can be split over several files, since the `translations` config object can take a glob, e.g.: ```ts translations: { de: path.join(__dirname, 'ui-extensions/my-extension/**/*.de.json'), } ``` This allows you, if you wish, to co-locate your translation files with your components. Care should be taken to uniquely namespace your translation tokens, as conflicts with the base translation file will cause your translations to overwrite the defaults. This can be solved by using a unique section name, e.g.: ```JSON { "my-reviews-plugin": { "all-reviews": "All reviews", "approve review": "Approve review" } } ``` --- --- title: 'Admin UI Theming & Branding' --- The Vendure Admin UI can be themed to your company's style and branding. ## AdminUiPlugin branding settings The `AdminUiPlugin` allows you to specify your "brand" name, and allows you to control whether to display the Vendure name and version in the UI. Specifying a brand name will also set it as the title of the Admin UI in the browser. ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ // ... adminUiConfig:{ brand: 'My Store', hideVendureBranding: false, hideVersion: false, } }), ], }; ``` :::note For the simple level of branding shown above, the `@vendure/ui-devkit` package is not required. ::: ## Specifying custom logos You can replace the Vendure logos and favicon with your own brand logo: 1. Install `@vendure/ui-devkit` 2. Configure the AdminUiPlugin to compile a custom build featuring your logos: ```ts title="src/vendure-config.ts" import path from 'path'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { VendureConfig } from '@vendure/core'; import { compileUiExtensions, setBranding } from '@vendure/ui-devkit/compiler'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ app: compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [ setBranding({ // The small logo appears in the top left of the screen smallLogoPath: path.join(__dirname, 'images/my-logo-sm.png'), // The large logo is used on the login page largeLogoPath: path.join(__dirname, 'images/my-logo-lg.png'), faviconPath: path.join(__dirname, 'images/my-favicon.ico'), }), ], }), }), ], } ``` ## Theming Much of the visual styling of the Admin UI can be customized by providing your own themes in a Sass stylesheet. For the most part, the Admin UI uses [CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/--*) to control colors and other styles. Here's a simple example which changes the color of links: 1. Install `@vendure/ui-devkit` 2. Create a custom stylesheet which overrides one or more of the CSS custom properties used in the Admin UI: ```css title="my-theme.scss" :root { --clr-link-active-color: hsl(110, 65%, 57%); --clr-link-color: hsl(110, 65%, 57%); --clr-link-hover-color: hsl(110, 65%, 57%); --clr-link-visited-color: hsl(110, 55%, 75%); } ``` To get an idea of which custom properties are available for theming, take a look at the source of the [Default theme](https://github.com/vendure-ecommerce/vendure/tree/master/packages/admin-ui/src/lib/static/styles/theme/default.scss) and the [Dark theme](https://github.com/vendure-ecommerce/vendure/tree/master/packages/admin-ui/src/lib/static/styles/theme/dark.scss) 3. Set this as a globalStyles extension: ```ts title="src/vendure-config.ts" import path from 'path'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { VendureConfig } from '@vendure/core'; import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ app: compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [{ globalStyles: path.join(__dirname, 'my-theme.scss') }], }), }), ], } ``` Some customizable styles in [Clarity](https://clarity.design/), the Admin UI's underlying UI framework, are controlled by Sass variables, which can be found on the [project's GitHub page](https://github.com/vmware-clarity/ng-clarity/blob/689a572344149aea90df1676eae04479795754f3/projects/angular/src/utils/_variables.clarity.scss). Similar to above, you can also provide your own values, which will override defaults set by the framework. Here's an example which changes the [height of the main header](https://github.com/vmware-clarity/ng-clarity/blob/689a572344149aea90df1676eae04479795754f3/projects/angular/src/layout/main-container/_variables.header.scss#L10): 1. Install `@vendure/ui-devkit` if not already installed 2. Create a custom stylesheet which overrides the target variable(s): ```css title="my-variables.scss" $clr-header-height: 4rem; ``` 3. Set this as a `sassVariableOverrides` extension: ```ts title="src/vendure-config.ts" import path from 'path'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { VendureConfig } from '@vendure/core'; import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ app: compileUiExtensions({ outputPath: path.join(__dirname, 'admin-ui'), extensions: [{ sassVariableOverrides: path.join(__dirname, 'my-variables.scss') }], }), }), ], } ``` `globalStyles` and `sassVariableOverrides` extensions can be used in conjunction or separately. --- --- title: 'Alerts' --- Alerts appear in the top bar of the Admin UI and provide a way of notifying the administrator of important information that may require action. You can define custom alerts with the [`registerAlert` function](/reference/admin-ui-api/alerts/register-alert/). Let's say you have a custom order process where certain orders require manual review & approval. You could define an alert to notify the administrator when there are orders that require review: ```ts title="src/plugins/manual-order-review/ui/providers.ts" import { registerAlert } from '@vendure/admin-ui/core'; import { Router } from '@angular/router'; import { interval } from 'rxjs'; import { ManualOrderReviewService } from './providers/manual-order-review.service'; export default [ ManualOrderReviewService, registerAlert({ id: 'orders-require-approval', // This function is responsible for fetching the data needed to determine // whether the alert should be displayed. check: ({ injector }) => { const manualOrderReviewService = injector.get(ManualOrderReviewService); return manualOrderReviewService.getOrdersRequiringApproval() .then(orders => orders.length); }, // This function is responsible for determining whether and how often the // `check` function should be called. In this case, we will check every 60 seconds. recheck: () => interval(60_000), // This function gets passed the data returned by the `check` function and // should return `true` if the alert should be displayed. isAlert: orderCount => orderCount > 0, // This function is called when the alert is clicked. Here, we will navigate to // a new route to display the orders requiring approval. action: (orderCount, { injector }) => { injector.get(Router).navigate(['/extensions/manual-order-review']); }, // This function is called to determine the label of the alert. label: (orderCount) => ({ text: `${orderCount} ${orderCount === 1 ? 'order' : 'orders'} require approval`, }), }), ]; ``` With this example, a check will be performed every 60 seconds to see if there are any orders requiring approval. The actual implementation of the check is left to the `ManualOrderReviewService` which in this case would make a request to the Vendure server to fetch the required data. If there are orders requiring approval, the alert will appear in the Admin UI like this: ![Alerts](./alerts-01.webp) --- --- title: 'Bulk Actions for List Views' --- List views in the Admin UI support bulk actions, which are performed on any selected items in the list. There are a default set of bulk actions that are defined by the Admin UI itself (e.g. delete, assign to channels), but using the `@vendure/ui-devit` package you are also able to define your own bulk actions. ![./bulk-actions-screenshot.webp](./bulk-actions-screenshot.webp) Use cases for bulk actions include things like: - Sending multiple products to a 3rd-party localization service - Exporting selected products to csv - Bulk-updating custom field data ### Bulk Action Example Bulk actions are declared using the [`registerBulkAction` function](/reference/admin-ui-api/bulk-actions/register-bulk-action/) ```ts title="src/plugins/translation/ui/providers.ts" import { ModalService, registerBulkAction } from '@vendure/admin-ui/core'; import { ProductDataTranslationService } from './product-data-translation.service'; export default [ ProductDataTranslationService, // Here is where we define our bulk action // for sending the selected products to a 3rd-party // translation API registerBulkAction({ // This tells the Admin UI that this bulk action should be made // available on the product list view. location: 'product-list', label: 'Send to translation service', icon: 'language', // Here is the logic that is executed when the bulk action menu item // is clicked. onClick: ({injector, selection}) => { const modalService = injector.get(ModalService); const translationService = injector.get(ProductDataTranslationService); modalService .dialog({ title: `Send ${selection.length} products for translation?`, buttons: [ {type: 'secondary', label: 'cancel'}, {type: 'primary', label: 'send', returnValue: true}, ], }) .subscribe(response => { if (response) { translationService.sendForTranslation(selection.map(item => item.productId)); } }); }, }), ]; ``` ### Conditionally displaying bulk actions Sometimes a bulk action only makes sense in certain circumstances. For example, the "assign to channel" action only makes sense when your server has multiple channels set up. We can conditionally control the display of a bulk action with the `isVisible` function, which should return a Promise resolving to a boolean: ```ts title="src/plugins/my-plugin/ui/providers.ts" import { registerBulkAction, DataService } from '@vendure/admin-ui/core'; export default [ registerBulkAction({ location: 'product-list', label: 'Assign to channel', // Only display this action if there are multiple channels isVisible: ({ injector }) => injector.get(DataService).client .userStatus() .mapSingle(({ userStatus }) => 1 < userStatus.channels.length) .toPromise(), // ... }), ]; ``` --- --- title: 'Creating Detail Views' --- # Creating Detail Views The two most common type of components you'll be creating in your UI extensions are list components and detail components. In Vendure, we have standardized the way you write these components so that your ui extensions can be made to fit seamlessly into the rest of the app. :::note The specific pattern described here is for Angular-based components. It is also possible to create detail views using React components, but in that case you won't be able to use the built-in Angular-specific components. ::: ## Example: Creating a Product Detail View Let's say you have a plugin which adds a new entity to the database called `ProductReview`. You have already created a [list view](/guides/extending-the-admin-ui/creating-list-views/), and now you need a detail view which can be used to view and edit individual reviews. ### Extend the TypedBaseDetailComponent class The detail component itself is an Angular component which extends the [BaseDetailComponent](/reference/admin-ui-api/list-detail-views/base-detail-component/) or [TypedBaseDetailComponent](/reference/admin-ui-api/list-detail-views/typed-base-detail-component) class. This example assumes you have set up your project to use code generation as described in the [GraphQL code generation guide](/guides/how-to/codegen/#codegen-for-admin-ui-extensions). ```ts title="src/plugins/reviews/ui/components/review-detail/review-detail.component.ts" import { ResultOf } from '@graphql-typed-document-node/core'; import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { TypedBaseDetailComponent, LanguageCode, NotificationService, SharedModule } from '@vendure/admin-ui/core'; // This is the TypedDocumentNode & type generated by GraphQL Code Generator import { graphql } from '../../gql'; export const reviewDetailFragment = graphql(` fragment ReviewDetail on ProductReview { id createdAt updatedAt title rating text authorName productId } `); export const getReviewDetailDocument = graphql(` query GetReviewDetail($id: ID!) { review(id: $id) { ...ReviewDetail } } `); export const createReviewDocument = graphql(` mutation CreateReview($input: CreateProductReviewInput!) { createProductReview(input: $input) { ...ReviewDetail } } `); export const updateReviewDocument = graphql(` mutation UpdateReview($input: UpdateProductReviewInput!) { updateProductReview(input: $input) { ...ReviewDetail } } `); @Component({ selector: 'review-detail', templateUrl: './review-detail.component.html', styleUrls: ['./review-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [SharedModule], }) export class ReviewDetailComponent extends TypedBaseDetailComponent implements OnInit, OnDestroy { detailForm = this.formBuilder.group({ title: [''], rating: [1], authorName: [''], }); constructor(private formBuilder: FormBuilder, private notificationService: NotificationService) { super(); } ngOnInit() { this.init(); } ngOnDestroy() { this.destroy(); } create() { const { title, rating, authorName } = this.detailForm.value; if (!title || rating == null || !authorName) { return; } this.dataService .mutate(createReviewDocument, { input: { title, rating, authorName }, }) .subscribe(({ createProductReview }) => { if (createProductReview.id) { this.notificationService.success('Review created'); this.router.navigate(['extensions', 'reviews', createProductReview.id]); } }); } update() { const { title, rating, authorName } = this.detailForm.value; this.dataService .mutate(updateReviewDocument, { input: { id: this.id, title, rating, authorName }, }) .subscribe(() => { this.notificationService.success('Review updated'); }); } protected setFormValues(entity: NonNullable['review']>, languageCode: LanguageCode): void { this.detailForm.patchValue({ title: entity.name, rating: entity.rating, authorName: entity.authorName, productId: entity.productId, }); } } ``` ### Create the template Here is the standard layout for detail views: ```html
``` ### Route config Here's how the routing would look for a typical list & detail view: ```ts title="src/plugins/reviews/ui/routes.ts" import { registerRouteComponent } from '@vendure/admin-ui/core'; import { ReviewDetailComponent, getReviewDetailDocument } from './components/review-detail/review-detail.component'; import { ReviewListComponent } from './components/review-list/review-list.component'; export default [ // List view registerRouteComponent({ path: '', component: ReviewListComponent, breadcrumb: 'Product reviews', }), // highlight-start // Detail view registerRouteComponent({ path: ':id', component: ReviewDetailComponent, query: getReviewDetailDocument, entityKey: 'productReview', getBreadcrumbs: entity => [ { label: 'Product reviews', link: ['/extensions', 'product-reviews'], }, { label: `#${entity?.id} (${entity?.product.name})`, link: [], }, ], }), // highlight-end ] ``` ## Supporting custom fields From Vendure v2.2, it is possible for your [custom entities to support custom fields](/guides/developer-guide/database-entity/#supporting-custom-fields). If you have set up your entity to support custom fields, and you want custom fields to be available in the Admin UI detail view, you need to add the following to your detail component: ```ts title="src/plugins/reviews/ui/components/review-detail/review-detail.component.ts" // highlight-next-line import { getCustomFieldsDefaults } from '@vendure/admin-ui/core'; @Component({ selector: 'review-detail', templateUrl: './review-detail.component.html', styleUrls: ['./review-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [SharedModule], }) export class ReviewDetailComponent extends TypedBaseDetailComponent implements OnInit, OnDestroy { // highlight-next-line customFields = this.getCustomFieldConfig('ProductReview'); detailForm = this.formBuilder.group({ title: [''], rating: [1], authorName: [''], // highlight-next-line customFields: this.formBuilder.group(getCustomFieldsDefaults(this.customFields)), }); protected setFormValues(entity: NonNullable['review']>, languageCode: LanguageCode): void { this.detailForm.patchValue({ title: entity.name, rating: entity.rating, authorName: entity.authorName, productId: entity.productId, }); // highlight-start if (this.customFields.length) { this.setCustomFieldFormValues(this.customFields, this.detailForm.get('customFields'), entity); } // highlight-end } } ``` Then add a card for your custom fields to the template: ```html title="src/plugins/reviews/ui/components/review-detail/review-detail.component.html"
// highlight-start // highlight-end
``` --- --- title: 'Creating List Views' --- The two most common type of components you'll be creating in your UI extensions are list components and detail components. In Vendure, we have standardized the way you write these components so that your ui extensions can be made to fit seamlessly into the rest of the app. :::note The specific pattern described here is for Angular-based components. It is also possible to create list views using React components, but in that case you won't be able to use the built-in data table & other Angular-specific components. ::: ## Example: Creating a Product Reviews List Let's say you have a plugin which adds a new entity to the database called `ProductReview`. You want to create a new list view in the Admin UI which displays all the reviews submitted. ### Use the PaginatedList interface To use the standardized list component, you need to make sure your plugin exposes this list in the GraphQL API following the [PaginatedList interface](/reference/typescript-api/common/paginated-list/): ```graphql type ProductReview implements Node { id: ID! createdAt: DateTime! updatedAt: DateTime! title: String! rating: Int! text: String! authorName: String! product: Product! productId: ID! } type ProductReviewList implements PaginatedList { items: [ProductReview!]! totalItems: Int! } ``` :::info See the [Paginated Lists guide](/guides/how-to/paginated-list/) for details on how to implement this in your server plugin code. ::: ### Create the list component The list component itself is an Angular component which extends the [BaseListComponent](/reference/admin-ui-api/list-detail-views/base-list-component/) or [TypedBaseListComponent](/reference/admin-ui-api/list-detail-views/typed-base-list-component) class. This example assumes you have set up your project to use code generation as described in the [GraphQL code generation guide](/guides/how-to/codegen/#codegen-for-admin-ui-extensions). ```ts title="src/plugins/reviews/ui/components/review-list/review-list.component.ts" import { ChangeDetectionStrategy, Component } from '@angular/core'; import { TypedBaseListComponent, SharedModule } from '@vendure/admin-ui/core'; // This is the TypedDocumentNode generated by GraphQL Code Generator import { graphql } from '../../gql'; const getReviewListDocument = graphql(` query GetReviewList($options: ReviewListOptions) { reviews(options: $options) { items { id createdAt updatedAt title rating text authorName productId } totalItems } } `); @Component({ selector: 'review-list', templateUrl: './review-list.component.html', styleUrls: ['./review-list.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [SharedModule], }) export class ReviewListComponent extends TypedBaseListComponent { // Here we set up the filters that will be available // to use in the data table readonly filters = this.createFilterCollection() .addIdFilter() .addDateFilters() .addFilter({ name: 'title', type: {kind: 'text'}, label: 'Title', filterField: 'title', }) .addFilter({ name: 'rating', type: {kind: 'number'}, label: 'Rating', filterField: 'rating', }) .addFilter({ name: 'authorName', type: {kind: 'text'}, label: 'Author', filterField: 'authorName', }) .connectToRoute(this.route); // Here we set up the sorting options that will be available // to use in the data table readonly sorts = this.createSortCollection() .defaultSort('createdAt', 'DESC') .addSort({name: 'createdAt'}) .addSort({name: 'updatedAt'}) .addSort({name: 'title'}) .addSort({name: 'rating'}) .addSort({name: 'authorName'}) .connectToRoute(this.route); constructor() { super(); super.configure({ document: getReviewListDocument, getItems: data => data.reviews, setVariables: (skip, take) => ({ options: { skip, take, filter: { title: { contains: this.searchTermControl.value, }, ...this.filters.createFilterInput(), }, sort: this.sorts.createSortInput(), }, }), refreshListOnChanges: [this.filters.valueChanges, this.sorts.valueChanges], }); } } ``` ### Create the template This is the standard layout for any list view. The main functionality is provided by the [DataTable2Component](/reference/admin-ui-api/components/data-table2component/). ```html title="src/plugins/reviews/ui/components/review-list/review-list.component.html" Create a review {{ review.id }} {{ review.createdAt | localeDate : 'short' }} {{ review.updatedAt | localeDate : 'short' }} {{ review.title }} {{ review.authorName }} ``` ### Route config ```ts title="src/plugins/reviews/ui/routes.ts" import { registerRouteComponent } from '@vendure/admin-ui/core'; import { ReviewListComponent } from './components/review-list/review-list.component'; export default [ // highlight-start registerRouteComponent({ path: '', component: ReviewListComponent, breadcrumb: 'Product reviews', }), // highlight-end ] ``` ## Supporting custom fields From Vendure v2.2, it is possible for your [custom entities to support custom fields](/guides/developer-guide/database-entity/#supporting-custom-fields). If you have set up your entity to support custom fields, and you want custom fields to be available in the Admin UI list view, you need to add the following to your list component: ```ts title="src/plugins/reviews/ui/components/review-list/review-list.component.ts" @Component({ selector: 'review-list', templateUrl: './review-list.component.html', styleUrls: ['./review-list.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [SharedModule], }) export class ReviewListComponent extends TypedBaseListComponent { // highlight-next-line customFields = this.getCustomFieldConfig('ProductReview'); readonly filters = this.createFilterCollection() .addIdFilter() .addDateFilters() .addFilter({ name: 'title', type: {kind: 'text'}, label: 'Title', filterField: 'title', }) .addFilter({ name: 'rating', type: {kind: 'number'}, label: 'Rating', filterField: 'rating', }) .addFilter({ name: 'authorName', type: {kind: 'text'}, label: 'Author', filterField: 'authorName', }) // highlight-next-line .addCustomFieldFilters(this.customFields) .connectToRoute(this.route); readonly sorts = this.createSortCollection() .defaultSort('createdAt', 'DESC') .addSort({name: 'createdAt'}) .addSort({name: 'updatedAt'}) .addSort({name: 'title'}) .addSort({name: 'rating'}) .addSort({name: 'authorName'}) // highlight-next-line .addCustomFieldSorts(this.customFields) .connectToRoute(this.route); // rest of class omitted for brevity } ``` and then add the `vdr-dt2-custom-field-column` component to your data table: ```html title="src/plugins/reviews/ui/components/review-list/review-list.component.html" // highlight-start // highlight-end ``` --- --- title: 'Custom DataTable Components' weight: 6 --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; The Admin UI list views are powered by a data table component which features sorting, advanced filtering, pagination and more. It will also give you the option of displaying any configured [custom fields](/guides/developer-guide/custom-fields/) for the entity in question. With Admin UI extensions, you can specify custom components to use in rendering any column of any data table - both custom fields _and_ built-in fields, using either Angular or React components. Let's say we want to make the product "slug" column link to the matching product detail page in our storefront. ### 1. Define a component First we'll define the component we will use to render the "slug" column: Angular components will receive the value of the current column as the `rowItem` input. In this case, the `rowItem` will be the Product entity, because we will be adding this to the product list data table. ```ts title="src/plugins/slug-link/ui/components/slug-link/slug-link.component.ts" import { Component, Input } from '@angular/core'; import { CustomColumnComponent } from '@vendure/admin-ui/core'; @Component({ selector: 'slug-link', template: ` {{ rowItem.slug }} `, standalone: true, }) export class SlugLinkComponent implements CustomColumnComponent { @Input() rowItem: { slug: string }; } ``` React components will receive the value of the current column as the `rowItem` prop. In this case, the `rowItem` will be the Product entity, because we will be adding this to the product list data table. ```tsx title="src/plugins/slug-link/ui/components/SlugLink.tsx" import { ReactDataTableComponentProps } from '@vendure/admin-ui/react'; import React from 'react'; export function SlugLink({ rowItem }: ReactDataTableComponentProps<{ slug: string }>) { const slug = rowItem.slug; return ( {slug} ); } ``` ### 2. Register the component Next we need to register the component in out `providers.ts` file. We need to pass both a `tableId` and a `columnId` to identify the table and column to which the component should be added. The values for these IDs can be found by pressing the `ctrl + u` shortcut when the Admin UI is in dev mode, and then clicking the extension point icon at the top of the column in question: ![Extension locations](./custom-data-table-location.webp) In this case we want to target the `product-list` table and the `slug` column. ```ts title="src/plugins/slug-link/ui/providers.ts" import { registerDataTableComponent } from '@vendure/admin-ui/core'; import { SlugLinkComponent } from './components/slug-link/slug-link.component'; export default [ registerDataTableComponent({ component: SlugWithLinkComponent, tableId: 'product-list', columnId: 'slug', }), ]; ``` ```ts title="src/plugins/slug-link/ui/providers.ts" import { registerReactDataTableComponent } from '@vendure/admin-ui/react'; import { SlugLink } from './components/SlugLink'; export default [ registerReactDataTableComponent({ component: SlugWithLink, tableId: 'product-list', columnId: 'slug', props: { // Additional props may be passed to the component foo: 'bar', }, }), ]; ``` When running the Admin UI, the product list slug should now be rendered as a link: ![Product list with slug link](./custom-data-table.webp) --- --- title: 'Custom Detail Components' --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; Detail views can be extended with custom Angular or React components using the [`registerCustomDetailComponent`](/reference/admin-ui-api/custom-detail-components/register-custom-detail-component/) and [`registerReactCustomDetailComponent`](/reference/admin-ui-api/react-extensions/register-react-custom-detail-component) functions. Any components registered in this way will appear below the main detail form. :::info The valid locations for embedding custom detail components can be found in the [CustomDetailComponentLocationId docs](/reference/admin-ui-api/custom-detail-components/custom-detail-component-location-id). ::: Let's imagine that your project has an external content management system (CMS) which is used to store additional details about products. You might want to display some of this information in the product detail page. We will demonstrate the same component in both Angular and React. ### 1. Create a component ```ts title="src/plugins/cms/ui/components/product-info/product-info.component.ts" import { Component, OnInit } from '@angular/core'; import { Observable, switchMap } from 'rxjs'; import { FormGroup } from '@angular/forms'; import { DataService, CustomDetailComponent, SharedModule } from '@vendure/admin-ui/core'; import { CmsDataService } from '../../providers/cms-data.service'; @Component({ template: `
{{ extraInfo$ | async | json }}
`, standalone: true, providers: [CmsDataService], imports: [SharedModule], }) export class ProductInfoComponent implements CustomDetailComponent, OnInit { // These two properties are provided by Vendure and will vary // depending on the particular detail page you are embedding this // component into. In this case, it will be a "product" entity. entity$: Observable detailForm: FormGroup; extraInfo$: Observable; constructor(private cmsDataService: CmsDataService) { } ngOnInit() { this.extraInfo$ = this.entity$.pipe( switchMap(entity => this.cmsDataService.getDataFor(entity.id)) ); } } ```
When using React, we can use the [`useDetailComponentData` hook](/reference/admin-ui-api/react-hooks/use-detail-component-data) to access the entity and form data. ```tsx title="src/plugins/cms/ui/components/ProductInfo.tsx" import React, { useEffect, useState } from 'react'; import { Card, useDetailComponentData, useInjector } from '@vendure/admin-ui/react'; import { CmsDataService } from '../providers/cms-data.service'; export function ProductInfo() { // The "entity" will vary depending on which detail page this component // is embedded in. In this case, it will be a "product" entity. const { entity, detailForm } = useDetailComponentData(); const cmsDataService = useInjector(CmsDataService); const [extraInfo, setExtraInfo] = useState(); useEffect(() => { if (!entity?.id) { return; } const subscription = cmsDataService.getDataFor(entity?.id).subscribe(data => { setExtraInfo(data); }); return () => subscription.unsubscribe(); }, [entity?.id]); return (
{JSON.stringify(extraInfo, null, 2)}
); } ```
### 2. Register the component We can then register the component in our `providers.ts` file: ```ts title="src/plugins/cms/ui/providers.ts" import { registerCustomDetailComponent } from '@vendure/admin-ui/core'; import { ProductInfoComponent } from './components/product-info/product-info.component'; export default [ registerCustomDetailComponent({ locationId: 'product-detail', component: ProductInfoComponent, }), ]; ``` ```ts title="src/plugins/cms/ui/providers.ts" import { registerReactCustomDetailComponent } from '@vendure/admin-ui/react'; import { ProductInfo } from './components/ProductInfo'; export default [ registerReactCustomDetailComponent({ locationId: 'product-detail', component: ProductInfo, }), ]; ``` When running the Admin UI, the component should now appear in the product detail page: ![Product detail with custom component](./detail-component.webp) ## Manipulating the detail form The `detailForm` property is an instance of the Angular [FormGroup](https://angular.io/api/forms/FormGroup) which can be used to manipulate the form fields, set the validity of the form, mark the form as dirty etc. For example, we could add a button which updates the `description` field of the product: ```ts title="src/plugins/cms/ui/components/product-info/product-info.component.ts" import { Component, OnInit } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { CustomDetailComponent } from '@vendure/admin-ui/core'; @Component({ template: ``, standalone: true, }) export class ProductInfoComponent implements CustomDetailComponent, OnInit { entity$: Observable detailForm: FormGroup; // highlight-start updateDescription() { const descriptionControl = this.detailForm.get('description'); if (descriptionControl) { descriptionControl.setValue('New description'); descriptionControl.markAsDirty(); } } // highlight-end } ``` ```tsx title="src/plugins/cms/ui/components/ProductInfo.tsx" import React from 'react'; import { Card, useDetailComponentData } from '@vendure/admin-ui/react'; export function ProductInfo() { const { detailForm } = useDetailComponentData(); // highlight-start const updateDescription = () => { const descriptionControl = detailForm.get('description'); if (descriptionControl) { descriptionControl.setValue('New description'); descriptionControl.markAsDirty(); } }; // highlight-end return ( ); } ``` --- --- title: 'Custom Form Inputs' --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; You can define custom Angular or React components which can be used to render [Custom Fields](/guides/developer-guide/custom-fields/) you have defined on your entities as well as [configurable args](/reference/typescript-api/configurable-operation-def/config-args/) used by custom [Configurable Operations](/guides/developer-guide/strategies-configurable-operations/#configurable-operations). ## For Custom Fields Let's say you define a custom "intensity" field on the Product entity: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; export const config: VendureConfig = { // ... customFields: { Product: [ { name: 'intensity', type: 'int', min: 0, max: 100, defaultValue: 0 }, ], }, } ``` By default, the "intensity" field will be displayed as a number input: ![./ui-extensions-custom-field-default.webp](./ui-extensions-custom-field-default.webp) But let's say we want to display a **range slider** instead. ### 1. Define a component First we need to define a new Angular or React component to render the slider: Angular components will have the `readonly`, `config` and `formControl` properties populated automatically. ```ts title="src/plugins/common/ui/components/slider-form-input/slider-form-input.component.ts" import { Component } from '@angular/core'; import { FormControl } from '@angular/forms'; import { IntCustomFieldConfig, SharedModule, FormInputComponent } from '@vendure/admin-ui/core'; @Component({ template: ` {{ formControl.value }} `, standalone: true, imports: [SharedModule], }) export class SliderControlComponent implements FormInputComponent { readonly: boolean; config: IntCustomFieldConfig; formControl: FormControl; } ``` React components can use the [`useFormControl`](/reference/admin-ui-api/react-hooks/use-form-control) hook to access the form control and set its value. The component will also receive `config` and `readonly` data as props. ```tsx title="src/plugins/common/ui/components/SliderFormInput.tsx" import React from 'react'; import { useFormControl, ReactFormInputOptions, useInjector } from '@vendure/admin-ui/react'; export function SliderFormInput({ readonly, config }: ReactFormInputOptions) { const { value, setFormValue } = useFormControl(); const handleChange = (e: React.ChangeEvent) => { const val = +e.target.value; setFormValue(val); }; return ( <> {value} ); }; ``` ### 2. Register the component Next we will register this component in our `providers.ts` file and give it a unique ID, `'slider-form-input'`: ```ts title="src/plugins/common/ui/providers.ts" import { registerFormInputComponent } from '@vendure/admin-ui/core'; import { SliderControlComponent } from './components/slider-form-input/slider-form-input.component'; export default [ registerFormInputComponent('slider-form-input', SliderControlComponent), ]; ``` ```ts title="src/plugins/common/ui/providers.ts" import { registerReactFormInputComponent } from '@vendure/admin-ui/react'; import { SliderControl } from './components/SliderFormInput'; export default [ registerReactFormInputComponent('slider-form-input', SliderFormInput), ]; ``` ### 3. Register the providers The `providers.ts` is then passed to the `compileUiExtensions()` function as described in the [UI Extensions Getting Started guide](/guides/extending-the-admin-ui/getting-started/): ```ts title="src/vendure-config.ts" import * as path from 'path'; import { VendureConfig } from '@vendure/core'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ port: 3302, app: compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [{ id: 'common', // highlight-start extensionPath: path.join(__dirname, 'plugins/common/ui'), providers: ['providers.ts'], // highlight-end }], }), }), ], }; ``` ### 4. Update the custom field config Once registered, this new slider input can be used in our custom field config: ```ts title="src/vendure-config.ts" customFields: { Product: [ { name: 'intensity', type: 'int', min: 0, max: 100, defaultValue: 0, // highlight-next-line ui: {component: 'slider-form-input'} }, ], } ``` As we can see, adding the `ui` property to the custom field config allows us to specify our custom slider component. The component id _'slider-form-input'_ **must match** the string passed as the first argument to `registerFormInputComponent()`. :::info If we want, we can also pass any other arbitrary data in the `ui` object, which will then be available in our component as `this.config.ui.myField`. Note that only JSON-compatible data types are permitted, so no functions or class instances. ::: Re-compiling the Admin UI will result in our SliderControl now being used for the "intensity" custom field: ![./ui-extensions-custom-field-slider.webp](./ui-extensions-custom-field-slider.webp) ## Custom Field Controls for Relations If you have a custom field of the `relation` type (which allows you to relate entities with one another), you can also define custom field controls for them. The basic mechanism is exactly the same as with primitive custom field types (i.e. `string`, `int` etc.), but there are a couple of important points to know: 1. The value of the `formControl` will be the _related entity object_ rather than an id. The Admin UI will internally take care of converting the entity object into an ID when performing the create/update mutation. 2. Your control will most likely need to fetch data in order to display a list of selections for the user. Here's an example of a custom field control for a `relation` field which relates a Product to a custom `ProductReview` entity: ```ts title="src/plugins/reviews/ui/components/relation-review-input/relation-review-input.component.ts" import { Component, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { RelationCustomFieldConfig } from '@vendure/common/lib/generated-types'; import { FormInputComponent, DataService, SharedModule } from '@vendure/admin-ui/core'; import { Observable } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { GET_REVIEWS_FOR_PRODUCT } from '../product-reviews-list/product-reviews-list.graphql'; @Component({ selector: 'relation-review-input', template: `
{{ review.rating }} / 5 {{ review.summary }}
`, standalone: true, imports: [SharedModule], }) export class RelationReviewInputComponent implements OnInit, FormInputComponent { readonly: boolean; formControl: FormControl; config: RelationCustomFieldConfig; reviews$: Observable; constructor(private dataService: DataService, private route: ActivatedRoute) {} ngOnInit() { this.reviews$ = this.route.data.pipe( switchMap(data => data.entity), switchMap((product: any) => { return this.dataService .query(GET_REVIEWS_FOR_PRODUCT, { productId: product.id }) .mapSingle(({ product }) => product?.reviews.items ?? []); }), ); } compareFn(item1: { id: string } | null, item2: { id: string } | null) { return item1 && item2 ? item1.id === item2.id : item1 === item2; } } ``` ## For ConfigArgs [ConfigArgs](/reference/typescript-api/configurable-operation-def/config-args/) are used by classes which extend [Configurable Operations](/guides/developer-guide/strategies-configurable-operations/#configurable-operations) (such as ShippingCalculator or PaymentMethodHandler). These ConfigArgs allow user-input values to be passed to the operation's business logic. They are configured in a very similar way to custom fields, and likewise can use custom form inputs by specifying the `ui` property. Here's an example: ```ts title="src/config/order-fixed-discount-action.ts" export const orderFixedDiscount = new PromotionOrderAction({ code: 'order_fixed_discount', args: { discount: { type: 'int', // highlight-start ui: { component: 'currency-form-input', }, // highlight-end }, }, execute(ctx, order, args) { return -args.discount; }, description: [{languageCode: LanguageCode.en, value: 'Discount order by fixed amount'}], }); ``` --- --- title: 'Custom History Timeline Components' --- The Order & Customer detail pages feature a timeline of history entries. Since v1.9.0 it is possible to define custom history entry types - see the [HistoryService docs](/reference/typescript-api/services/history-service/) for an example. You can also define a custom Angular component to render any timeline entry using the [registerHistoryEntryComponent function](/reference/admin-ui-api/custom-history-entry-components/register-history-entry-component/). ![./timeline-entry.webp](./timeline-entry.webp) :::note Currently it is only possible to define new tabs using Angular components. ::: Following the example used in the HistoryService docs, we can define a component to render the tax ID verification entry in our Customer timeline: ```ts title="src/plugins/tax-id/ui/components/tax-id-history-entry/tax-id-history-entry.component.ts" import { Component } from '@angular/core'; import { CustomerFragment, CustomerHistoryEntryComponent, SharedModule, TimelineDisplayType, TimelineHistoryEntry, } from '@vendure/admin-ui/core'; @Component({ selector: 'tax-id-verification-entry', template: `
Tax ID {{ entry.data.taxId }} was verified
Tax ID {{ entry.data.taxId }} could not be verified
`, standalone: true, imports: [SharedModule], }) export class TaxIdHistoryEntryComponent implements CustomerHistoryEntryComponent { entry: TimelineHistoryEntry; customer: CustomerFragment; getDisplayType(entry: TimelineHistoryEntry): TimelineDisplayType { return entry.data.valid ? 'success' : 'error'; } getName(entry: TimelineHistoryEntry): string { return 'Tax ID Verification Plugin'; } isFeatured(entry: TimelineHistoryEntry): boolean { return true; } getIconShape(entry: TimelineHistoryEntry) { return entry.data.valid ? 'check-circle' : 'exclamation-circle'; } } ``` We can then register this component in the `providers.ts` file: ```ts title="src/plugins/tax-id/ui/providers.ts" import { registerHistoryEntryComponent } from '@vendure/admin-ui/core'; import { TaxIdHistoryEntryComponent } from './components/tax-id-history-entry/tax-id-history-entry.component'; export default [ registerHistoryEntryComponent({ type: 'CUSTOMER_TAX_ID_VERIFICATION', component: TaxIdHistoryEntryComponent, }), ]; ``` Then we need to add the `providers.ts` file to the `uiExtensions` array as described in the [UI Extensions Getting Started guide](/guides/extending-the-admin-ui/getting-started/). --- --- title: 'Dashboard Widgets' --- Dashboard widgets are components which can be added to the Admin UI dashboard. These widgets are useful for displaying information which is commonly required by administrations, such as sales summaries, lists of incomplete orders, notifications, etc. The Admin UI comes with a handful of widgets, and you can also create your own widgets. ![Dashboard widgets](./dashboard-widgets.webp) :::note Currently it is only possible to define new widgets using Angular components. ::: ## Example: Reviews Widget In this example we will use a hypothetical reviews plugin, which allows customers to write product reviews. These reviews then get approved by an Administrator before being displayed in the storefront. To notify administrators about new reviews that need approval, we'll create a dashboard widget. ### Create the widget A dashboard widget is an Angular component. This example features a simplified UI, just to illustrate the overall structure: ```ts title="src/plugins/reviews/ui/components/reviews-widget/reviews-widget.component.ts" import { Component, OnInit } from '@angular/core'; import { DataService, SharedModule } from '@vendure/admin-ui/core'; import { Observable } from 'rxjs'; @Component({ selector: 'reviews-widget', template: ` `, standalone: true, imports: [SharedModule], }) export class ReviewsWidgetComponent implements OnInit { pendingReviews$: Observable; constructor(private dataService: DataService) {} ngOnInit() { this.pendingReviews$ = this.dataService.query(gql` query GetAllReviews($options: ProductReviewListOptions) { productReviews(options: $options) { items { id createdAt authorName summary rating } } }`, { options: { filter: { state: { eq: 'new' } }, take: 10, }, }) .mapStream(data => data.productReviews.items); } } ``` :::note We also need to define an `NgModule` for this component. This is because we will be lazy-loading the component at run-time, and the NgModule is required for us to use shared providers (e.g. `DataService`) and any shared components, directives or pipes defined in the `@vendure/admin-ui/core` package. ::: ### Register the widget Our widget now needs to be registered in our [providers file](/guides/extending-the-admin-ui/getting-started/#providers): ```ts title="src/plugins/reviews/ui/providers.ts" import { registerDashboardWidget } from '@vendure/admin-ui/core'; export default [ // highlight-start registerDashboardWidget('reviews', { title: 'Latest reviews', supportedWidths: [4, 6, 8, 12], requiresPermissions: ['ReadReview'], loadComponent: () => import('./reviews-widget/reviews-widget.component').then( m => m.ReviewsWidgetComponent, ), }), // highlight-end ]; ``` * **`title`** This is the title of the widget that will be displayed in the widget header. * **`supportedWidths`** This indicated which widths are supported by the widget. The number indicates columns in a Bootstrap-style 12-column grid. So `12` would be full-width, `6` half-width, etc. In the UI, the administrator will be able to re-size the widget to one of the supported widths. If not provided, all widths will be allowed. * **`requiresPermissions`** This allows an array of Permissions to be specified which limit the display of the widget to administrators who possess all of those permissions. If not provided, all administrators will be able to use the widget. * **`loadComponent`** This function defines how to load the component. Using the dynamic `import()` syntax will enable the Angular compiler to intelligently generate a lazy-loaded JavaScript bundle just for that component. This means that your widget can, for example, include 3rd-party dependencies (such as a charting library) without increasing the bundle size (and therefore load-times) of the main Admin UI app. The widget-specific code will _only_ be loaded when the widget is rendered on the dashboard. Once registered, the reviews widget will be available to select by administrators with the appropriate permissions. ## Setting the default widget layout While administrators can customize which widgets they want to display on the dashboard, and the layout of those widgets, you can also set a default layout: ```ts title="src/plugins/reviews/ui/providers.ts" import { registerDashboardWidget, setDashboardWidgetLayout } from '@vendure/admin-ui/core'; export default [ registerDashboardWidget('reviews', { // omitted for brevity }), // highlight-start setDashboardWidgetLayout([ { id: 'welcome', width: 12 }, { id: 'orderSummary', width: 4 }, { id: 'latestOrders', width: 8 }, { id: 'reviews', width: 6 }, ]), // highlight-end ]; ``` This defines the order of widgets with their default widths. The actual layout in terms of rows and columns will be calculated at run-time based on what will fit on each row. ## Overriding default widgets The Admin UI comes with a set of default widgets, such as the order summary and latest orders widgets (they can be found in [the default-widgets.ts file](https://github.com/vendure-ecommerce/vendure/blob/master/packages/admin-ui/src/lib/dashboard/src/default-widgets.ts)). Sometimes you may wish to alter the permissions settings of the default widgets to better control which of your Administrators is able to access it. For example, the "order summary" widget has a default permission requirement of "ReadOrder". If you want to limit the availability to e.g. the SuperAdmin role, you can do so by overriding the definition like this: ```ts title="src/plugins/reviews/ui/providers.ts" import { registerDashboardWidget } from '@vendure/admin-ui/core'; import { OrderSummaryWidgetComponent } from '@vendure/admin-ui/dashboard'; export default [ // highlight-start registerDashboardWidget('orderSummary', { title: 'dashboard.orders-summary', loadComponent: () => OrderSummaryWidgetComponent, requiresPermissions: ['SuperAdmin'], }), // highlight-end ]; ``` --- --- title: 'Defining routes' --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; Routes allow you to mount entirely custom components at a given URL in the Admin UI. New routes will appear in this area of the Admin UI: ![Route area](./route-area.webp) Routes can be defined natively using either **Angular** or **React**. It is also possible to [use other frameworks](/guides/extending-the-admin-ui/using-other-frameworks/) in a more limited capacity. ## Example: Creating a "Greeter" route ### 1. Create a plugin We will first quickly scaffold a new plugin to house our UI extensions: - Run `npx vendure add` from your project root - Select `Create a new Vendure plugin` and when prompted for a name, name it **"greeter"** - After the plugin is created, you will be prompted to add features to the plugin. Select `[Plugin: UI] Set up Admin UI extensions` You should now have a new plugin scaffolded at `./src/plugins/greeter`, with some empty UI extension files in the `ui` directory. If you check your `vendure-config.ts` file you should also see that your `AdminUiPlugin.init()` call has been modified to compile the UI extensions: ```ts AdminUiPlugin.init({ route: 'admin', port: serverPort + 2, adminUiConfig: { apiPort: serverPort, }, // highlight-start app: compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [ GreeterPlugin.ui, ], devMode: true, }), // highlight-end }), ``` ### 2. Create the route component First we need to create the component which will be mounted at the route. This component can be either an Angular component or a React component. ```ts title="src/plugins/greeter/ui/components/greeter/greeter.component.ts" import { SharedModule } from '@vendure/admin-ui/core'; import { Component } from '@angular/core'; @Component({ selector: 'greeter', template: `

{{ greeting }}

`, standalone: true, imports: [SharedModule], }) export class GreeterComponent { greeting = 'Hello!'; } ```
```ts title="src/plugins/greeter/ui/components/greeter/Greeter.tsx" import React from 'react'; export function Greeter() { const greeting = 'Hello!'; return (

{greeting}

); } ```
:::note The `` (Angular) and `
` (React) is a wrapper that sets the layout and max width of your component to match the rest of the Admin UI. You should usually wrap your component in this element. ::: ### 3. Define the route Next we need to define a route in our `routes.ts` file. Note that this file can have any name, but "routes.ts" is a convention. Using [`registerRouteComponent`](/reference/admin-ui-api/routes/register-route-component) you can define a new route based on an Angular component. ```ts title="src/plugins/greeter/ui/routes.ts" import { registerRouteComponent } from '@vendure/admin-ui/core'; import { GreeterComponent } from './components/greeter/greeter.component'; export default [ registerRouteComponent({ component: GreeterComponent, path: '', title: 'Greeter Page', breadcrumb: 'Greeter', }), ]; ``` Here's the equivalent example using React and [`registerReactRouteComponent`](/reference/admin-ui-api/react-extensions/register-react-route-component): ```ts title="src/plugins/greeter/ui/routes.ts" import { registerReactRouteComponent } from '@vendure/admin-ui/react'; import { Greeter } from './components/Greeter'; export default [ registerReactRouteComponent({ component: Greeter, path: '', title: 'Greeter Page', breadcrumb: 'Greeter', }), ]; ``` The `path: ''` is actually optional, since `''` is the default value. But this is included here to show that you can mount different components at different paths. See the section on route parameters below. ### 4. Add the route to the extension config Since you have used the CLI to scaffold your plugin, this part has already been done for you. But for the sake of completeness this is the part of your plugin which is configured to point to your routes file: ```ts title="src/plugins/greeter/greeter.plugin.ts" // ... export class GreeterPlugin { static options: PluginInitOptions; static init(options: PluginInitOptions): Type { this.options = options; return GreeterPlugin; } // highlight-start static ui: AdminUiExtension = { id: 'greeter-ui', extensionPath: path.join(__dirname, 'ui'), routes: [{ route: 'greeter', filePath: 'routes.ts' }], providers: ['providers.ts'], }; // highlight-end } ``` Note that by specifying `route: 'greeter'`, we are "mounting" the routes at the `/extensions/greeter` path. :::info The `/extensions/` prefix is used to avoid conflicts with built-in routes. From Vendure v2.2.0 it is possible to customize this prefix using the `prefix` property. See the section on [overriding built-in routes](#overriding-built-in-routes) for more information. ::: The `filePath` property is relative to the directory specified in the `extensionPath` property. In this case, the `routes.ts` file is located at `src/plugins/greeter/ui/routes.ts`. ### 5. Test it out Now run your app with `npm run dev`. Wait for it to compile the Admin UI extensions. Now go to the Admin UI app in your browser and log in. You should now be able to manually enter the URL `http://localhost:3000/admin/extensions/greeter` and you should see the component with the "Hello!" header: ![./ui-extensions-greeter.webp](./ui-extensions-greeter.webp) ## Links To link to other routes, you must use the `routerLink` directive for Angular, or the `Link` component for React: ```html John Smith ``` ```tsx import React from 'react'; import { Link } from '@vendure/admin-ui/react'; export function DemoComponent() { return ( John Smith ); } ``` ## Route parameters The `path` property is used to specify the path to a specific component. This path can contain parameters, which will then be made available to the component. Parameters are defined using the `:` prefix. For example: ```ts title="src/plugins/my-plugin/ui/routes.ts" import { registerRouteComponent } from '@vendure/admin-ui/core'; import { TestComponent } from './components/test/test.component'; export default [ registerRouteComponent({ component: TestComponent, // highlight-next-line path: ':id', title: 'Test', breadcrumb: 'Test', }), ]; ``` ```ts title="src/plugins/my-plugin/ui/routes.ts" import { registerReactRouteComponent } from '@vendure/admin-ui/react'; import { Test } from './components/Test'; export default [ registerReactRouteComponent({ component: Test, // highlight-next-line path: ':id', title: 'Test', breadcrumb: 'Test', }), ]; ``` The `id` parameter will then be available in the component: ```ts title="src/plugins/my-plugin/ui/components/test/test.component.ts" import { SharedModule } from '@vendure/admin-ui/core'; import { Component } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; @Component({ selector: 'test', template: ` // highlight-next-line

id: {{ id }}

`, standalone: true, imports: [SharedModule], }) export class TestComponent { id: string; constructor(private route: ActivatedRoute) { // highlight-next-line this.id = this.route.snapshot.paramMap.get('id'); } } ```
```tsx title="src/plugins/my-plugin/ui/components/Test.tsx" import React from 'react'; import { useRouteParams } from '@vendure/admin-ui/react'; export function Test() { // highlight-next-line const { params } = useRouteParams(); return (
// highlight-next-line

id: {params.id}

); } ```
Loading the route `/extensions/test/123` will then display the id "123". ## Injecting services It is possible to inject services into your components. This includes both the [built-in services](/reference/admin-ui-api/services/) for things like data fetching, notifications and modals, as well as any custom services you have defined in your UI extension. Here's an example of injecting the built-in `NotificationService` into a component to display a toast notification: In Angular, we can use either the constructor to inject the service (as shown below), or the `inject()` function. See the [Angular dependency injection guide](https://angular.io/guide/dependency-injection#injecting-a-dependency) for more information. ```ts title="src/plugins/my-plugin/ui/components/test/test.component.ts" import { SharedModule, NotificationService } from '@vendure/admin-ui/core'; import { Component } from '@angular/core'; @Component({ selector: 'test', template: ` `, standalone: true, imports: [SharedModule], }) export class TestComponent { // highlight-next-line constructor(private notificationService: NotificationService) {} showNotification() { // highlight-next-line this.notificationService.success('Hello!'); } } ``` In React, we use the [`useInjector()`](/reference/admin-ui-api/react-hooks/use-injector) hook to inject the service: ```tsx title="src/plugins/my-plugin/ui/components/Test.tsx" import { NotificationService } from '@vendure/admin-ui/core'; // highlight-next-line import { useInjector } from '@vendure/admin-ui/react'; import React from 'react'; export function Test() { // highlight-next-line const notificationService = useInjector(NotificationService); function showNotification() { // highlight-next-line notificationService.success('Hello!'); } return (
); } ```
## Setting page title The `title` property is used to set the page title. This is displayed in the browser tab as well as in the page header. ### In the route definition The page title can be set in the route definition: ```ts title="src/plugins/my-plugin/ui/routes.ts" import { registerRouteComponent } from '@vendure/admin-ui/core'; import { TestComponent } from './components/test/test.component'; export default [ registerRouteComponent({ component: TestComponent, // highlight-next-line title: 'Test', breadcrumb: 'Test', }), ]; ``` ```ts title="src/plugins/my-plugin/ui/routes.ts" import { registerReactRouteComponent } from '@vendure/admin-ui/react'; import { Test } from './components/Test'; export default [ registerReactRouteComponent({ component: Test, // highlight-next-line title: 'Test', breadcrumb: 'Test', }), ]; ``` ### Dynamically from the component It is also possible to update the page title dynamically from the route component itself: ```ts title="src/plugins/my-plugin/ui/components/test/test.component.ts" import { PageMetadataService, SharedModule } from '@vendure/admin-ui/core'; import { Component } from '@angular/core'; @Component({ selector: 'test', template: ` // highlight-next-line `, standalone: true, imports: [SharedModule], }) export class TestComponent { // highlight-next-line constructor(private pageMetadataService: PageMetadataService) {} handleClick() { // highlight-next-line pageMetadataService.setTitle('New title'); } } ``` ```tsx title="src/plugins/my-plugin/ui/components/Test.tsx" import { Card, usePageMetadata } from '@vendure/admin-ui/react'; import React from 'react'; export function Test() { // highlight-next-line const { setTitle } = usePageMetadata(); function handleClick() { // highlight-next-line setTitle('New title'); } return (
); } ```
## Setting breadcrumbs ### In the route definition The page breadcumbs can be set in the route definition in a couple of ways. The simplest is to specify the `breadcumb` property: ```ts title="src/plugins/my-plugin/ui/routes.ts" import { registerRouteComponent } from '@vendure/admin-ui/core'; import { TestComponent } from './components/test/test.component'; export default [ registerRouteComponent({ component: TestComponent, title: 'Test', locationId: 'my-location-id' // highlight-next-line breadcrumb: 'Test', }), ]; ``` ```ts title="src/plugins/my-plugin/ui/routes.ts" import { registerReactRouteComponent } from '@vendure/admin-ui/react'; import { Test } from './components/Test'; export default [ registerReactRouteComponent({ component: Test, title: 'Test', // highlight-next-line breadcrumb: 'Test', }), ]; ``` This can be a string (as above), a link/label pair, or an array of link/label pairs: ```ts title="src/plugins/my-plugin/ui/routes.ts" import { registerRouteComponent } from '@vendure/admin-ui/core'; import { TestComponent } from './components/test/test.component'; export default [ registerRouteComponent({ component: TestComponent, path: 'test-1', title: 'Test 1', // highlight-start breadcrumb: { label: 'Test', link: '/extensions/test' }, // highlight-end }), registerRouteComponent({ component: TestComponent, path: 'test-2', title: 'Test 2', // highlight-start breadcrumb: [ { label: 'Parent', link: '/extensions/test' }, { label: 'Child', link: '/extensions/test/test-2' }, ], // highlight-end }), ]; ``` A more powerful way to set the breadcrumbs is by using the `getBreadcrumbs` property. This is a function that receives any resolved detail data and returns an array of link/label pairs. An example of its use can be seen in the [Creating detail views guide](/guides/extending-the-admin-ui/creating-detail-views/#route-config). ### Dynamically from the component Similar to setting the title, the breadcrumbs can also be updated dynamically from the route component itself: ```ts title="src/plugins/my-plugin/ui/components/test/test.component.ts" import { PageMetadataService, SharedModule } from '@vendure/admin-ui/core'; import { Component } from '@angular/core'; @Component({ selector: 'test', template: ` // highlight-next-line `, standalone: true, imports: [SharedModule], }) export class TestComponent { // highlight-next-line constructor(private pageMetadataService: PageMetadataService) {} handleClick() { // highlight-next-line pageMetadataService.setBreadcrumb('New breadcrumb'); } } ``` ```tsx title="src/plugins/my-plugin/ui/components/Test.tsx" import { Card, usePageMetadata } from '@vendure/admin-ui/react'; import React from 'react'; export function Test() { // highlight-next-line const { setBreadcrumb } = usePageMetadata(); function handleClick() { // highlight-next-line setBreadcrumb('New breadcrumb'); } return (
); } ```
## Overriding built-in routes From Vendure v2.2.0, it is possible to override any of the built-in Admin UI routes. This is useful if you want to completely replace a built-in route with your own custom component. To do so, you'll need to specify the route `prefix` to be `''`, and then specify a `route` property which matches a built-in route. For example, let's say we want to override the product detail page. The full path of that route is: ``` /catalog/products/:id ``` ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; import * as path from 'path'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ port: 3002, app: compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [ { id: 'my-plugin', extensionPath: path.join(__dirname, 'plugins/my-plugin/ui'), routes: [ { // Setting the prefix to '' means that we won't add the // default `/extensions/` prefix to the route prefix: '', // This part matches the built-in route path for the // "catalog" module route: 'catalog', filePath: 'routes.ts', }, ], }, ], }), }), ], }; ``` Then in the `routes.ts` file, you can define a route which matches the built-in route: ```ts title="src/plugins/my-plugin/ui/routes.ts" import { GetProductDetailDocument, registerRouteComponent } from '@vendure/admin-ui/core'; import { MyProductDetailComponent } from './components/product-detail/product-detail.component'; export default [ registerRouteComponent({ component: MyProductDetailComponent, // The path must then match the remainder // of the built-in route path path: 'products/:id', // We can re-use the GraphQL query from the core to get // access to the same data in our component query: GetProductDetailDocument, entityKey: 'product', getBreadcrumbs: entity => [ { label: 'breadcrumb.products', link: ['/catalog/products'] }, { label: entity?.name ?? 'catalog.create-new-product', link: ['.'] }, ], }), ]; ``` ```ts title="src/plugins/my-plugin/ui/routes.ts" import { GetProductDetailDocument } from '@vendure/admin-ui/core'; import { registerReactRouteComponent } from '@vendure/admin-ui/react'; import { MyProductDetail } from './components/ProductDetail'; export default [ registerReactRouteComponent({ component: MyProductDetail, // The path must then match the remainder // of the built-in route path path: 'products/:id', // We can re-use the GraphQL query from the core to get // access to the same data in our component query: GetProductDetailDocument, entityKey: 'product', getBreadcrumbs: entity => [ { label: 'breadcrumb.products', link: ['/catalog/products'] }, { label: entity?.name ?? 'catalog.create-new-product', link: ['.'] }, ], }), ]; ``` ## Advanced configuration The Admin UI app routing is built on top of the [Angular router](https://angular.io/guide/routing-overview) - a very advanced and robust router. As such, you are able to tap into all the advanced features it provides by using the `routeConfig` property, which takes an Angular [`Route` definition object](https://angular.io/api/router/Route) and passes it directly to the router. ```ts title="src/plugins/my-plugin/ui/routes.ts" import { registerRouteComponent } from '@vendure/admin-ui/core'; import { inject } from '@angular/core'; import { ActivatedRouteSnapshot } from '@angular/router'; import { TestComponent } from './components/test/test.component'; import { PermissionsService } from './services'; export default [ registerRouteComponent({ component: TestComponent, path: ':id', title: 'Test', breadcrumb: 'Test', // highlight-start routeConfig: { pathMatch: 'full', canActivate: [(route: ActivatedRouteSnapshot) => { return inject(PermissionsService).canActivate(route.params.id); }], }, // highlight-end }), ]; ``` This allows you to leverage advanced features such as: - [Route guards](https://angular.io/api/router/CanActivateFn) - [Data resolvers](https://angular.io/api/router/ResolveFn) - [Nested routes](https://angular.io/guide/router#nesting-routes) - [Redirects](https://angular.io/guide/router#setting-up-redirects) --- --- title: 'Getting Started' --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; :::warning Angular Admin UI Deprecation The Angular-based Admin UI has been replaced by the new [React Admin Dashboard](/guides/extending-the-dashboard/getting-started/). The Angular Admin UI will not be maintained after **July 2026**. Until then, we will continue patching critical bugs and security issues. Community contributions will always be merged and released. The first stable release of the React Admin Dashboard is targeted for **the end of July 2025**. **For new projects, we strongly recommend using the [React Admin Dashboard](/guides/extending-the-dashboard/getting-started/) instead.** If you want to use the Admin UI and the Dashboard together please change the [compatibilityMode](/reference/core-plugins/admin-ui-plugin/admin-ui-plugin-options#compatibilitymode) to true. ::: When creating a plugin, you may wish to extend the Admin UI in order to expose a graphical interface to the plugin's functionality, or to add new functionality to the Admin UI itself. The UI can be extended with custom components written in [Angular](https://angular.io/) or [React](https://react.dev/). :::note The APIs described in this section were introduced in Vendure v2.1.0. For the legacy APIs, see the [Legacy API section](#legacy-api--v210). ::: UI extensions fall into two categories: - **Providers**: these are used to add new functionality to the Admin UI, such as adding buttons to pages, adding new nav menu items, or defining custom form inputs. They would typically be defined in a file named `providers.ts`. - **Routes**: these are used to define new pages in the Admin UI, such as a new page for managing a custom entity. They would typically be defined in a file named `routes.ts`. ## Setup :::cli Use `npx vendure add` and select "Set up Admin UI extensions". If you don't already have any plugins in your project, first create a plugin to house your UI extensions. Then select: ```sh [Plugin: UI] Set up Admin UI extensions ``` Then follow the prompts, which will guide you through the process of setting up the necessary files and folders for your UI extensions. ::: ### Manual setup It is recommended to use the `vendure add` command as described above, but if you prefer to set up the Admin UI extensions manually, or just want to get a better understanding of what the CLI is doing, follow these steps: First, install the [`@vendure/ui-devkit` package](https://www.npmjs.com/package/@vendure/ui-devkit) as a dev dependency: ```bash npm install --save-dev @vendure/ui-devkit ``` ```bash yarn add --dev @vendure/ui-devkit ``` :::info If you plan to use React components in your UI extensions, you should also install the `@types/react` package: ```bash npm install --save-dev @types/react ``` ```bash yarn add --dev @types/react ``` ::: You can then create the following folder structure to hold your UI extensions: ``` src ├── vendure-config.ts └── plugins └── my-plugin └── ui ├── routes.ts └── providers.ts ``` Let's add a simple UI extension that adds a new button to the "order list" page. We'll leave the routes file empty for now. ```ts title="src/plugins/my-plugin/ui/providers.ts" import { addActionBarItem } from '@vendure/admin-ui/core'; export default [ addActionBarItem({ id: 'test-button', label: 'Test Button', locationId: 'order-list', }), ]; ``` Now we can configure the paths to your UI extension files. By convention, we will add this config object as a static property on your plugin class: ```ts title="src/plugins/my-plugin/my.plugin.ts" import * as path from 'path'; import { PluginCommonModule, Type, VendurePlugin } from '@vendure/core'; // highlight-next-line import { AdminUiExtension } from '@vendure/ui-devkit/compiler'; @VendurePlugin({ imports: [PluginCommonModule], compatibility: '^3.0.0', }) export class MyPlugin { // highlight-start static ui: AdminUiExtension = { id: 'my-plugin-ui', extensionPath: path.join(__dirname, 'ui'), routes: [{ route: 'my-plugin', filePath: 'routes.ts' }], providers: ['providers.ts'], }; // highlight-end } ``` You can then use the [`compileUiExtensions` function](/reference/admin-ui-api/ui-devkit/compile-ui-extensions/) to compile your UI extensions and add them to the Admin UI app bundle. ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; // highlight-next-line import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; import { MyPlugin } from './plugins/greeter/my.plugin'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ port: 3002, // highlight-start app: compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [MyPlugin.ui], devMode: true, }), // highlight-end adminUiConfig: { apiPort: 3000, }, }), ], }; ``` Everything above will be automatically done for you when you use the CLI. Now when you start the server, the following will happen: 1. A new folder called `admin-ui` will be created in the root of your project (as specified by the `outputPath` option). This is a temporary directory (it should not be added to version control) which will contain the source files of your custom Admin UI app. 2. During bootstrap, the `compileUiExtensions` function will be called, which will compile the Admin UI app and serve it in development mode. The dev server will be listening on port `4200` but this port will also be proxied to port `3000` (as specified by `apiOptions.port`). This step can take up to a minute or two, depending on the speed of your machine. :::caution **Note:** the TypeScript source files of your UI extensions **must not** be compiled by your regular TypeScript build task. This is because they will instead be compiled by the Angular compiler when you run `compileUiExtensions()`. You can exclude them in your main `tsconfig.json` by adding a line to the "exclude" array (this is already defined on a default Vendure project): ```json title="tsconfig.json" { "exclude": [ "node_modules", "migration.ts", // highlight-start "src/plugins/**/ui/*", "admin-ui" // highlight-end ] } ``` ::: :::info **How It Works:** The Admin UI is an Angular application, and to generate a custom UI including your extensions, it is internally using the powerful [Angular CLI](https://angular.io/cli) to compile the app into an optimized bundle, including code-splitting and lazy-loading any routes which you define. ::: ## Providers Your `providers.ts` file exports an array of objects known as "providers" in Angular terminology. These providers are passed to the application on startup to configure new functionality. With providers you can: - Add new buttons to the action bar of existing pages (the top bar containing the primary actions for a page) using [`addActionBarItem`](/reference/admin-ui-api/action-bar/add-action-bar-item). - Add new menu items to the left-hand navigation menu using [`addNavMenuItem`](/reference/admin-ui-api/nav-menu/add-nav-menu-item) and [`addNavMenuSection`](/reference/admin-ui-api/nav-menu/add-nav-menu-section). - Define bulk actions for list views using [`registerBulkAction`](/reference/admin-ui-api/bulk-actions/register-bulk-action). - Define arbitrary components to be rendered in a detail view page using [`registerCustomDetailComponent`](/reference/admin-ui-api/custom-detail-components/register-custom-detail-component) or [`registerReactCustomDetailComponent`](/reference/admin-ui-api/react-extensions/register-react-custom-detail-component) - Add custom widgets to the dashboard using [`registerDashboardWidget`](/reference/admin-ui-api/dashboard-widgets/register-dashboard-widget) - Define custom components for rendering data table cells using [`registerDataTableComponent`](/reference/admin-ui-api/custom-table-components/register-data-table-component) or [`registerReactDataTableComponent`](/reference/admin-ui-api/react-extensions/register-react-data-table-component) - Define custom form input components for custom fields and configurable operation arguments using [`registerFormInputComponent`](/reference/admin-ui-api/custom-input-components/register-form-input-component) or [`registerReactFormInputComponent`](/reference/admin-ui-api/react-extensions/register-react-form-input-component) - Define custom components to render customer/order history timeline entries using [`registerHistoryEntryComponent`](/reference/admin-ui-api/custom-history-entry-components/register-history-entry-component) ### Providers format A providers file should have a **default export** which is an array of providers: ```ts title="src/plugins/my-plugin/ui/providers.ts" import { addActionBarItem } from '@vendure/admin-ui/core'; export default [ addActionBarItem({ id: 'test-button', label: 'Test Button', locationId: 'order-list', }), ]; ``` ### Specifying providers When defining UI extensions in the `compileUiExtensions()` function, you must specify at least one providers file. This is done by passing an array of file paths, where each file path is relative to the directory specified by the `extensionPath` option. ```ts title="src/vendure-config.ts" import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; import * as path from 'path'; // ... omitted for brevity compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [ // Note: this object will usually be // found in the `ui` static property // of a plugin like `MyPlugin.ui`. { id: 'test-extension', extensionPath: path.join(__dirname, 'plugins/my-plugin/ui'), // highlight-next-line providers: ['providers.ts'], }, ], devMode: true, }); ``` :::info When running the Admin UI in dev mode, you can use the `ctrl + u` keyboard shortcut to see the location of all UI extension points. Clicking on an extension point will display a code snippet which you can copy and paste into your `providers.ts` file. ![Provider extension points](./provider-extension-points.webp) ::: In addition to the specialized UI extension providers listed above, the providers array can also contain any kind of Angular providers which you want to use inside your custom logic. For example, we can define a custom service, add it to the providers array and then consume it from within another provider: ```ts title="src/plugins/my-plugin/ui/providers.ts" import { Injectable } from '@angular/core'; import { addActionBarItem } from '@vendure/admin-ui/core'; // highlight-start @Injectable() class MyService { greet() { return 'Hello!'; } } // highlight-end export default [ MyService, addActionBarItem({ id: 'print-invoice', label: 'Print invoice', locationId: 'order-detail', onClick: (event, context) => { // highlight-start const myService = context.injector.get(MyService); console.log(myService.greet()); // logs "Hello!" // highlight-end }, }), ]; ``` ## Routes Routes allow you to define completely custom views in the Admin UI. ![Custom route](../defining-routes/route-area.webp) Your `routes.ts` file exports an array of objects which define new routes in the Admin UI. For example, imagine you have created a plugin which implements a simple content management system. You can define a route for the list of articles, and another for the detail view of an article. For a detailed instructions, see the [Defining Routes guide](/guides/extending-the-admin-ui/defining-routes/). ## Dev vs Prod mode When you are developing your Admin UI extension, you can set the `devMode` option to `true` which will compile the Admin UI app in development mode, and recompile and auto-refresh the browser on any changes to your extension source files. ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; import * as path from 'path'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ port: 3002, app: compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [ { // ... }, ], devMode: true, }), }), ], }; ``` ## Compiling as a deployment step Although the examples so far all use the `compileUiExtensions` function in conjunction with the AdminUiPlugin, it is also possible to use it on its own: ```ts title="src/compile-admin-ui.ts" import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; import * as path from 'path'; compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [ /* ... */ ], }) .compile?.() .then(() => { process.exit(0); }); ``` This can then be run from the command line: ```bash npx ts-node src/compile-admin-ui.ts ``` ```bash yarn ts-node src/compile-admin-ui.ts ``` Once complete, the production-ready app bundle will be output to `admin-ui/dist/browser`. This method is suitable for a production setup, so that the Admin UI can be compiled ahead-of-time as part of your deployment process. This ensures that your Vendure server starts up as quickly as possible. In this case, you can pass the path of the compiled app to the AdminUiPlugin: ```ts title="src/vendure-config.ts" import { VendureConfig } from '@vendure/core'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import * as path from 'path'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ port: 3002, app: { path: path.join(__dirname, '../admin-ui/dist/browser'), }, }), ], }; ``` :::info To compile the angular app ahead of time (for production) and copy the dist folder to Vendure's output dist folder, include the following commands in your package.json scripts: ```json { "scripts": { "copy": "npx copyfiles -u 1 'admin-ui/dist/**/*' dist", "build": "tsc && yarn copy", "build:admin": "rimraf admin-ui && npx ts-node src/compile-admin-ui.ts" } } ``` "build:admin" will remove the admin-ui folder and run the compileUiExtensions function to generate the admin-ui Angular app. Make sure to install `copyfiles` before running the "copy" command: ```bash npm install copyfiles ``` ```bash yarn add copyfiles ``` ::: ## Using other frameworks While the Admin UI natively supports extensions written with Angular or React, it is still possible to create extensions using other front-end frameworks such as Vue or Solid. Note that creating extensions in this way is much more limited, with only the ability to define new routes, and limited access to internal services such as data fetching and notifications. See [UI extensions in other frameworks](/guides/extending-the-admin-ui/using-other-frameworks/). ## IDE Support ### WebStorm If you are using Angular in your UI extensions and WebStorm is not recognizing the Angular templates, you can add an `angular.json` file to the `/src/plugins//ui` directory: ```json title="angular.json" { "$schema": "../../../../node_modules/@angular/cli/lib/config/schema.json", "version": 1, "newProjectRoot": "projects", "projects": { "ui-extensions": { "root": "", "sourceRoot": "src", "projectType": "application" } } } ``` This allows WebStorm's built-in Angular support to recognize the Angular templates in your UI extensions. Note that depending on your folder structure, you may need to adjust the path to the schema file in the `$schema` property. ### VS Code If you are using Angular in your UI extensions and VS Code is not recognizing the Angular templates, you can add an empty `tsconfig.json` file to the `/src/plugins//ui` directory: ```json title="tsconfig.json" {} ``` This works around the fact that your main `tsconfig.json` file excludes the `src/plugins/**/ui` directory, which would otherwise prevent the Angular Language Service from working correctly. ## Legacy API < v2.1.0 Prior to Vendure v2.1.0, the API for extending the Admin UI was more verbose and less flexible (React components were not supported at all, for instance). This API is still supported, but from v2.1 is marked as deprecated and will be removed in a future major version. This section describes the legacy API. ### Lazy vs Shared Modules Angular uses the concept of modules ([NgModules](https://angular.io/guide/ngmodules)) for organizing related code. These modules can be lazily loaded, which means that the code is not loaded when the app starts, but only later once that code is required. This keeps the main bundle small and improves performance. When creating your UI extensions, you can set your module to be either `lazy` or `shared`. Shared modules are loaded _eagerly_, i.e. their code is bundled up with the main app and loaded as soon as the app loads. As a rule, modules defining new routes should be lazily loaded (so that the code is only loaded once that route is activated), and modules defining [new navigations items](/guides/extending-the-admin-ui/nav-menu/) and [custom form input](/guides/extending-the-admin-ui/custom-form-inputs/) should be set to `shared`. :::info "lazy" modules are equivalent to the new "routes" API, and "shared" modules are equivalent to the new "providers" API. In fact, behind the scenes, the new APIs are automatically creating these modules for you. ::: ### Example lazy module Here's a very simple Angular component which displays a greeting: ```ts title="src/plugins/greeter/ui/components/greeter/greeter.component.ts" import { Component } from '@angular/core'; @Component({ selector: 'greeter', template: `

{{ greeting }}

`, }) export class GreeterComponent { greeting = 'Hello!'; } ``` Next we need to declare an Angular module to house the component: ```ts title="src/plugins/greeter/ui/greeter.module.ts" import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { SharedModule } from '@vendure/admin-ui/core'; import { GreeterComponent } from './greeter.component'; @NgModule({ imports: [ SharedModule, RouterModule.forChild([ { path: '', pathMatch: 'full', component: GreeterComponent, data: { breadcrumb: 'Greeter' }, }, ]), ], declarations: [GreeterComponent], }) export class GreeterModule {} ``` :::note The `SharedModule` should, in general, always be imported by your extension modules. It provides the basic Angular directives and other common functionality that any extension would require. ::: Now we need to tell the `compileUiExtensions` function where to find the extension, and which file contains the NgModule itself (since a non-trivial UI extension will likely contain multiple files). ```ts title="src/vendure-config.ts" import path from 'path'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { VendureConfig } from '@vendure/core'; import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ port: 3002, app: compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [ { extensionPath: path.join(__dirname, 'plugins/greeter/ui'), // highlight-start ngModules: [ { type: 'lazy', route: 'greet', ngModuleFileName: 'greeter.module.ts', ngModuleName: 'GreeterModule', }, ], // highlight-end }, ], }), }), ], }; ``` ### Example shared module Here's an example of the legacy API for defining a shared module: ```ts title="src/plugins/invoices/ui/invoice-shared.module.ts" import { NgModule } from '@angular/core'; import { SharedModule, addActionBarItem } from '@vendure/admin-ui/core'; @NgModule({ imports: [SharedModule], providers: [ addActionBarItem({ id: 'print-invoice', label: 'Print invoice', locationId: 'order-detail', routerLink: route => { const id = route.snapshot.params.id; return ['./extensions/order-invoices', id]; }, requiresPermission: 'ReadOrder', }), ], }) export class InvoiceSharedModule {} ``` ```ts title="src/vendure-config.ts" import path from 'path'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { VendureConfig } from '@vendure/core'; import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ port: 3002, app: compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [ { extensionPath: path.join(__dirname, 'plugins/invoices/ui'), // highlight-start ngModules: [ { type: 'shared', ngModuleFileName: 'invoice-shared.module.ts', ngModuleName: 'InvoiceSharedModule', }, ], // highlight-end }, ], }), }), ], }; ``` ### Migrating to the new API If you have existing UI extensions written using the legacy API, you can migrate them to the new API as follows: 1. Convert all components to be [standalone components](https://angular.io/guide/standalone-components). Standalone components were introduced in recent versions of Angular and allow components to be defined without the need for a module. To convert an existing component, you need to set `standalone: true` and add an `imports` array containing any components, directives or pipes you are using in that component. Typically, you can import `SharedModule` to get access to all the common Angular directives and pipes, as well as the shared Admin UI components. ```ts import { Component } from '@angular/core'; // highlight-next-line import { SharedModule } from '@vendure/admin-ui/core'; @Component({ selector: 'greeter', template: `

{{ greeting }}

`, // highlight-start standalone: true, imports: [SharedModule], // highlight-end }) export class GreeterComponent { greeting = 'Hello!'; } ``` 2. In templates for page components, remove the `` and `` components, as they are included by default now when using the `registeRouteComponent()` function: ```html // highlight-start // highlight-end This content should remain // highlight-next-line ``` 3. Remove any `NgModule` files, and replace lazy modules with `routes.ts`, and shared modules with `providers.ts` (see above). --- --- title: 'Modify the Nav Menu' weight: 5 --- The Nav Menu is the main navigation for the Admin UI, located on the left-hand side when in desktop mode. It is used to provide top-level access to routes in the app, and can be extended and modified by UI extensions. ## Extending the NavMenu Once you have defined some custom routes, you need some way for the administrator to access them. For this you will use the [addNavMenuItem](/reference/admin-ui-api/nav-menu/add-nav-menu-item/) and [addNavMenuSection](/reference/admin-ui-api/nav-menu/add-nav-menu-section) functions. Let's add a new section to the Admin UI main nav bar containing a link to the "greeter" module from the [Getting Started guide](/guides/extending-the-admin-ui/getting-started/#routes) example: ```ts title="src/plugins/greeter/ui/providers.ts" import { addNavMenuSection } from '@vendure/admin-ui/core'; export default [ addNavMenuSection({ id: 'greeter', label: 'My Extensions', items: [{ id: 'greeter', label: 'Greeter', routerLink: ['/extensions/greet'], // Icon can be any of https://core.clarity.design/foundation/icons/shapes/ icon: 'cursor-hand-open', }], }, // Add this section before the "settings" section 'settings'), ]; ``` Now we must also register these providers with the compiler: ```ts title="src/vendure-config.ts" import path from 'path'; import { VendureConfig } from '@vendure/core'; import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; export const config: VendureConfig = { // ... plugins: [ AdminUiPlugin.init({ port: 3002, app: compileUiExtensions({ outputPath: path.join(__dirname, '../admin-ui'), extensions: [ { id: 'greeter', extensionPath: path.join(__dirname, 'plugins/greeter/ui'), routes: [{ route: 'greet', filePath: 'routes.ts' }], // highlight-start providers: ['providers.ts'] // highlight-end }, ], }), }), ], }; ``` Running the server will compile our new shared module into the app, and the result should look like this: ![./ui-extensions-navbar.webp](./ui-extensions-navbar.webp) ## Overriding existing nav items It is also possible to override one of the default (built-in) nav menu sections or items. This can be useful for example if you wish to provide a completely different implementation of the product list view. This is done by setting the `id` property to that of an existing nav menu section or item. The `id` can be found by inspecting the link element in your browser's dev tools for the `data-item-id` attribute: ![Navbar menu id](./nav-menu-id.webp) ## Removing existing nav items If you would like to remove an existing nav item, you can do so by overriding it and setting the `requiresPermission` property to an invalid value: ```ts title="src/plugins/greeter/ui/providers.ts" import { SharedModule, addNavMenuItem} from '@vendure/admin-ui/core'; export default [ addNavMenuItem({ id: 'collections', // <-- we will override the "collections" menu item label: 'Collections', routerLink: ['/catalog', 'collections'], // highlight-start // we use an invalid permission which ensures it is hidden from all users requiresPermission: '__disable__' // highlight-end }, 'catalog'), ]; ``` --- --- title: 'Page Tabs' weight: 5 --- You can add your own tabs to any of the Admin UI's list or detail pages using the [registerPageTab](/reference/admin-ui-api/tabs/register-page-tab/) function. For example, to add a new tab to the product detail page for displaying product reviews: ```ts title="src/plugins/reviews/ui/providers.ts" import { registerPageTab } from '@vendure/admin-ui/core'; import { ReviewListComponent } from './components/review-list/review-list.component'; export default [ registerPageTab({ location: 'product-detail', tab: 'Reviews', route: 'reviews', tabIcon: 'star', component: ReviewListComponent, }), ]; ``` ![./ui-extensions-tabs.webp](./ui-extensions-tabs.webp) If you want to add page tabs to a custom admin page, specify the `locationId` property: ```ts title="src/plugins/my-plugin/ui/routes.ts" import { registerRouteComponent } from '@vendure/admin-ui/core'; import { TestComponent } from './components/test/test.component'; export default [ registerRouteComponent({ component: TestComponent, title: 'Test', // highlight-next-line locationId: 'my-location-id' }), ]; ``` :::note Currently it is only possible to define new tabs using Angular components. ::: --- --- title: "UI Component Library" --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; The Admin UI is built on a customized version of the [Clarity Design System](https://clarity.design/documentation/get-started). This means that if you are writing Angular-based UI extensions, you can use the same components that are used in the rest of the Admin UI. If you are using React, we are gradually exporting the most-used components for use with React. ## Buttons ![Buttons](./buttons.webp) There are three types of button: - **Regular**: These are general-purpose buttons available in a number of styles. They are used in the action bar and as confirmation buttons for dialogs. - **Ghost**: This style is primarily used to indicate a column in a table which is a link to a detail view. - **Small**: This style is used for secondary actions of less prominence than a standard button, or when the button must fit in a small space. ```html John Smith ``` ```tsx import React from 'react'; import { CdsIcon, Link } from '@vendure/admin-ui/react'; export function DemoComponent() { return ( <> John Smith ); } ``` ## Icons You can use the built-in [Clarity Icons](https://core.clarity.design/foundation/icons/shapes/) for a consistent look-and-feel with the rest of the Admin UI app. ![Icons](./icons.webp) ```html ``` ```tsx import React from 'react'; import { starIcon, userIcon } from '@cds/core/icon'; import { CdsIcon } from '@vendure/admin-ui/react'; export function DemoComponent() { return ( <> ); } ``` ## Form inputs Form inputs are styled globally, so you don't need to use special components for these. The label & tooltip styling is controlled by the "form field" wrapper component. ![Form input](./form-inputs.webp) ```html
```
```tsx import React from 'react'; import { starIcon, userIcon } from '@cds/core/icon'; import { FormField, RichTextEditor } from '@vendure/admin-ui/react'; export function DemoComponent() { return (