1
0
Эх сурвалжийг харах

Merge branch 'master' into minor

Michael Bromley 2 жил өмнө
parent
commit
901b055b00
65 өөрчлөгдсөн 2111 нэмэгдсэн , 232 устгасан
  1. 3 0
      README.md
  2. BIN
      docs/docs/guides/core-concepts/email/email-plugin-flow.webp
  3. 155 0
      docs/docs/guides/core-concepts/email/index.mdx
  4. BIN
      docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/01-create-space.webp
  5. BIN
      docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/02-space-access-keys.webp
  6. BIN
      docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/03-create-app.webp
  7. BIN
      docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/04-configure-server.webp
  8. BIN
      docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/05-open-app.webp
  9. BIN
      docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/deploy-to-do-app-platform.webp
  10. 216 0
      docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/index.md
  11. 0 1
      docs/docs/guides/deployment/deploy-to-google-cloud-run/index.md
  12. BIN
      docs/docs/guides/deployment/deploy-to-northflank/01-create-template-screen.webp
  13. BIN
      docs/docs/guides/deployment/deploy-to-northflank/02-paste-config.webp
  14. BIN
      docs/docs/guides/deployment/deploy-to-northflank/03-run-template.webp
  15. BIN
      docs/docs/guides/deployment/deploy-to-northflank/04-find-project.webp
  16. BIN
      docs/docs/guides/deployment/deploy-to-northflank/05-server-service.webp
  17. BIN
      docs/docs/guides/deployment/deploy-to-northflank/06-find-url.webp
  18. 383 55
      docs/docs/guides/deployment/deploy-to-northflank/index.md
  19. BIN
      docs/docs/guides/deployment/deploy-to-railway/01-new-service.webp
  20. BIN
      docs/docs/guides/deployment/deploy-to-railway/02-env-vars.webp
  21. BIN
      docs/docs/guides/deployment/deploy-to-railway/03-test-server.webp
  22. BIN
      docs/docs/guides/deployment/deploy-to-railway/deploy-to-railway.webp
  23. 202 0
      docs/docs/guides/deployment/deploy-to-railway/index.md
  24. BIN
      docs/docs/guides/deployment/deploy-to-render/01-create-db.webp
  25. BIN
      docs/docs/guides/deployment/deploy-to-render/02-env-group.webp
  26. BIN
      docs/docs/guides/deployment/deploy-to-render/03-db-connection.webp
  27. BIN
      docs/docs/guides/deployment/deploy-to-render/04-link-env-group.webp
  28. BIN
      docs/docs/guides/deployment/deploy-to-render/05-server-url.webp
  29. BIN
      docs/docs/guides/deployment/deploy-to-render/deploy-to-render.webp
  30. 221 0
      docs/docs/guides/deployment/deploy-to-render/index.md
  31. 26 1
      docs/docs/guides/deployment/deploying-admin-ui.md
  32. 1 1
      docs/docs/guides/developer-guide/configuration/index.md
  33. 1 1
      docs/docs/guides/developer-guide/events/index.mdx
  34. 127 4
      docs/docs/guides/developer-guide/the-api-layer/index.mdx
  35. 3 0
      docs/docs/guides/developer-guide/updating/index.md
  36. 3 2
      docs/docs/reference/core-plugins/elasticsearch-plugin/index.md
  37. 0 59
      docs/docs/reference/core-plugins/email-plugin/custom-template-loader.md
  38. 14 14
      docs/docs/reference/core-plugins/email-plugin/email-plugin-options.md
  39. 4 4
      docs/docs/reference/core-plugins/email-plugin/email-plugin-types.md
  40. 66 0
      docs/docs/reference/core-plugins/email-plugin/email-utils.md
  41. 12 13
      docs/docs/reference/core-plugins/email-plugin/index.md
  42. 100 0
      docs/docs/reference/core-plugins/email-plugin/template-loader.md
  43. 4 1
      docs/sidebars.js
  44. 4 0
      docs/src/css/custom.css
  45. 1 1
      package.json
  46. 1 2
      packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts
  47. 14 5
      packages/admin-ui/src/lib/order/src/components/refund-order-dialog/refund-order-dialog.component.ts
  48. 3 3
      packages/admin-ui/src/lib/settings/src/settings.routes.ts
  49. 26 1
      packages/core/e2e/fixtures/test-shipping-eligibility-checkers.ts
  50. 16 16
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  51. 1 0
      packages/core/e2e/graphql/shop-definitions.ts
  52. 263 0
      packages/core/e2e/order-multiple-shipping.e2e-spec.ts
  53. 18 0
      packages/core/e2e/role.e2e-spec.ts
  54. 81 5
      packages/core/e2e/shop-order.e2e-spec.ts
  55. 2 2
      packages/core/src/health-check/health-check-registry.service.ts
  56. 2 1
      packages/core/src/health-check/health-check.module.ts
  57. 49 6
      packages/core/src/health-check/http-health-check-strategy.ts
  58. 7 0
      packages/core/src/service/helpers/order-calculator/order-calculator.ts
  59. 5 5
      packages/core/src/service/services/administrator.service.ts
  60. 5 0
      packages/core/src/service/services/order.service.ts
  61. 33 5
      packages/core/src/service/services/role.service.ts
  62. 6 0
      packages/email-plugin/src/default-email-handlers.ts
  63. 11 12
      packages/email-plugin/src/plugin.ts
  64. 16 9
      packages/email-plugin/src/template-loader.ts
  65. 6 3
      packages/email-plugin/src/types.ts

+ 3 - 0
README.md

@@ -6,6 +6,9 @@ An open-source headless commerce platform built on [Node.js](https://nodejs.org)
 ![Publish & Install](https://github.com/vendure-ecommerce/vendure/workflows/Publish%20&%20Install/badge.svg)
 [![lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg)](https://lernajs.io/)
 
+![vendure_github_banner (1)](https://github.com/vendure-ecommerce/vendure/assets/6275952/0e25b1c7-a648-44a1-ba00-60012f0e7aaa)
+
+
 ### [www.vendure.io](https://www.vendure.io/)
 
 * [Getting Started](https://docs.vendure.io/getting-started/): Get Vendure up and running locally in a matter of minutes with a single command

BIN
docs/docs/guides/core-concepts/email/email-plugin-flow.webp


+ 155 - 0
docs/docs/guides/core-concepts/email/index.mdx

@@ -0,0 +1,155 @@
+---
+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" <noreply@myshop.com>',
+                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);
+            });
+    }
+}
+```

BIN
docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/01-create-space.webp


BIN
docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/02-space-access-keys.webp


BIN
docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/03-create-app.webp


BIN
docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/04-configure-server.webp


BIN
docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/05-open-app.webp


BIN
docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/deploy-to-do-app-platform.webp


+ 216 - 0
docs/docs/guides/deployment/deploy-to-digital-ocean-app-platform/index.md

@@ -0,0 +1,216 @@
+---
+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 `<angle brackets>` 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=<add some random characters>
+SUPERADMIN_USERNAME=superadmin
+// highlight-next-line
+SUPERADMIN_PASSWORD=<create some strong password>
+// highlight-start
+MINIO_ACCESS_KEY=<use the key generated earlier>
+MINIO_SECRET_KEY=<use the secret generated earlier>
+MINIO_ENDPOINT=<use the endpoint of your spaces bucket>
+// 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`.
+:::
+
+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.
+
+## 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](./05-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.
+:::

+ 0 - 1
docs/docs/guides/deployment/deploy-to-google-cloud-run/index.md

@@ -1,6 +1,5 @@
 ---
 title: "Deploying to Google Cloud Run"
-showtoc: true
 images: 
     - "/docs/deployment/deploy-to-google-cloud-run/deploy-to-gcr.webp"
 ---

BIN
docs/docs/guides/deployment/deploy-to-northflank/01-create-template-screen.webp


BIN
docs/docs/guides/deployment/deploy-to-northflank/02-paste-config.webp


BIN
docs/docs/guides/deployment/deploy-to-northflank/03-run-template.webp


BIN
docs/docs/guides/deployment/deploy-to-northflank/04-find-project.webp


BIN
docs/docs/guides/deployment/deploy-to-northflank/05-server-service.webp


BIN
docs/docs/guides/deployment/deploy-to-northflank/06-find-url.webp


+ 383 - 55
docs/docs/guides/deployment/deploy-to-northflank/index.md

@@ -5,41 +5,56 @@ 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 your free Northflank account
+## 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**, and a **Postgres database**.
+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 left sidebar and click the "Create template" button.
+Click the templates menu item in the navbar and click the "Create template" button.
 
 ![./01-create-template-screen.webp](./01-create-template-screen.webp)
 
-Fill in the name of the template (though note that the name will be replaced by the name "Vendure Template" based on the configuration below).
-
-Now paste the following configuration into the "Template" code editor:
+Now paste the following configuration into the editor in the "code" tab:
 
 ![./02-paste-config.webp](./02-paste-config.webp)
 
+<Tabs>
+<TabItem label="Full Template" value="Full Template">
+
+:::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".
+:::
+
+<div class="limited-height-code-block">
 
 ```json
 {
-  "name": "Vendure Template",
-  "description": "Vendure is a modern, open-source headless commerce framework built with TypeScript & Node.js.",
   "apiVersion": "v1",
+  "name": "Vendure Template",
+  "description": "Vendure is a modern, open-source composable commerce platform",
   "project": {
     "spec": {
-      "name": "Vendure Template",
-      "description": "Vendure is a modern, open-source headless commerce framework built with TypeScript & Node.js.",
+      "name": "Vendure",
       "region": "europe-west",
+      "description": "Vendure is a modern, open-source composable commerce platform",
       "color": "#57637A"
     }
   },
@@ -49,71 +64,143 @@ Now paste the following configuration into the "Template" code editor:
       "type": "sequential",
       "steps": [
         {
-          "kind": "Addon",
+          "kind": "Workflow",
           "spec": {
-            "name": "database",
-            "type": "postgres",
-            "version": "latest",
-            "billing": {
-              "deploymentPlan": "nf-compute-10",
-              "storageClass": "ssd",
-              "storage": 4096,
-              "replicas": 1
-            },
-            "tlsEnabled": false,
-            "externalAccessEnabled": false
+            "type": "parallel",
+            "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": {
-            "name": "secrets",
             "secretType": "environment-arguments",
             "priority": 10,
+            "name": "secrets",
             "secrets": {
               "variables": {
                 "APP_ENV": "production",
                 "COOKIE_SECRET": "${fn.randomSecret(32)}",
                 "SUPERADMIN_USERNAME": "superadmin",
-                "SUPERADMIN_PASSWORD": "superadmin",
+                "SUPERADMIN_PASSWORD": "${fn.randomSecret(16)}",
                 "DB_SCHEMA": "public"
-              }
+              },
+              "files": {}
             },
             "addonDependencies": [
               {
-                "addonId": "database",
+                "addonId": "${refs.database.id}",
                 "keys": [
                   {
                     "keyName": "HOST",
-                    "aliases": ["DB_HOST"]
+                    "aliases": [
+                      "DB_HOST"
+                    ]
                   },
                   {
                     "keyName": "PORT",
-                    "aliases": ["DB_PORT"]
+                    "aliases": [
+                      "DB_PORT"
+                    ]
                   },
                   {
                     "keyName": "DATABASE",
-                    "aliases": ["DB_NAME"]
+                    "aliases": [
+                      "DB_NAME"
+                    ]
                   },
                   {
                     "keyName": "USERNAME",
-                    "aliases": ["DB_USERNAME"]
+                    "aliases": [
+                      "DB_USERNAME"
+                    ]
                   },
                   {
                     "keyName": "PASSWORD",
-                    "aliases": ["DB_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": "build",
+            "name": "builder",
             "billing": {
-              "deploymentPlan": "nf-compute-10"
+              "deploymentPlan": "nf-compute-20"
             },
             "vcsData": {
               "projectUrl": "https://github.com/vendure-ecommerce/one-click-deploy",
@@ -126,9 +213,25 @@ Now paste the following configuration into the "Template" code editor:
                 "dockerWorkDir": "/",
                 "useCache": false
               }
-            }
+            },
+            "disabledCI": false,
+            "buildArguments": {}
           }
         },
+        {
+          "kind": "Build",
+          "spec": {
+            "id": "${refs.builder.id}",
+            "type": "service",
+            "branch": "master",
+            "buildOverrides": {
+              "buildArguments": {}
+            },
+            "reuseExistingBuilds": true
+          },
+          "condition": "success",
+          "ref": "build"
+        },
         {
           "kind": "Workflow",
           "spec": {
@@ -137,30 +240,37 @@ Now paste the following configuration into the "Template" code editor:
               {
                 "kind": "DeploymentService",
                 "spec": {
-                  "name": "server",
-                  "billing": {
-                    "deploymentPlan": "nf-compute-10"
-                  },
                   "deployment": {
                     "instances": 1,
                     "docker": {
                       "configType": "customCommand",
-                      "customCommand": "yarn start:server"
+                      "customCommand": "node ./dist/index.js"
                     },
                     "internal": {
-                      "id": "build",
-                      "branch": "master",
+                      "buildId": "${refs.build.id}",
                       "buildSHA": "latest"
                     }
                   },
+                  "name": "server",
+                  "billing": {
+                    "deploymentPlan": "nf-compute-20"
+                  },
                   "ports": [
                     {
-                      "name": "p01",
+                      "name": "app",
                       "internalPort": 3000,
                       "public": true,
-                      "protocol": "HTTP"
+                      "protocol": "HTTP",
+                      "security": {
+                        "credentials": [],
+                        "policies": []
+                      },
+                      "domains": [],
+                      "disableNfDomain": false
                     }
-                  ]
+                  ],
+                  "runtimeEnvironment": {},
+                  "runtimeFiles": {}
                 }
               },
               {
@@ -174,25 +284,230 @@ Now paste the following configuration into the "Template" code editor:
                     "instances": 1,
                     "docker": {
                       "configType": "customCommand",
-                      "customCommand": "yarn start:worker"
+                      "customCommand": "node ./dist/index-worker.js"
                     },
                     "internal": {
-                      "id": "build",
-                      "branch": "master",
+                      "buildId": "${refs.build.id}",
                       "buildSHA": "latest"
                     }
-                  }
+                  },
+                  "ports": [],
+                  "runtimeEnvironment": {}
                 }
               }
             ]
           }
+        }
+      ]
+    }
+  }
+}
+```
+
+</div>
+
+</TabItem>
+
+
+<TabItem label="Lite Template" value="Lite Template">
+
+:::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.
+:::
+
+
+<div class="limited-height-code-block">
+
+```json
+{
+  "apiVersion": "v1",
+  "name": "Vendure Lite Template",
+  "description": "Vendure is a modern, open-source composable commerce platform",
+  "project": {
+    "spec": {
+      "name": "Vendure Lite",
+      "region": "europe-west",
+      "description": "Vendure is a modern, open-source composable commerce platform",
+      "color": "#17b9ff"
+    }
+  },
+  "spec": {
+    "kind": "Workflow",
+    "spec": {
+      "type": "sequential",
+      "steps": [
+        {
+          "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": {
+            "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",
+            "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": "build",
+            "id": "builder",
             "type": "service",
-            "branch": "master"
+            "branch": "master",
+            "reuseExistingBuilds": true
+          },
+          "condition": "success"
+        },
+        {
+          "kind": "DeploymentService",
+          "spec": {
+            "name": "server",
+            "billing": {
+              "deploymentPlan": "nf-compute-20"
+            },
+            "deployment": {
+              "instances": 1,
+              "docker": {
+                "configType": "customCommand",
+                "customCommand": "yarn start:server"
+              },
+              "internal": {
+                "buildId": "${refs.build.id}",
+                "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"
+              }
+            ]
           }
         }
       ]
@@ -201,6 +516,11 @@ Now paste the following configuration into the "Template" code editor:
 }
 ```
 
+</div>
+
+</TabItem>
+</Tabs>
+
 Then click the "Create template" button.
 
 ## Run the template
@@ -223,19 +543,27 @@ 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.
+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:
+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:
 
-- [Set up a MinIO instance](https://northflank.com/docs/v1/application/databases-and-persistence/deploy-databases-on-northflank/deploy-minio-on-northflank) for storing assets using our [S3 asset storage integration]({{< relref "s3asset-storage-strategy" >}}#configures3assetstorage).
-- [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]({{< relref "bull-mqjob-queue-plugin" >}}) and set up [Redis-based session caching]({{< relref "session-cache-strategy" >}}) to handle multi-instance deployments.
+- 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.

BIN
docs/docs/guides/deployment/deploy-to-railway/01-new-service.webp


BIN
docs/docs/guides/deployment/deploy-to-railway/02-env-vars.webp


BIN
docs/docs/guides/deployment/deploy-to-railway/03-test-server.webp


BIN
docs/docs/guides/deployment/deploy-to-railway/deploy-to-railway.webp


+ 202 - 0
docs/docs/guides/deployment/deploy-to-railway/index.md

@@ -0,0 +1,202 @@
+---
+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=<add some random characters>
+SUPERADMIN_USERNAME=superadmin
+// highlight-next-line
+SUPERADMIN_PASSWORD=<create some strong 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)
+  

BIN
docs/docs/guides/deployment/deploy-to-render/01-create-db.webp


BIN
docs/docs/guides/deployment/deploy-to-render/02-env-group.webp


BIN
docs/docs/guides/deployment/deploy-to-render/03-db-connection.webp


BIN
docs/docs/guides/deployment/deploy-to-render/04-link-env-group.webp


BIN
docs/docs/guides/deployment/deploy-to-render/05-server-url.webp


BIN
docs/docs/guides/deployment/deploy-to-render/deploy-to-render.webp


+ 221 - 0
docs/docs/guides/deployment/deploy-to-render/index.md

@@ -0,0 +1,221 @@
+---
+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=<database "Database">
+DB_USERNAME=<database "Username">
+DB_PASSWORD=<database "Password">
+DB_HOST=<database "Hostname">
+DB_PORT=<database "Port">
+ASSET_UPLOAD_DIR=/vendure-assets
+// highlight-next-line
+COOKIE_SECRET=<add some random characters>
+SUPERADMIN_USERNAME=superadmin
+// highlight-next-line
+SUPERADMIN_PASSWORD=<create some strong 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)
+  

+ 26 - 1
docs/docs/guides/deployment/deploying-admin-ui.md

@@ -6,7 +6,32 @@ showtoc: true
 
 If you have customized the Admin UI with extensions, you should [compile your extensions ahead of time as part of the deployment process](/guides/extending-the-admin-ui/getting-started/#compiling-as-a-deployment-step).
 
-### Deploying a stand-alone Admin UI
+## 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.
 

+ 1 - 1
docs/docs/guides/developer-guide/configuration/index.md

@@ -217,7 +217,7 @@ export const plugins: VendureConfig['plugins'] = [
 
 ```ts title="src/vendure-config.ts"
 import { VendureConfig } from '@vendure/core';
-import { plugins } from 'vendure-config-plugins';
+import { plugins } from './vendure-config-plugins';
 
 export const config: VendureConfig = {
   plugins,

+ 1 - 1
docs/docs/guides/developer-guide/events/index.mdx

@@ -91,7 +91,7 @@ lifecycle hooks of a plugin or service (see [NestJS Lifecycle events](https://do
 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 { Injectable, OnModuleInit } from '@nestjs/common';
+import { OnModuleInit } from '@nestjs/common';
 import { EventBus, ProductEvent, PluginCommonModule, VendurePlugin } from '@vendure/core';
 
 import { StorefrontBuildService } from './services/storefront-build.service';

+ 127 - 4
docs/docs/guides/developer-guide/the-api-layer/index.mdx

@@ -67,18 +67,141 @@ If you have your local development server running, you can try this out by openi
 "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
+### 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.
-* **NestJS-specific middleware**: NestJS allows you to define specific types of middleware including [Guards](https://docs.nestjs.com/guards),
+
+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. They are defined via decorators on custom resolvers or controllers.
-* **Apollo Server plugins**: Apollo Server (the underlying GraphQL server library used by Vendure) allows you to define
+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,
+        },
+        {
+            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

+ 3 - 0
docs/docs/guides/developer-guide/updating/index.md

@@ -33,6 +33,9 @@ Then run `npm install` or `yarn install` depending on which package manager you
 
 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).

+ 3 - 2
docs/docs/reference/core-plugins/elasticsearch-plugin/index.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## ElasticsearchPlugin
 
-<GenerationInfo sourceFile="packages/elasticsearch-plugin/src/plugin.ts" sourceLine="222" packageName="@vendure/elasticsearch-plugin" />
+<GenerationInfo sourceFile="packages/elasticsearch-plugin/src/plugin.ts" sourceLine="223" packageName="@vendure/elasticsearch-plugin" />
 
 This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
 engine. This is a drop-in replacement for the DefaultSearchPlugin which exposes many powerful configuration options enabling your storefront
@@ -22,7 +22,8 @@ advanced Elasticsearch features like spacial search.
 
 **Requires Elasticsearch v7.0 < required Elasticsearch version < 7.10 **
 Elasticsearch version 7.10.2 will throw error due to incompatibility with elasticsearch-js client.
-[Check here for more info](https://github.com/elastic/elasticsearch-js/issues/1519)
+[Check here for more info](https://github.com/elastic/elasticsearch-js/issues/1519).
+
 `yarn add @elastic/elasticsearch @vendure/elasticsearch-plugin`
 
 or

+ 0 - 59
docs/docs/reference/core-plugins/email-plugin/custom-template-loader.md

@@ -1,59 +0,0 @@
----
-title: "Custom Template Loader"
-isDefaultIndex: false
-generated: true
----
-<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
-import MemberInfo from '@site/src/components/MemberInfo';
-import GenerationInfo from '@site/src/components/GenerationInfo';
-import MemberDescription from '@site/src/components/MemberDescription';
-
-
-## TemplateLoader
-
-<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="391" 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*
-
-```ts
-import { EmailPlugin, TemplateLoader } from '@vendure/email-plugin';
-
-class MyTemplateLoader implements TemplateLoader {
-     loadTemplate(injector, ctx, { type, templateName }){
-         return myCustomTemplateFunction(ctx);
-     }
-}
-
-// In vendure-config.ts:
-...
-EmailPlugin.init({
-    templateLoader: new MyTemplateLoader()
-    ...
-})
-```
-
-```ts title="Signature"
-interface TemplateLoader {
-    loadTemplate(injector: Injector, ctx: RequestContext, input: LoadTemplateInput): Promise<string>;
-    loadPartials?(): Promise<Partial[]>;
-}
-```
-
-<div className="members-wrapper">
-
-### loadTemplate
-
-<MemberInfo kind="method" type={`(injector: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: LoadTemplateInput) => Promise&#60;string&#62;`}   />
-
-
-### loadPartials
-
-<MemberInfo kind="method" type={`() => Promise&#60;Partial[]&#62;`}   />
-
-
-
-
-</div>

+ 14 - 14
docs/docs/reference/core-plugins/email-plugin/email-plugin-options.md

@@ -19,11 +19,11 @@ Configuration for the EmailPlugin.
 interface EmailPluginOptions {
     templatePath?: string;
     templateLoader?: TemplateLoader;
-    transport:
-        | EmailTransportOptions
-        | ((
-              injector?: Injector,
-              ctx?: RequestContext,
+    transport:
+        | EmailTransportOptions
+        | ((
+              injector?: Injector,
+              ctx?: RequestContext,
           ) => EmailTransportOptions | Promise<EmailTransportOptions>);
     handlers: Array<EmailEventHandler<string, any>>;
     globalTemplateVars?: { [key: string]: any };
@@ -38,43 +38,43 @@ interface EmailPluginOptions {
 
 <MemberInfo kind="property" type={`string`}   />
 
-The path to the location of the email templates. In a default Vendure installation,
+The path to the location of the email templates. In a default Vendure installation,
 the templates are installed to `<project root>/vendure/email/templates`.
 ### templateLoader
 
-<MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/custom-template-loader#templateloader'>TemplateLoader</a>`}  since="2.0.0"  />
+<MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/template-loader#templateloader'>TemplateLoader</a>`}  since="2.0.0"  />
 
-An optional TemplateLoader which can be used to load templates from a custom location or async service.
+An optional TemplateLoader which can be used to load templates from a custom location or async service.
 The default uses the FileBasedTemplateLoader which loads templates from `<project root>/vendure/email/templates`
 ### transport
 
-<MemberInfo kind="property" type={`| <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>
         | ((
               injector?: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>,
               ctx?: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>,
           ) =&#62; <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a> | Promise&#60;<a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>&#62;)`}   />
+<MemberInfo kind="property" type={`| <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>         | ((               injector?: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>,               ctx?: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>,           ) =&#62; <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a> | Promise&#60;<a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>&#62;)`}   />
 
 Configures how the emails are sent.
 ### handlers
 
 <MemberInfo kind="property" type={`Array&#60;<a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;string, any&#62;&#62;`}   />
 
-An array of <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>s which define which Vendure events will trigger
+An array of <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>s which define which Vendure events will trigger
 emails, and how those emails are generated.
 ### globalTemplateVars
 
 <MemberInfo kind="property" type={`{ [key: string]: any }`}   />
 
-An object containing variables which are made available to all templates. For example,
-the storefront URL could be defined here and then used in the "email address verification"
+An object containing variables which are made available to all templates. For example,
+the storefront URL could be defined here and then used in the "email address verification"
 email.
 ### emailSender
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/email-sender#emailsender'>EmailSender</a>`} default="<a href='/reference/core-plugins/email-plugin/email-sender#nodemaileremailsender'>NodemailerEmailSender</a>"   />
 
-An optional allowed EmailSender, used to allow custom implementations of the send functionality
+An optional allowed EmailSender, used to allow custom implementations of the send functionality
 while still utilizing the existing emailPlugin functionality.
 ### emailGenerator
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/email-generator#emailgenerator'>EmailGenerator</a>`} default="<a href='/reference/core-plugins/email-plugin/email-generator#handlebarsmjmlgenerator'>HandlebarsMjmlGenerator</a>"   />
 
-An optional allowed EmailGenerator, used to allow custom email generation functionality to
+An optional allowed EmailGenerator, used to allow custom email generation functionality to
 better match with custom email sending functionality.
 
 

+ 4 - 4
docs/docs/reference/core-plugins/email-plugin/email-plugin-types.md

@@ -130,7 +130,7 @@ type EmailAttachment = Omit<Attachment, 'raw'> & { path?: string }
 
 ## SetTemplateVarsFn
 
-<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="411" packageName="@vendure/email-plugin" />
+<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="414" packageName="@vendure/email-plugin" />
 
 A function used to define template variables available to email templates.
 See <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>.setTemplateVars().
@@ -145,7 +145,7 @@ type SetTemplateVarsFn<Event> = (
 
 ## SetAttachmentsFn
 
-<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="425" packageName="@vendure/email-plugin" />
+<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="428" packageName="@vendure/email-plugin" />
 
 A function used to define attachments to be sent with the email.
 See https://nodemailer.com/message/attachments/ for more information about
@@ -158,7 +158,7 @@ type SetAttachmentsFn<Event> = (event: Event) => EmailAttachment[] | Promise<Ema
 
 ## OptionalAddressFields
 
-<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="435" packageName="@vendure/email-plugin" since="1.1.0" />
+<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="438" packageName="@vendure/email-plugin" since="1.1.0" />
 
 Optional address-related fields for sending the email.
 
@@ -194,7 +194,7 @@ An email address that will appear on the _Reply-To:_ field
 
 ## SetOptionalAddressFieldsFn
 
-<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="461" packageName="@vendure/email-plugin" since="1.1.0" />
+<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="464" packageName="@vendure/email-plugin" since="1.1.0" />
 
 A function used to set the <a href='/reference/core-plugins/email-plugin/email-plugin-types#optionaladdressfields'>OptionalAddressFields</a>.
 

+ 66 - 0
docs/docs/reference/core-plugins/email-plugin/email-utils.md

@@ -0,0 +1,66 @@
+---
+title: "Email Utils"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## transformOrderLineAssetUrls
+
+<GenerationInfo sourceFile="packages/email-plugin/src/default-email-handlers.ts" sourceLine="101" packageName="@vendure/email-plugin" />
+
+Applies the configured `AssetStorageStrategy.toAbsoluteUrl()` function to each of the
+OrderLine's `featuredAsset.preview` properties, so that they can be correctly displayed
+in the email template.
+This is required since that step usually happens at the API in middleware, which is not
+applicable in this context. So we need to do it manually.
+
+**Note: Mutates the Order object**
+
+```ts title="Signature"
+function transformOrderLineAssetUrls(ctx: RequestContext, order: Order, injector: Injector): Order
+```
+Parameters
+
+### ctx
+
+<MemberInfo kind="parameter" type={`<a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>`} />
+
+### order
+
+<MemberInfo kind="parameter" type={`<a href='/reference/typescript-api/entities/order#order'>Order</a>`} />
+
+### injector
+
+<MemberInfo kind="parameter" type={`<a href='/reference/typescript-api/common/injector#injector'>Injector</a>`} />
+
+
+
+## hydrateShippingLines
+
+<GenerationInfo sourceFile="packages/email-plugin/src/default-email-handlers.ts" sourceLine="122" packageName="@vendure/email-plugin" />
+
+Ensures that the ShippingLines are hydrated so that we can use the
+`shippingMethod.name` property in the email template.
+
+```ts title="Signature"
+function hydrateShippingLines(ctx: RequestContext, order: Order, injector: Injector): Promise<ShippingLine[]>
+```
+Parameters
+
+### ctx
+
+<MemberInfo kind="parameter" type={`<a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>`} />
+
+### order
+
+<MemberInfo kind="parameter" type={`<a href='/reference/typescript-api/entities/order#order'>Order</a>`} />
+
+### injector
+
+<MemberInfo kind="parameter" type={`<a href='/reference/typescript-api/common/injector#injector'>Injector</a>`} />
+

+ 12 - 13
docs/docs/reference/core-plugins/email-plugin/index.md

@@ -11,15 +11,14 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## EmailPlugin
 
-<GenerationInfo sourceFile="packages/email-plugin/src/plugin.ts" sourceLine="277" packageName="@vendure/email-plugin" />
+<GenerationInfo sourceFile="packages/email-plugin/src/plugin.ts" sourceLine="276" packageName="@vendure/email-plugin" />
 
 The EmailPlugin creates and sends transactional emails based on Vendure events. By default, it uses an [MJML](https://mjml.io/)-based
 email generator to generate the email body and [Nodemailer](https://nodemailer.com/about/) to send the emails.
 
 ## High-level description
-Vendure has an internal events system (see <a href='/reference/typescript-api/events/event-bus#eventbus'>EventBus</a>) that allows plugins to subscribe to events. The EmailPlugin is configured with
-<a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>s that listen for a specific event and when it is published, the handler defines which template to use to generate
-the resulting email.
+Vendure has an internal events system (see <a href='/reference/typescript-api/events/event-bus#eventbus'>EventBus</a>) that allows plugins to subscribe to events. The EmailPlugin is configured with <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>s
+that listen for a specific event and when it is published, the handler defines which template to use to generate the resulting email.
 
 The plugin comes with a set of default handlers for the following events:
 - Order confirmation
@@ -88,7 +87,7 @@ language which makes the task of creating responsive email markup simple. By def
 
 Dynamic data such as the recipient's name or order items are specified using [Handlebars syntax](https://handlebarsjs.com/):
 
-```HTML
+```html
 <p>Dear {{ order.customer.firstName }} {{ order.customer.lastName }},</p>
 
 <p>Thank you for your order!</p>
@@ -136,12 +135,12 @@ import { CustomerService } from '@vendure/core';
 
 // This allows you to then customize each handler to your needs.
 // For example, let's set a new subject line to the order confirmation:
-orderConfirmationHandler
+const myOrderConfirmationHandler = orderConfirmationHandler
   .setSubject(`We received your order!`);
 
 // Another example: loading additional data and setting new
 // template variables.
-passwordResetHandler
+const myPasswordResetHandler = passwordResetHandler
   .loadData(async ({ event, injector }) => {
     const customerService = injector.get(CustomerService);
     const customer = await customerService.findOneByUserId(event.ctx, event.user.id);
@@ -156,9 +155,9 @@ passwordResetHandler
 // individually
 EmailPlugin.init({
   handlers: [
-    orderConfirmationHandler,
+    myOrderConfirmationHandler,
+    myPasswordResetHandler,
     emailVerificationHandler,
-    passwordResetHandler,
     emailAddressChangeHandler,
   ],
   // ...
@@ -187,10 +186,10 @@ const config: VendureConfig = {
           return injector.get(MyTransportService).getSettings(ctx);
         } else {
           return {
-             type: 'smtp',
-             host: 'smtp.example.com',
-             // ... etc.
-           }
+            type: 'smtp',
+            host: 'smtp.example.com',
+            // ... etc.
+          }
         }
       }
     }),

+ 100 - 0
docs/docs/reference/core-plugins/email-plugin/template-loader.md

@@ -0,0 +1,100 @@
+---
+title: "TemplateLoader"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## TemplateLoader
+
+<GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="392" packageName="@vendure/email-plugin" />
+
+Loads email templates based on the given request context, type and template name
+and return the template as a string.
+
+*Example*
+
+```ts
+import { EmailPlugin, TemplateLoader } from '@vendure/email-plugin';
+
+class MyTemplateLoader implements TemplateLoader {
+     loadTemplate(injector, ctx, { type, templateName }){
+         return myCustomTemplateFunction(ctx);
+     }
+}
+
+// In vendure-config.ts:
+...
+EmailPlugin.init({
+    templateLoader: new MyTemplateLoader()
+    ...
+})
+```
+
+```ts title="Signature"
+interface TemplateLoader {
+    loadTemplate(injector: Injector, ctx: RequestContext, input: LoadTemplateInput): Promise<string>;
+    loadPartials?(): Promise<Partial[]>;
+}
+```
+
+<div className="members-wrapper">
+
+### loadTemplate
+
+<MemberInfo kind="method" type={`(injector: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: LoadTemplateInput) => Promise&#60;string&#62;`}   />
+
+Load template and return it's content as a string
+### loadPartials
+
+<MemberInfo kind="method" type={`() => Promise&#60;Partial[]&#62;`}   />
+
+Load partials and return their contents.
+This method is only called during initialization, i.e. during server startup.
+
+
+</div>
+
+
+## FileBasedTemplateLoader
+
+<GenerationInfo sourceFile="packages/email-plugin/src/template-loader.ts" sourceLine="15" packageName="@vendure/email-plugin" />
+
+Loads email templates from the local file system. This is the default
+loader used by the EmailPlugin.
+
+```ts title="Signature"
+class FileBasedTemplateLoader implements TemplateLoader {
+    constructor(templatePath: string)
+    loadTemplate(_injector: Injector, _ctx: RequestContext, { type, templateName }: LoadTemplateInput) => Promise<string>;
+    loadPartials() => Promise<Partial[]>;
+}
+```
+* Implements: <code><a href='/reference/core-plugins/email-plugin/template-loader#templateloader'>TemplateLoader</a></code>
+
+
+
+<div className="members-wrapper">
+
+### constructor
+
+<MemberInfo kind="method" type={`(templatePath: string) => FileBasedTemplateLoader`}   />
+
+
+### loadTemplate
+
+<MemberInfo kind="method" type={`(_injector: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, _ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, { type, templateName }: LoadTemplateInput) => Promise&#60;string&#62;`}   />
+
+
+### loadPartials
+
+<MemberInfo kind="method" type={`() => Promise&#60;Partial[]&#62;`}   />
+
+
+
+
+</div>

+ 4 - 1
docs/sidebars.js

@@ -192,8 +192,11 @@ const sidebars = {
                     value: 'Deployment Guides',
                     className: 'sidebar-section-header',
                 },
-                'guides/deployment/deploy-to-google-cloud-run/index',
                 'guides/deployment/deploy-to-northflank/index',
+                'guides/deployment/deploy-to-digital-ocean-app-platform/index',
+                'guides/deployment/deploy-to-railway/index',
+                'guides/deployment/deploy-to-render/index',
+                'guides/deployment/deploy-to-google-cloud-run/index',
             ],
         },
     ],

+ 4 - 0
docs/src/css/custom.css

@@ -100,3 +100,7 @@ html[data-theme='dark'] {
     opacity: 0.2;
     margin: 6px 12px;
 }
+
+.limited-height-code-block pre.prism-code {
+    max-height: 800px;
+}

+ 1 - 1
package.json

@@ -12,7 +12,7 @@
     "format": "prettier --write --html-whitespace-sensitivity ignore",
     "docs:generate-typescript-docs": "ts-node scripts/docs/generate-typescript-docs.ts",
     "docs:generate-graphql-docs": "ts-node scripts/docs/generate-graphql-docs.ts --api=shop && ts-node scripts/docs/generate-graphql-docs.ts --api=admin",
-    "docs:build": "yarn docs:generate-graphql-docs && yarn docs:generate-typescript-docs",
+    "docs:build": "yarn docs:generate-typescript-docs && yarn docs:generate-graphql-docs",
     "codegen": "tsc -p scripts/codegen/plugins && ts-node scripts/codegen/generate-graphql-types.ts",
     "version": "yarn check-imports && yarn check-angular-versions && yarn build && yarn check-core-type-defs && yarn generate-changelog && git add CHANGELOG* && git add */version.ts",
     "dev-server:start": "cd packages/dev-server && yarn start",

+ 1 - 2
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts

@@ -363,8 +363,7 @@ export class ProductDetailComponent
                     return this.productDetailService.updateProduct({
                         product,
                         languageCode,
-                        autoUpdate:
-                            this.detailForm.get(['product', 'autoUpdateVariantNames'])?.value ?? false,
+                        autoUpdate: this.detailForm.get(['autoUpdateVariantNames'])?.value ?? false,
                         productInput,
                         variantsInput,
                     });

+ 14 - 5
packages/admin-ui/src/lib/order/src/components/refund-order-dialog/refund-order-dialog.component.ts

@@ -80,18 +80,21 @@ export class RefundOrderDialogComponent
                 .filter(refundLine => refundLine.orderLineId === line.id)
                 .reduce((sum, refundLine) => sum + refundLine.quantity, 0) ?? 0;
 
-        return refundedCount < line.quantity;
+        return refundedCount < line.orderPlacedQuantity;
     }
 
     ngOnInit() {
-        this.lineQuantities = this.order.lines.reduce((result, line) => ({
+        this.lineQuantities = this.order.lines.reduce(
+            (result, line) => ({
                 ...result,
                 [line.id]: {
                     quantity: 0,
                     refund: false,
                     cancel: false,
                 },
-            }), {});
+            }),
+            {},
+        );
         this.settledPayments = (this.order.payments || []).filter(p => p.state === 'Settled');
         if (this.settledPayments.length) {
             this.selectedPayment = this.settledPayments[0];
@@ -106,12 +109,18 @@ export class RefundOrderDialogComponent
     }
 
     isRefunding(): boolean {
-        const result = Object.values(this.lineQuantities).reduce((isRefunding, line) => isRefunding || (0 < line.quantity && line.refund), false);
+        const result = Object.values(this.lineQuantities).reduce(
+            (isRefunding, line) => isRefunding || (0 < line.quantity && line.refund),
+            false,
+        );
         return result;
     }
 
     isCancelling(): boolean {
-        const result = Object.values(this.lineQuantities).reduce((isCancelling, line) => isCancelling || (0 < line.quantity && line.cancel), false);
+        const result = Object.values(this.lineQuantities).reduce(
+            (isCancelling, line) => isCancelling || (0 < line.quantity && line.cancel),
+            false,
+        );
         return result;
     }
 

+ 3 - 3
packages/admin-ui/src/lib/settings/src/settings.routes.ts

@@ -56,7 +56,7 @@ export const createRoutes = (pageService: PageService): Route[] => [
         path: 'channels/:id',
         component: PageComponent,
         data: {
-            locationId: 'channel-list',
+            locationId: 'channel-detail',
             breadcrumb: { label: _('breadcrumb.channels'), link: ['../', 'channels'] },
         },
         children: pageService.getPageTabRoutes('channel-detail'),
@@ -74,7 +74,7 @@ export const createRoutes = (pageService: PageService): Route[] => [
         path: 'stock-locations/:id',
         component: PageComponent,
         data: {
-            locationId: 'stock-location-list',
+            locationId: 'stock-location-detail',
             breadcrumb: { label: _('breadcrumb.stock-locations'), link: ['../', 'stock-locations'] },
         },
         children: pageService.getPageTabRoutes('stock-location-detail'),
@@ -92,7 +92,7 @@ export const createRoutes = (pageService: PageService): Route[] => [
         path: 'sellers/:id',
         component: PageComponent,
         data: {
-            locationId: 'seller-list',
+            locationId: 'seller-detail',
             breadcrumb: { label: _('breadcrumb.sellers'), link: ['../', 'sellers'] },
         },
         children: pageService.getPageTabRoutes('seller-detail'),

+ 26 - 1
packages/core/e2e/fixtures/test-shipping-eligibility-checkers.ts

@@ -1,5 +1,5 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
-import { ShippingEligibilityChecker } from '@vendure/core';
+import { EntityHydrator, ShippingEligibilityChecker } from '@vendure/core';
 
 export const countryCodeShippingEligibilityChecker = new ShippingEligibilityChecker({
     code: 'country-code-shipping-eligibility-checker',
@@ -13,3 +13,28 @@ export const countryCodeShippingEligibilityChecker = new ShippingEligibilityChec
         return order.shippingAddress?.countryCode === args.countryCode;
     },
 });
+
+let entityHydrator: EntityHydrator;
+
+/**
+ * @description
+ * This checker does nothing except for hydrating the Order in order to test
+ * an edge-case which would cause inconsistencies when modifying the Order in which
+ * the OrderService would e.g. remove an OrderLine, but then this step would re-add it
+ * because the removal had not yet been persisted by the time the `applyPriceAdjustments()`
+ * step was run (during which this checker will run).
+ *
+ * See https://github.com/vendure-ecommerce/vendure/issues/2548
+ */
+export const hydratingShippingEligibilityChecker = new ShippingEligibilityChecker({
+    code: 'hydrating-shipping-eligibility-checker',
+    description: [{ languageCode: LanguageCode.en, value: 'Hydrating Shipping Eligibility Checker' }],
+    args: {},
+    init(injector) {
+        entityHydrator = injector.get(EntityHydrator);
+    },
+    check: async (ctx, order) => {
+        await entityHydrator.hydrate(ctx, order, { relations: ['lines.sellerChannel'] });
+        return true;
+    },
+});

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 16 - 16
packages/core/e2e/graphql/generated-e2e-shop-types.ts


+ 1 - 0
packages/core/e2e/graphql/shop-definitions.ts

@@ -44,6 +44,7 @@ export const TEST_ORDER_FRAGMENT = gql`
             }
         }
         shippingLines {
+            priceWithTax
             shippingMethod {
                 id
                 code

+ 263 - 0
packages/core/e2e/order-multiple-shipping.e2e-spec.ts

@@ -0,0 +1,263 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import { summate } from '@vendure/common/lib/shared-utils';
+import {
+    mergeConfig,
+    RequestContext,
+    ShippingLineAssignmentStrategy,
+    ShippingLine,
+    Order,
+    OrderLine,
+    manualFulfillmentHandler,
+    defaultShippingCalculator,
+    defaultShippingEligibilityChecker,
+    OrderService,
+    RequestContextService,
+} from '@vendure/core';
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
+import path from 'path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { hydratingShippingEligibilityChecker } from './fixtures/test-shipping-eligibility-checkers';
+import {
+    CreateAddressInput,
+    CreateShippingMethodDocument,
+    LanguageCode,
+} from './graphql/generated-e2e-admin-types';
+
+import * as Codegen from './graphql/generated-e2e-admin-types';
+import * as CodegenShop from './graphql/generated-e2e-shop-types';
+import { GET_COUNTRY_LIST, UPDATE_COUNTRY } from './graphql/shared-definitions';
+import {
+    ADD_ITEM_TO_ORDER,
+    GET_ACTIVE_ORDER,
+    GET_AVAILABLE_COUNTRIES,
+    GET_ELIGIBLE_SHIPPING_METHODS,
+    REMOVE_ITEM_FROM_ORDER,
+    SET_SHIPPING_ADDRESS,
+    SET_SHIPPING_METHOD,
+} from './graphql/shop-definitions';
+
+declare module '@vendure/core/dist/entity/custom-entity-fields' {
+    interface CustomShippingMethodFields {
+        minPrice: number;
+        maxPrice: number;
+    }
+}
+
+class CustomShippingLineAssignmentStrategy implements ShippingLineAssignmentStrategy {
+    assignShippingLineToOrderLines(
+        ctx: RequestContext,
+        shippingLine: ShippingLine,
+        order: Order,
+    ): OrderLine[] | Promise<OrderLine[]> {
+        const { minPrice, maxPrice } = shippingLine.shippingMethod.customFields;
+        return order.lines.filter(l => l.linePriceWithTax >= minPrice && l.linePriceWithTax <= maxPrice);
+    }
+}
+
+describe('Shop orders', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig(), {
+            customFields: {
+                ShippingMethod: [
+                    { name: 'minPrice', type: 'int' },
+                    { name: 'maxPrice', type: 'int' },
+                ],
+            },
+            shippingOptions: {
+                shippingLineAssignmentStrategy: new CustomShippingLineAssignmentStrategy(),
+            },
+        }),
+    );
+
+    type OrderSuccessResult =
+        | CodegenShop.UpdatedOrderFragment
+        | CodegenShop.TestOrderFragmentFragment
+        | CodegenShop.TestOrderWithPaymentsFragment
+        | CodegenShop.ActiveOrderCustomerFragment;
+    const orderResultGuard: ErrorResultGuard<OrderSuccessResult> = createErrorResultGuard(
+        input => !!input.lines,
+    );
+
+    let lessThan100MethodId: string;
+    let greaterThan100MethodId: string;
+    let orderService: OrderService;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 3,
+        });
+        await adminClient.asSuperAdmin();
+        orderService = server.app.get(OrderService);
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('setup shipping methods', async () => {
+        const result1 = await adminClient.query(CreateShippingMethodDocument, {
+            input: {
+                code: 'less-than-100',
+                translations: [{ languageCode: LanguageCode.en, name: 'Less than 100', description: '' }],
+                fulfillmentHandler: manualFulfillmentHandler.code,
+                checker: {
+                    code: defaultShippingEligibilityChecker.code,
+                    arguments: [{ name: 'orderMinimum', value: '0' }],
+                },
+                calculator: {
+                    code: defaultShippingCalculator.code,
+                    arguments: [
+                        { name: 'rate', value: '1000' },
+                        { name: 'taxRate', value: '0' },
+                        { name: 'includesTax', value: 'auto' },
+                    ],
+                },
+                customFields: {
+                    minPrice: 0,
+                    maxPrice: 100_00,
+                },
+            },
+        });
+
+        const result2 = await adminClient.query(CreateShippingMethodDocument, {
+            input: {
+                code: 'greater-than-100',
+                translations: [{ languageCode: LanguageCode.en, name: 'Greater than 200', description: '' }],
+                fulfillmentHandler: manualFulfillmentHandler.code,
+                checker: {
+                    code: defaultShippingEligibilityChecker.code,
+                    arguments: [{ name: 'orderMinimum', value: '0' }],
+                },
+                calculator: {
+                    code: defaultShippingCalculator.code,
+                    arguments: [
+                        { name: 'rate', value: '2000' },
+                        { name: 'taxRate', value: '0' },
+                        { name: 'includesTax', value: 'auto' },
+                    ],
+                },
+                customFields: {
+                    minPrice: 100_00,
+                    maxPrice: 500000_00,
+                },
+            },
+        });
+
+        expect(result1.createShippingMethod.id).toBe('T_3');
+        expect(result2.createShippingMethod.id).toBe('T_4');
+        lessThan100MethodId = result1.createShippingMethod.id;
+        greaterThan100MethodId = result2.createShippingMethod.id;
+    });
+
+    it('assigns shipping methods to correct order lines', async () => {
+        await shopClient.query<
+            CodegenShop.AddItemToOrderMutation,
+            CodegenShop.AddItemToOrderMutationVariables
+        >(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_1',
+            quantity: 1,
+        });
+        await shopClient.query<
+            CodegenShop.AddItemToOrderMutation,
+            CodegenShop.AddItemToOrderMutationVariables
+        >(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_11',
+            quantity: 1,
+        });
+
+        await shopClient.query<
+            CodegenShop.SetShippingAddressMutation,
+            CodegenShop.SetShippingAddressMutationVariables
+        >(SET_SHIPPING_ADDRESS, {
+            input: {
+                streetLine1: '12 the street',
+                postalCode: '123456',
+                countryCode: 'US',
+            },
+        });
+
+        const { eligibleShippingMethods } = await shopClient.query<CodegenShop.GetShippingMethodsQuery>(
+            GET_ELIGIBLE_SHIPPING_METHODS,
+        );
+
+        expect(eligibleShippingMethods.map(m => m.id).includes(lessThan100MethodId)).toBe(true);
+        expect(eligibleShippingMethods.map(m => m.id).includes(greaterThan100MethodId)).toBe(true);
+
+        const { setOrderShippingMethod } = await shopClient.query<
+            CodegenShop.SetShippingMethodMutation,
+            CodegenShop.SetShippingMethodMutationVariables
+        >(SET_SHIPPING_METHOD, {
+            id: [lessThan100MethodId, greaterThan100MethodId],
+        });
+
+        orderResultGuard.assertSuccess(setOrderShippingMethod);
+
+        const order = await getInternalOrder(setOrderShippingMethod.id);
+        expect(order?.lines[0].shippingLine?.shippingMethod.code).toBe('greater-than-100');
+        expect(order?.lines[1].shippingLine?.shippingMethod.code).toBe('less-than-100');
+
+        expect(order?.shippingLines.length).toBe(2);
+    });
+
+    it('removes shipping methods that are no longer applicable', async () => {
+        const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+
+        const { removeOrderLine } = await shopClient.query<
+            CodegenShop.RemoveItemFromOrderMutation,
+            CodegenShop.RemoveItemFromOrderMutationVariables
+        >(REMOVE_ITEM_FROM_ORDER, {
+            orderLineId: activeOrder!.lines[0].id,
+        });
+        orderResultGuard.assertSuccess(removeOrderLine);
+
+        const order = await getInternalOrder(activeOrder!.id);
+        expect(order?.lines.length).toBe(1);
+        expect(order?.shippingLines.length).toBe(1);
+        expect(order?.shippingLines[0].shippingMethod.code).toBe('less-than-100');
+
+        const { activeOrder: activeOrder2 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
+            GET_ACTIVE_ORDER,
+        );
+
+        expect(activeOrder2?.shippingWithTax).toBe(summate(activeOrder2!.shippingLines, 'priceWithTax'));
+    });
+
+    it('removes remaining shipping method when removing all items', async () => {
+        const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+
+        const { removeOrderLine } = await shopClient.query<
+            CodegenShop.RemoveItemFromOrderMutation,
+            CodegenShop.RemoveItemFromOrderMutationVariables
+        >(REMOVE_ITEM_FROM_ORDER, {
+            orderLineId: activeOrder!.lines[0].id,
+        });
+        orderResultGuard.assertSuccess(removeOrderLine);
+
+        const order = await getInternalOrder(activeOrder!.id);
+        expect(order?.lines.length).toBe(0);
+
+        const { activeOrder: activeOrder2 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
+            GET_ACTIVE_ORDER,
+        );
+
+        expect(activeOrder2?.shippingWithTax).toBe(0);
+    });
+
+    async function getInternalOrder(externalOrderId: string): Promise<Order> {
+        const ctx = await server.app.get(RequestContextService).create({ apiType: 'admin' });
+        const internalOrderId = +externalOrderId.replace('T_', '');
+        const order = await orderService.findOne(ctx, internalOrderId, [
+            'lines',
+            'lines.shippingLine',
+            'lines.shippingLine.shippingMethod',
+            'shippingLines',
+            'shippingLines.shippingMethod',
+        ]);
+        return order!;
+    }
+});

+ 18 - 0
packages/core/e2e/role.e2e-spec.ts

@@ -510,6 +510,24 @@ describe('Role resolver', () => {
             await adminClient.asUserWithCredentials(limitedAdmin.emailAddress, 'test');
         });
 
+        it('limited admin cannot view Roles which require permissions they do not have', async () => {
+            const result = await adminClient.query<Codegen.GetRolesQuery, Codegen.GetRolesQueryVariables>(
+                GET_ROLES,
+            );
+
+            const roleCodes = result.roles.items.map(r => r.code);
+            expect(roleCodes).toEqual(['second-channel-admin-manager']);
+        });
+
+        it('limited admin cannot view Role which requires permissions they do not have', async () => {
+            const result = await adminClient.query<Codegen.GetRoleQuery, Codegen.GetRoleQueryVariables>(
+                GET_ROLE,
+                { id: orderReaderRole.id },
+            );
+
+            expect(result.role).toBeNull();
+        });
+
         it(
             'limited admin cannot create Role with SuperAdmin permission',
             assertThrowsWithMessage(async () => {

+ 81 - 5
packages/core/e2e/shop-order.e2e-spec.ts

@@ -21,14 +21,18 @@ import {
     testFailingPaymentMethod,
     testSuccessfulPaymentMethod,
 } from './fixtures/test-payment-methods';
-import { countryCodeShippingEligibilityChecker } from './fixtures/test-shipping-eligibility-checkers';
+import {
+    countryCodeShippingEligibilityChecker,
+    hydratingShippingEligibilityChecker,
+} from './fixtures/test-shipping-eligibility-checkers';
 import {
     CreateAddressInput,
+    CreateShippingMethodDocument,
     CreateShippingMethodInput,
     LanguageCode,
 } from './graphql/generated-e2e-admin-types';
 import * as Codegen from './graphql/generated-e2e-admin-types';
-import { ErrorCode } from './graphql/generated-e2e-shop-types';
+import { ErrorCode, RemoveItemFromOrderDocument } from './graphql/generated-e2e-shop-types';
 import * as CodegenShop from './graphql/generated-e2e-shop-types';
 import {
     ATTEMPT_LOGIN,
@@ -83,6 +87,7 @@ describe('Shop orders', () => {
                 shippingEligibilityCheckers: [
                     defaultShippingEligibilityChecker,
                     countryCodeShippingEligibilityChecker,
+                    hydratingShippingEligibilityChecker,
                 ],
             },
             customFields: {
@@ -2226,12 +2231,12 @@ describe('Shop orders', () => {
                     id: minPriceShippingMethodId,
                 },
             );
-            const result1 = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
+            const result1 = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
             expect(result1.activeOrder?.shippingLines[0].shippingMethod.id).toBe(minPriceShippingMethodId);
 
             const { removeAllOrderLines } = await shopClient.query<
-                RemoveAllOrderLines.Mutation,
-                RemoveAllOrderLines.Variables
+                CodegenShop.RemoveAllOrderLinesMutation,
+                CodegenShop.RemoveAllOrderLinesMutationVariables
             >(REMOVE_ALL_ORDER_LINES);
             orderResultGuard.assertSuccess(removeAllOrderLines);
             expect(removeAllOrderLines.shippingLines.length).toBe(0);
@@ -2306,6 +2311,77 @@ describe('Shop orders', () => {
             expect(activeOrder.shippingAddress).toEqual(shippingAddress);
             expect(activeOrder.billingAddress).toEqual(billingAddress);
         });
+
+        // https://github.com/vendure-ecommerce/vendure/issues/2548
+        it('hydrating Order in the ShippingEligibilityChecker does not break order modification', async () => {
+            // First we'll create a ShippingMethod that uses the hydrating checker
+            await adminClient.query(CreateShippingMethodDocument, {
+                input: {
+                    code: 'hydrating-checker',
+                    translations: [
+                        { languageCode: LanguageCode.en, name: 'hydrating checker', description: '' },
+                    ],
+                    fulfillmentHandler: manualFulfillmentHandler.code,
+                    checker: {
+                        code: hydratingShippingEligibilityChecker.code,
+                        arguments: [],
+                    },
+                    calculator: {
+                        code: defaultShippingCalculator.code,
+                        arguments: [
+                            { name: 'rate', value: '1000' },
+                            { name: 'taxRate', value: '0' },
+                            { name: 'includesTax', value: 'auto' },
+                        ],
+                    },
+                },
+            });
+
+            await shopClient.asAnonymousUser();
+            await shopClient.query<
+                CodegenShop.AddItemToOrderMutation,
+                CodegenShop.AddItemToOrderMutationVariables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_1',
+                quantity: 1,
+            });
+            await shopClient.query<
+                CodegenShop.AddItemToOrderMutation,
+                CodegenShop.AddItemToOrderMutationVariables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_2',
+                quantity: 3,
+            });
+
+            const result1 = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+
+            expect(result1.activeOrder?.lines.map(l => l.linePriceWithTax).sort()).toEqual([155880, 503640]);
+            expect(result1.activeOrder?.subTotalWithTax).toBe(659520);
+
+            // set the shipping method that uses the hydrating checker
+            const { eligibleShippingMethods } = await shopClient.query<CodegenShop.GetShippingMethodsQuery>(
+                GET_ELIGIBLE_SHIPPING_METHODS,
+            );
+            const { setOrderShippingMethod } = await shopClient.query<
+                CodegenShop.SetShippingMethodMutation,
+                CodegenShop.SetShippingMethodMutationVariables
+            >(SET_SHIPPING_METHOD, {
+                id: eligibleShippingMethods.find(m => m.code === 'hydrating-checker')!.id,
+            });
+            orderResultGuard.assertSuccess(setOrderShippingMethod);
+
+            // Remove an item from the order
+            const { removeOrderLine } = await shopClient.query(RemoveItemFromOrderDocument, {
+                orderLineId: result1.activeOrder!.lines[0].id,
+            });
+            orderResultGuard.assertSuccess(removeOrderLine);
+            expect(removeOrderLine.lines.length).toBe(1);
+
+            const result2 = await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+
+            expect(result2.activeOrder?.lines.map(l => l.linePriceWithTax).sort()).toEqual([503640]);
+            expect(result2.activeOrder?.subTotalWithTax).toBe(503640);
+        });
     });
 });
 

+ 2 - 2
packages/core/src/health-check/health-check-registry.service.ts

@@ -15,8 +15,8 @@ import { HealthIndicatorFunction } from '@nestjs/terminus';
  * service to add a check for that dependency to the Vendure health check.
  *
  *
- * Since v1.6.0, the preferred way to implement a custom health check is by creating a new
- * {@link HealthCheckStrategy} and then passing it to the `systemOptions.healthChecks` array.
+ * Since v1.6.0, the preferred way to implement a custom health check is by creating a new {@link HealthCheckStrategy}
+ * and then passing it to the `systemOptions.healthChecks` array.
  * See the {@link HealthCheckStrategy} docs for an example configuration.
  *
  * The alternative way to register a health check is by injecting this service directly into your

+ 2 - 1
packages/core/src/health-check/health-check.module.ts

@@ -8,11 +8,12 @@ import { JobQueueModule } from '../job-queue/job-queue.module';
 
 import { HealthCheckRegistryService } from './health-check-registry.service';
 import { HealthController } from './health-check.controller';
+import { CustomHttpHealthIndicator } from './http-health-check-strategy';
 
 @Module({
     imports: [TerminusModule, ConfigModule, JobQueueModule],
     controllers: [HealthController],
-    providers: [HealthCheckRegistryService],
+    providers: [HealthCheckRegistryService, CustomHttpHealthIndicator],
     exports: [HealthCheckRegistryService],
 })
 export class HealthCheckModule {

+ 49 - 6
packages/core/src/health-check/http-health-check-strategy.ts

@@ -1,10 +1,11 @@
-import { HealthIndicatorFunction, HttpHealthIndicator } from '@nestjs/terminus';
+import { Injectable, Scope } from '@nestjs/common';
+import { HealthCheckError, HealthIndicatorFunction, HealthIndicatorResult } from '@nestjs/terminus';
+import { HealthIndicator } from '@nestjs/terminus/dist/health-indicator/index';
+import fetch from 'node-fetch';
 
 import { Injector } from '../common/index';
 import { HealthCheckStrategy } from '../config/system/health-check-strategy';
 
-let indicator: HttpHealthIndicator;
-
 export interface HttpHealthCheckOptions {
     key: string;
     url: string;
@@ -35,13 +36,55 @@ export interface HttpHealthCheckOptions {
  */
 export class HttpHealthCheckStrategy implements HealthCheckStrategy {
     constructor(private options: HttpHealthCheckOptions) {}
+    private injector: Injector;
 
-    async init(injector: Injector) {
-        indicator = await injector.get(HttpHealthIndicator);
+    init(injector: Injector) {
+        this.injector = injector;
     }
 
     getHealthIndicator(): HealthIndicatorFunction {
         const { key, url, timeout } = this.options;
-        return () => indicator.pingCheck(key, url, { timeout });
+        return async () => {
+            const indicator = await this.injector.resolve(CustomHttpHealthIndicator);
+            return indicator.pingCheck(key, url, timeout);
+        };
+    }
+}
+
+/**
+ * A much simplified version of the Terminus Modules' `HttpHealthIndicator` which has no
+ * dependency on the @nestjs/axios package.
+ */
+@Injectable({
+    scope: Scope.TRANSIENT,
+})
+export class CustomHttpHealthIndicator extends HealthIndicator {
+    /**
+     * Prepares and throw a HealthCheckError
+     *
+     * @throws {HealthCheckError}
+     */
+    private generateHttpError(key: string, error: any) {
+        const response: { [key: string]: any } = {
+            message: error.message,
+        };
+        if (error.response) {
+            response.statusCode = error.response.status;
+            response.statusText = error.response.statusText;
+        }
+        throw new HealthCheckError(error.message, this.getStatus(key, false, response));
+    }
+
+    async pingCheck(key: string, url: string, timeout?: number): Promise<HealthIndicatorResult> {
+        let isHealthy = false;
+
+        try {
+            await fetch(url, { timeout });
+            isHealthy = true;
+        } catch (err) {
+            this.generateHttpError(key, err);
+        }
+
+        return this.getStatus(key, isHealthy);
     }
 }

+ 7 - 0
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -306,6 +306,13 @@ export class OrderCalculator {
     }
 
     private async applyShipping(ctx: RequestContext, order: Order) {
+        // First we need to remove any ShippingLines which are no longer applicable
+        // to the Order, i.e. there is no OrderLine which is assigned to the ShippingLine's
+        // ShippingMethod.
+        const orderLineShippingLineIds = order.lines.map(line => line.shippingLineId);
+        order.shippingLines = order.shippingLines.filter(shippingLine =>
+            orderLineShippingLineIds.includes(shippingLine.id),
+        );
         for (const shippingLine of order.shippingLines) {
             const currentShippingMethod =
                 shippingLine?.shippingMethodId &&

+ 5 - 5
packages/core/src/service/services/administrator.service.ts

@@ -10,7 +10,7 @@ import { In, IsNull } from 'typeorm';
 import { RequestContext } from '../../api/common/request-context';
 import { RelationPaths } from '../../api/index';
 import { EntityNotFoundError, InternalServerError, UserInputError } from '../../common/error/errors';
-import { idsAreEqual, normalizeEmailAddress } from '../../common/index';
+import { assertFound, idsAreEqual, normalizeEmailAddress } from '../../common/index';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { ConfigService } from '../../config';
 import { TransactionalConnection } from '../../connection/transactional-connection';
@@ -317,10 +317,10 @@ export class AdministratorService {
                 superadminCredentials.identifier,
                 superadminCredentials.password,
             );
-            const createdAdministrator = await this.connection
-                .getRepository(ctx, Administrator)
-                .save(administrator);
-            await this.assignRole(ctx, createdAdministrator.id, superAdminRole.id);
+            const { id } = await this.connection.getRepository(ctx, Administrator).save(administrator);
+            const createdAdministrator = await assertFound(this.findOne(ctx, id));
+            createdAdministrator.user.roles.push(superAdminRole);
+            await this.connection.getRepository(ctx, User).save(createdAdministrator.user, { reload: false });
         } else {
             const superAdministrator = await this.connection.rawConnection
                 .getRepository(Administrator)

+ 5 - 0
packages/core/src/service/services/order.service.ts

@@ -620,6 +620,11 @@ export class OrderService {
         }
         const orderLine = this.getOrderLineOrThrow(order, orderLineId);
         order.lines = order.lines.filter(line => !idsAreEqual(line.id, orderLineId));
+        // Persist the orderLine removal before applying price adjustments
+        // so that any hydration of the Order entity during the course of the
+        // `applyPriceAdjustments()` (e.g. in a ShippingEligibilityChecker etc)
+        // will not re-add the OrderLine.
+        await this.connection.getRepository(ctx, Order).save(order, { reload: false });
         const updatedOrder = await this.applyPriceAdjustments(ctx, order);
         const deletedOrderLine = new OrderLine(orderLine);
         await this.connection.getRepository(ctx, OrderLine).remove(orderLine);

+ 33 - 5
packages/core/src/service/services/role.service.ts

@@ -72,10 +72,19 @@ export class RoleService {
         return this.listQueryBuilder
             .build(Role, options, { relations: relations ?? ['channels'], ctx })
             .getManyAndCount()
-            .then(([items, totalItems]) => ({
-                items,
-                totalItems,
-            }));
+            .then(async ([items, totalItems]) => {
+                const visibleRoles: Role[] = [];
+                for (const item of items) {
+                    const canRead = await this.activeUserCanReadRole(ctx, item);
+                    if (canRead) {
+                        visibleRoles.push(item);
+                    }
+                }
+                return {
+                    items: visibleRoles,
+                    totalItems,
+                };
+            });
     }
 
     findOne(ctx: RequestContext, roleId: ID, relations?: RelationPaths<Role>): Promise<Role | undefined> {
@@ -85,7 +94,11 @@ export class RoleService {
                 where: { id: roleId },
                 relations: relations ?? ['channels'],
             })
-            .then(result => result ?? undefined);
+            .then(async result => {
+                if (result && (await this.activeUserCanReadRole(ctx, result))) {
+                    return result;
+                }
+            });
     }
 
     getChannelsForRole(ctx: RequestContext, roleId: ID): Promise<Channel[]> {
@@ -156,6 +169,21 @@ export class RoleService {
         return false;
     }
 
+    private async activeUserCanReadRole(ctx: RequestContext, role: Role): Promise<boolean> {
+        const permissionsRequired = getChannelPermissions([role]);
+        for (const channelPermissions of permissionsRequired) {
+            const activeUserHasRequiredPermissions = await this.userHasAllPermissionsOnChannel(
+                ctx,
+                channelPermissions.id,
+                channelPermissions.permissions,
+            );
+            if (!activeUserHasRequiredPermissions) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     /**
      * @description
      * Returns true if the User has all the specified permissions on that Channel

+ 6 - 0
packages/email-plugin/src/default-email-handlers.ts

@@ -94,6 +94,9 @@ export const defaultEmailHandlers: Array<EmailEventHandler<any, any>> = [
  * applicable in this context. So we need to do it manually.
  *
  * **Note: Mutates the Order object**
+ *
+ * @docsCategory core plugins/EmailPlugin
+ * @docsPage Email utils
  */
 export function transformOrderLineAssetUrls(ctx: RequestContext, order: Order, injector: Injector): Order {
     const { assetStorageStrategy } = injector.get(ConfigService).assetOptions;
@@ -112,6 +115,9 @@ export function transformOrderLineAssetUrls(ctx: RequestContext, order: Order, i
  * @description
  * Ensures that the ShippingLines are hydrated so that we can use the
  * `shippingMethod.name` property in the email template.
+ *
+ * @docsCategory core plugins/EmailPlugin
+ * @docsPage Email utils
  */
 export async function hydrateShippingLines(
     ctx: RequestContext,

+ 11 - 12
packages/email-plugin/src/plugin.ts

@@ -43,9 +43,8 @@ import {
  * email generator to generate the email body and [Nodemailer](https://nodemailer.com/about/) to send the emails.
  *
  * ## High-level description
- * Vendure has an internal events system (see {@link EventBus}) that allows plugins to subscribe to events. The EmailPlugin is configured with
- * {@link EmailEventHandler}s that listen for a specific event and when it is published, the handler defines which template to use to generate
- * the resulting email.
+ * Vendure has an internal events system (see {@link EventBus}) that allows plugins to subscribe to events. The EmailPlugin is configured with {@link EmailEventHandler}s
+ * that listen for a specific event and when it is published, the handler defines which template to use to generate the resulting email.
  *
  * The plugin comes with a set of default handlers for the following events:
  * - Order confirmation
@@ -113,7 +112,7 @@ import {
  *
  * Dynamic data such as the recipient's name or order items are specified using [Handlebars syntax](https://handlebarsjs.com/):
  *
- * ```HTML
+ * ```html
  * <p>Dear {{ order.customer.firstName }} {{ order.customer.lastName }},</p>
  *
  * <p>Thank you for your order!</p>
@@ -161,12 +160,12 @@ import {
  *
  * // This allows you to then customize each handler to your needs.
  * // For example, let's set a new subject line to the order confirmation:
- * orderConfirmationHandler
+ * const myOrderConfirmationHandler = orderConfirmationHandler
  *   .setSubject(`We received your order!`);
  *
  * // Another example: loading additional data and setting new
  * // template variables.
- * passwordResetHandler
+ * const myPasswordResetHandler = passwordResetHandler
  *   .loadData(async ({ event, injector }) => {
  *     const customerService = injector.get(CustomerService);
  *     const customer = await customerService.findOneByUserId(event.ctx, event.user.id);
@@ -181,9 +180,9 @@ import {
  * // individually
  * EmailPlugin.init({
  *   handlers: [
- *     orderConfirmationHandler,
+ *     myOrderConfirmationHandler,
+ *     myPasswordResetHandler,
  *     emailVerificationHandler,
- *     passwordResetHandler,
  *     emailAddressChangeHandler,
  *   ],
  *   // ...
@@ -211,10 +210,10 @@ import {
  *           return injector.get(MyTransportService).getSettings(ctx);
  *         } else {
  *           return {
-                type: 'smtp',
-                host: 'smtp.example.com',
-                // ... etc.
-              }
+ *             type: 'smtp',
+ *             host: 'smtp.example.com',
+ *             // ... etc.
+ *           }
  *         }
  *       }
  *     }),

+ 16 - 9
packages/email-plugin/src/template-loader.ts

@@ -1,14 +1,19 @@
 import { Injector, RequestContext } from '@vendure/core';
 import fs from 'fs/promises';
 import path from 'path';
+
 import { LoadTemplateInput, Partial, TemplateLoader } from './types';
 
 /**
- * Loads email templates according to the configured TemplateConfig values.
+ * @description
+ * Loads email templates from the local file system. This is the default
+ * loader used by the EmailPlugin.
+ *
+ * @docsCategory core plugins/EmailPlugin
+ * @docsPage TemplateLoader
  */
 export class FileBasedTemplateLoader implements TemplateLoader {
-
-    constructor(private templatePath: string) { }
+    constructor(private templatePath: string) {}
 
     async loadTemplate(
         _injector: Injector,
@@ -22,11 +27,13 @@ export class FileBasedTemplateLoader implements TemplateLoader {
     async loadPartials(): Promise<Partial[]> {
         const partialsPath = path.join(this.templatePath, 'partials');
         const partialsFiles = await fs.readdir(partialsPath);
-        return Promise.all(partialsFiles.map(async (file) => {
-            return {
-                name: path.basename(file, '.hbs'),
-                content: await fs.readFile(path.join(partialsPath, file), 'utf-8')
-            }
-        }));
+        return Promise.all(
+            partialsFiles.map(async file => {
+                return {
+                    name: path.basename(file, '.hbs'),
+                    content: await fs.readFile(path.join(partialsPath, file), 'utf-8'),
+                };
+            }),
+        );
     }
 }

+ 6 - 3
packages/email-plugin/src/types.ts

@@ -364,7 +364,7 @@ export interface Partial {
 
 /**
  * @description
- * Load an email template based on the given request context, type and template name
+ * Loads email templates based on the given request context, type and template name
  * and return the template as a string.
  *
  * @example
@@ -386,16 +386,19 @@ export interface Partial {
  * ```
  *
  * @docsCategory core plugins/EmailPlugin
- * @docsPage Custom Template Loader
+ * @docsPage TemplateLoader
+ * @docsWeight 0
  */
 export interface TemplateLoader {
     /**
+     * @description
      * Load template and return it's content as a string
      */
     loadTemplate(injector: Injector, ctx: RequestContext, input: LoadTemplateInput): Promise<string>;
     /**
+     * @description
      * Load partials and return their contents.
-     * This method is only called during initalization, i.e. during server startup.
+     * This method is only called during initialization, i.e. during server startup.
      */
     loadPartials?(): Promise<Partial[]>;
 }

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно